diff --git a/README.md b/README.md index b91f42187cf030..aabb422a732276 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ There are many ways in which you can participate in this project, for example: * [Submit bugs and feature requests](https://github.com/microsoft/vscode/issues), and help us verify as they are checked in * Review [source code changes](https://github.com/microsoft/vscode/pulls) -* Review the [documentation](https://github.com/microsoft/vscode-docs) and make pull requests for anything from typos to and new content. +* Review the [documentation](https://github.com/microsoft/vscode-docs) and make pull requests for anything from typos to new content. If you are interested in fixing issues and contributing directly to the code base, please see the document [How to Contribute](https://github.com/microsoft/vscode/wiki/How-to-Contribute), which covers the following: diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 14b8867519d658..d5b4a0fafed0c3 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -21,6 +21,7 @@ import { getProductionDependencies } from './dependencies.ts'; import { type IExtensionDefinition, getExtensionStream } from './builtInExtensions.ts'; import { fetchUrls, fetchGithub } from './fetch.ts'; import { createTsgoStream, spawnTsgo } from './tsgo.ts'; +import watcher from './watch/index.ts'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -617,21 +618,59 @@ export async function esbuildExtensions(taskName: string, isWatch: boolean, scri // Additional projects to run esbuild on. These typically build code for webviews -const esbuildMediaScripts = [ - 'ipynb/esbuild.notebook.mts', - 'markdown-language-features/esbuild.notebook.mts', - 'markdown-language-features/esbuild.webview.mts', - 'markdown-math/esbuild.notebook.mts', - 'mermaid-markdown-features/esbuild.webview.mts', - 'notebook-renderers/esbuild.notebook.mts', - 'simple-browser/esbuild.webview.mts', +const esbuildMediaScripts: { script: string; tsconfig: string }[] = [ + { script: 'ipynb/esbuild.notebook.mts', tsconfig: 'ipynb/notebook-src/tsconfig.json' }, + { script: 'markdown-language-features/esbuild.notebook.mts', tsconfig: 'markdown-language-features/notebook/tsconfig.json' }, + { script: 'markdown-language-features/esbuild.webview.mts', tsconfig: 'markdown-language-features/preview-src/tsconfig.json' }, + { script: 'markdown-math/esbuild.notebook.mts', tsconfig: 'markdown-math/notebook/tsconfig.json' }, + { script: 'mermaid-markdown-features/esbuild.webview.mts', tsconfig: 'mermaid-markdown-features/preview-src/tsconfig.json' }, + { script: 'notebook-renderers/esbuild.notebook.mts', tsconfig: 'notebook-renderers/tsconfig.json' }, + { script: 'simple-browser/esbuild.webview.mts', tsconfig: 'simple-browser/preview-src/tsconfig.json' }, ]; export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { - return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ - script: path.join(extensionsPath, p), - outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined + const esbuildTask = esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(({ script }) => ({ + script: path.join(extensionsPath, script), + outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(script)) : undefined }))); + + const typeCheckTasks = esbuildMediaScripts.map(({ tsconfig }) => { + const tsconfigPath = path.join(extensionsPath, tsconfig); + const config = { taskName: 'typechecking extension media (tsgo)', noEmit: true }; + if (!isWatch) { + return spawnTsgo(tsconfigPath, config); + } else { + return watchTypeCheckExtensionMedia(tsconfigPath, config); + } + }); + + return Promise.all([esbuildTask, ...typeCheckTasks]).then(() => undefined); +} + +function watchTypeCheckExtensionMedia(tsconfigPath: string, config: { taskName: string; noEmit?: boolean }): Promise { + const srcDir = path.dirname(tsconfigPath); + const watchInput = watcher([ + path.join(srcDir, '**', '*.{ts,tsx,d.ts}'), + tsconfigPath, + '!' + path.join(srcDir, '**', 'node_modules', '**'), + '!' + path.join(srcDir, '**', 'out', '**'), + '!' + path.join(srcDir, '**', 'dist', '**'), + ], { cwd: root, base: srcDir, dot: true, readDelay: 200 }); + const stream = watchInput + .pipe(util2.debounce(() => { + const tsgoStream = createTsgoStream(tsconfigPath, config); + // Always emit 'end' (even on tsgo error) so the debounce resets to idle + // and can process future file changes. Errors are already logged by + // spawnTsgo's runReporter, so swallowing the stream error is safe. + const result = es.through(); + tsgoStream.on('end', () => result.emit('end')); + tsgoStream.on('error', () => result.emit('end')); + return result; + }, 200)); + + return new Promise((_resolve, reject) => { + stream.on('error', reject); + }); } export function getBuildRootsForExtension(extensionPath: string): string[] { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5980ac410ccaee..5bde2059394a9d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2263,49 +2263,49 @@ "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.individual.expired%", - "when": "github.copilot.interactiveSession.individual.expired" + "when": "github.copilot.interactiveSession.individual.expired && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.enterprise%", - "when": "github.copilot.interactiveSession.enterprise.disabled" + "when": "github.copilot.interactiveSession.enterprise.disabled && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.offline%", - "when": "github.copilot.offline" + "when": "github.copilot.offline && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.invalidToken%", - "when": "github.copilot.interactiveSession.invalidToken" + "when": "github.copilot.interactiveSession.invalidToken && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.rateLimited%", - "when": "github.copilot.interactiveSession.rateLimited" + "when": "github.copilot.interactiveSession.rateLimited && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.gitHubLoginFailed%", - "when": "github.copilot.interactiveSession.gitHubLoginFailed" + "when": "github.copilot.interactiveSession.gitHubLoginFailed && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.contactSupport%", - "when": "github.copilot.interactiveSession.contactSupport" + "when": "github.copilot.interactiveSession.contactSupport && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", "title": "%copilot.title%", "content": "%github.copilot.viewsWelcome.chatDisabled%", - "when": "github.copilot.interactiveSession.chatDisabled" + "when": "github.copilot.interactiveSession.chatDisabled && !github.copilot.hasByokModels" }, { "icon": "$(chat-sparkle)", diff --git a/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts b/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts index 43cfbbfebd0728..8d283a40762c22 100644 --- a/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts +++ b/extensions/copilot/src/extension/authentication/vscode-node/authentication.contribution.ts @@ -50,6 +50,11 @@ class AuthUpgradeAsk extends Disposable { } private async waitForChatEnabled() { + if (!this._authenticationService.anyGitHubSession) { + // BYOK / air-gapped: do not wait for a Copilot token that may never arrive. + return; + } + try { await this._authenticationService.getCopilotToken(); } catch (error) { diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts new file mode 100644 index 00000000000000..8b282e80b4ee7c --- /dev/null +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; + +const NOTIFICATION_ID = 'copilot.byokUtilityModelHint'; +const UTILITY_MODEL_SETTING = 'chat.utilityModel'; +const UTILITY_SMALL_MODEL_SETTING = 'chat.utilitySmallModel'; + +/** + * Shows a chat input notification in air-gapped BYOK scenarios (no GitHub + * session) when at least one BYOK model is available but the utility model + * settings are still defaults. The default utility models require GitHub + * Copilot access, so without it the utility slots silently fall back and + * degrade the experience until the user points them at a BYOK model. + * + * The notification hides automatically once the user signs in, BYOK models + * disappear, or both utility settings are configured. + */ +export class ByokUtilityModelNotificationContribution extends Disposable { + + private _notification: vscode.ChatInputNotification | undefined; + private _hasByokModels = false; + private _refreshing = false; + + constructor( + @IAuthenticationService private readonly _authService: IAuthenticationService, + @IConfigurationService private readonly _configService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._authService.onDidAuthenticationChange(() => this._update())); + this._register(vscode.lm.onDidChangeChatModels(() => this._update())); + this._register(this._configService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(UTILITY_MODEL_SETTING) || e.affectsConfiguration(UTILITY_SMALL_MODEL_SETTING)) { + this._update(); + } + })); + + this._update(); + } + + private async _refreshHasByokModels(): Promise { + if (this._refreshing) { + return; + } + this._refreshing = true; + try { + const models = await vscode.lm.selectChatModels({}); + this._hasByokModels = models.some(m => m.vendor !== 'copilot'); + } catch (err) { + this._logService.warn(`[ByokUtilityModelNotification] Failed to query language models: ${err}`); + } finally { + this._refreshing = false; + } + } + + private async _update(): Promise { + await this._refreshHasByokModels(); + + const signedOut = !this._authService.anyGitHubSession; + const utilityUnset = !this._isUtilityOverrideSet(UTILITY_MODEL_SETTING); + const utilitySmallUnset = !this._isUtilityOverrideSet(UTILITY_SMALL_MODEL_SETTING); + + if (!signedOut || !this._hasByokModels || (!utilityUnset && !utilitySmallUnset)) { + this._hideNotification(); + return; + } + + this._showNotification(utilityUnset, utilitySmallUnset); + } + + private _isUtilityOverrideSet(configKey: string): boolean { + const raw = this._configService.getNonExtensionConfig(configKey); + return typeof raw === 'string' && raw.length > 0; + } + + private _showNotification(utilityUnset: boolean, utilitySmallUnset: boolean): void { + const notification = this._ensureNotification(); + notification.severity = vscode.ChatInputNotificationSeverity.Info; + notification.dismissible = true; + notification.autoDismissOnMessage = false; + + if (utilityUnset && utilitySmallUnset) { + notification.message = vscode.l10n.t('Set BYOK utility models'); + notification.description = vscode.l10n.t('Unlocks full AI features.'); + notification.actions = [ + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: ['chat.utility'] }, + ]; + } else if (utilityUnset) { + notification.message = vscode.l10n.t('Set BYOK utility model'); + notification.description = vscode.l10n.t('Unlocks full AI features.'); + notification.actions = [ + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: [UTILITY_MODEL_SETTING] }, + ]; + } else { + notification.message = vscode.l10n.t('Set BYOK small utility model'); + notification.description = vscode.l10n.t('Unlocks full AI features.'); + notification.actions = [ + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: [UTILITY_SMALL_MODEL_SETTING] }, + ]; + } + + notification.show(); + } + + private _ensureNotification(): vscode.ChatInputNotification { + if (!this._notification) { + this._notification = vscode.chat.createInputNotification(NOTIFICATION_ID); + this._register({ dispose: () => this._notification?.dispose() }); + } + return this._notification; + } + + private _hideNotification(): void { + this._notification?.hide(); + } +} diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts new file mode 100644 index 00000000000000..eecc4463400388 --- /dev/null +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { Emitter } from '../../../../util/vs/base/common/event'; + +// ---- vscode mock ----------------------------------------------------------- + +const mockNotification = { + severity: 0, + dismissible: false, + autoDismissOnMessage: false, + message: '', + description: '', + actions: [] as { label: string; commandId: string; commandArgs?: unknown[] }[], + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), +}; + +const onDidChangeChatModelsEmitter = new Emitter(); +const selectChatModelsMock = vi.fn(); + +vi.mock('vscode', () => ({ + ChatInputNotificationSeverity: { Info: 1 }, + chat: { + createInputNotification: vi.fn(() => mockNotification), + }, + lm: { + get onDidChangeChatModels() { return onDidChangeChatModelsEmitter.event; }, + selectChatModels: (...args: unknown[]) => selectChatModelsMock(...args), + }, + l10n: { t: (str: string, ...args: unknown[]) => str.replace(/\{(\d+)\}/g, (_, i) => String(args[Number(i)])) }, +})); + +import { ByokUtilityModelNotificationContribution } from '../byokUtilityModel.contribution'; + +// ---- helpers --------------------------------------------------------------- + +function createAuthService(opts?: { anyGitHubSession?: unknown }) { + const emitter = new Emitter(); + const hasSession = opts && 'anyGitHubSession' in opts; + const authService = { + _serviceBrand: undefined, + anyGitHubSession: hasSession ? opts!.anyGitHubSession : undefined, + onDidAuthenticationChange: emitter.event, + } as unknown as IAuthenticationService; + return { authService, emitter }; +} + +function createConfigService(values: Record = {}) { + const emitter = new Emitter<{ affectsConfiguration: (key: string) => boolean }>(); + const store = new Map(Object.entries(values)); + const configService = { + _serviceBrand: undefined, + getNonExtensionConfig: (key: string) => store.get(key) as T | undefined, + onDidChangeConfiguration: emitter.event, + } as unknown as IConfigurationService; + const set = (key: string, value: unknown) => { + if (value === undefined) { + store.delete(key); + } else { + store.set(key, value); + } + emitter.fire({ affectsConfiguration: (k: string) => k === key }); + }; + return { configService, set }; +} + +const noopLog = { + _serviceBrand: undefined, + trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), show: vi.fn(), +} as unknown as ILogService; + +async function flushAsync() { + // Drain microtasks so async _update() observers complete. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +// ---- tests ----------------------------------------------------------------- + +describe('ByokUtilityModelNotificationContribution', () => { + let contribution: ByokUtilityModelNotificationContribution | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + mockNotification.show.mockClear(); + mockNotification.hide.mockClear(); + mockNotification.message = ''; + mockNotification.description = ''; + mockNotification.actions = []; + selectChatModelsMock.mockResolvedValue([{ vendor: 'ollama', id: 'llama3' }]); + }); + + afterEach(() => { + contribution?.dispose(); + contribution = undefined; + }); + + test('shows notification when signed out + BYOK + both utility settings unset', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).toHaveBeenCalled(); + expect(mockNotification.message).toBe('Set BYOK utility models'); + expect(mockNotification.actions).toHaveLength(1); + expect(mockNotification.actions[0].commandId).toBe('workbench.action.openSettings'); + expect(mockNotification.actions[0].commandArgs).toEqual(['chat.utility']); + }); + + test('shows notification with single action when only chat.utilityModel is unset', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService({ 'chat.utilitySmallModel': 'ollama/llama3' }); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).toHaveBeenCalled(); + expect(mockNotification.message).toBe('Set BYOK utility model'); + expect(mockNotification.actions).toHaveLength(1); + expect(mockNotification.actions[0].commandArgs).toEqual(['chat.utilityModel']); + }); + + test('does not show notification when signed in', async () => { + const { authService } = createAuthService({ anyGitHubSession: { accessToken: 'tok' } }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('does not show notification when no BYOK models are registered', async () => { + selectChatModelsMock.mockResolvedValue([{ vendor: 'copilot', id: 'gpt-4' }]); + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('does not show notification when both utility settings are configured', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService({ + 'chat.utilityModel': 'ollama/llama3', + 'chat.utilitySmallModel': 'ollama/llama3', + }); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('hides notification once both utility settings are configured', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService, set } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + expect(mockNotification.show).toHaveBeenCalled(); + + set('chat.utilityModel', 'ollama/llama3'); + await flushAsync(); + expect(mockNotification.hide).not.toHaveBeenCalled(); // small model still unset → still showing + + set('chat.utilitySmallModel', 'ollama/llama3'); + await flushAsync(); + expect(mockNotification.hide).toHaveBeenCalled(); + }); + + test('hides notification when user signs in', async () => { + const { authService, emitter } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + expect(mockNotification.show).toHaveBeenCalled(); + + (authService as unknown as { anyGitHubSession: unknown }).anyGitHubSession = { accessToken: 'tok' }; + emitter.fire(); + await flushAsync(); + + expect(mockNotification.hide).toHaveBeenCalled(); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index bc69b2c103a66e..d4dd32aefb9ef9 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -96,6 +96,10 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { } private _fetchAndCacheModels(): void { + if (!this._authenticationService.anyGitHubSession) { + this.logService.info('[CopilotCLIModels] Skipping model fetch since there is no GitHub session'); + return; + } const availableModels = this._availableModels = this._getAvailableModels(); availableModels.then(models => { // Bail out if a newer fetch has superseded this one (e.g. auth changed mid-flight). @@ -162,6 +166,9 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { } satisfies CopilotCLIModelInfo)); } catch (ex) { this.logService.error(`[CopilotCLISession] Failed to fetch models`, ex); + // Clear cached promise so subsequent calls retry instead of + // permanently returning an empty list after a transient failure. + this._availableModels = undefined; return []; } } @@ -280,8 +287,9 @@ export interface CLIAgentInfo { readonly agent: Readonly; /** File URI for prompt-file agents, synthetic `copilotcli:` URI for SDK-only agents. */ readonly sourceUri: URI; - readonly extensionId?: string; - readonly pluginUri?: URI; + readonly source: vscode.ChatResourceSource; + readonly extensionId: string | undefined; + readonly pluginUri: URI | undefined; } export interface ICopilotCLIAgents { @@ -374,7 +382,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { }); } - return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri }))); + return this._agentsPromise.then(infos => infos.map(i => ({ agent: this.cloneAgent(i.agent), sourceUri: i.sourceUri, source: i.source, extensionId: i.extensionId, pluginUri: i.pluginUri }))); } async getAgentsImpl(): Promise { @@ -435,6 +443,9 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { ...(model ? { model } : {}), }, sourceUri: customAgent.uri, + source: customAgent.source, + extensionId: customAgent.extensionId, + pluginUri: customAgent.pluginUri }; } @@ -563,7 +574,12 @@ export class CopilotCLISDK implements ICopilotCLISDK { host: 'https://github.com', copilotUser: { endpoints: { - api: overrideProxyUrl + api: overrideProxyUrl, + // `proxy` must also point at the mock server so that SDK + // calls to /copilot_internal/v2/token and /models/session + // are routed to the mock instead of the real GitHub API + // (which would reject the fake HMAC with a 401). + proxy: overrideProxyUrl, } } }; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 2b577258d037bb..4941261ff8c125 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1037,6 +1037,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS shutdown = sessionManager.closeSession(sessionId).catch(error => { this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after fetching chat history: ${error}`); }); + } catch (error) { + this.logService.error(`[CopilotCLISession] Failed to read session ${sessionId}, it may be corrupted: ${error}`); + return { history: [], events: [] }; } finally { await shutdown; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 2cfacfbbab2be2..e9bdf3f1d9c073 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -88,13 +88,14 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod */ private async getAgentItems(_token: vscode.CancellationToken): Promise { const agentInfos = await this.copilotCLIAgents.getAgents(); - return agentInfos.map(({ agent, sourceUri, pluginUri, extensionId }) => ({ + return agentInfos.map(({ agent, sourceUri, pluginUri, extensionId, source }) => ({ uri: sourceUri, type: vscode.ChatSessionCustomizationType.Agent, name: agent.displayName || agent.name, description: agent.description, extensionId, - pluginUri + pluginUri, + source })); } @@ -137,6 +138,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod name: basename(uri), description: undefined, groupKey: 'agent-instructions', + source: 'local', // these are surfaced by the extension, even if they come from the workspace extensionId: undefined, pluginUri: undefined }); @@ -172,7 +174,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod badge, badgeTooltip, extensionId: instruction.extensionId, - pluginUri: instruction.pluginUri + pluginUri: instruction.pluginUri, + source: instruction.source }); } else { items.push({ @@ -182,7 +185,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod description, groupKey: 'on-demand-instructions', extensionId: instruction.extensionId, - pluginUri: instruction.pluginUri + pluginUri: instruction.pluginUri, + source: instruction.source }); } } @@ -201,6 +205,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod description: s.description, extensionId: s.extensionId, pluginUri: s.pluginUri, + source: s.source })); } @@ -216,6 +221,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod description: undefined, extensionId: h.extensionId, pluginUri: h.pluginUri, + source: h.source })); } @@ -230,6 +236,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod description: undefined, extensionId: undefined, pluginUri: undefined, + source: 'plugin' })); } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts index 033620186371b0..26b7f5dfdb074c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -42,6 +42,9 @@ function makeAgentInfo(name: string, description = '', displayName?: string): CL return { agent: makeSweAgent(name, description, displayName), sourceUri: URI.from({ scheme: 'copilotcli', path: `/agents/${name}` }), + source: 'local', + extensionId: undefined, + pluginUri: undefined, }; } @@ -50,6 +53,9 @@ function makeFileAgentInfo(name: string, fileUri: URI, description = ''): CLIAge return { agent: makeSweAgent(name, description), sourceUri: fileUri, + source: 'local', + extensionId: undefined, + pluginUri: undefined, }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index b26f9962a5cf7f..1eb0924b21a589 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -110,6 +110,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch description: agent.description, extensionId: undefined, pluginUri: undefined, + source: 'builtin' // No groupKey — vscode infers Built-in from non-file: scheme }); } @@ -126,6 +127,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch description: agent.description, extensionId: agent.extensionId, pluginUri: agent.pluginUri, + source: agent.source }); } } @@ -150,6 +152,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch description: skill.description, extensionId: skill.extensionId, pluginUri: skill.pluginUri, + source: skill.source }; skillItems.push(item); } @@ -168,23 +171,23 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch private async discoverInstructions(): Promise { const items: vscode.ChatSessionCustomizationItem[] = []; - const candidates: URI[] = []; + const candidates: { uri: URI; source: vscode.ChatResourceSource }[] = []; for (const folder of this.workspaceService.getWorkspaceFolders()) { for (const entry of WORKSPACE_INSTRUCTION_PATHS) { if (typeof entry === 'string') { - candidates.push(URI.joinPath(folder, entry)); + candidates.push({ uri: URI.joinPath(folder, entry), source: 'local' }); } else { - candidates.push(URI.joinPath(folder, ...entry)); + candidates.push({ uri: URI.joinPath(folder, ...entry), source: 'local' }); } } } for (const entry of HOME_INSTRUCTION_PATHS) { - candidates.push(URI.joinPath(this.envService.userHome, ...entry)); + candidates.push({ uri: URI.joinPath(this.envService.userHome, ...entry), source: 'user' }); } - for (const uri of candidates) { + for (const { uri, source } of candidates) { if (await this.fileExists(uri)) { const name = basename(uri).replace(/\.md$/i, ''); items.push({ @@ -194,10 +197,10 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch description: undefined, extensionId: undefined, pluginUri: undefined, + source, }); } } - return items; } @@ -214,9 +217,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch const items: vscode.ChatSessionCustomizationItem[] = []; const settingsPaths = this.getSettingsFilePaths(); - for (const settingsUri of settingsPaths) { + for (const { uri, source } of settingsPaths) { try { - const content = await this.fileSystemService.readFile(settingsUri); + const content = await this.fileSystemService.readFile(uri); const settings: HooksSettings = JSON.parse(new TextDecoder().decode(content)); if (!settings.hooks) { continue; @@ -232,12 +235,13 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch for (const hook of matcher.hooks) { const matcherLabel = matcher.matcher === '*' ? '' : ` (${matcher.matcher})`; items.push({ - uri: settingsUri, + uri, type: vscode.ChatSessionCustomizationType.Hook, name: `${eventId}${matcherLabel}`, description: hook.command, extensionId: undefined, pluginUri: undefined, + source }); } } @@ -250,15 +254,15 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch return items; } - private getSettingsFilePaths(): URI[] { - const paths: URI[] = []; + private getSettingsFilePaths(): { uri: URI; source: vscode.ChatResourceSource }[] { + const paths: { uri: URI; source: vscode.ChatResourceSource }[] = []; for (const folder of this.workspaceService.getWorkspaceFolders()) { - paths.push(URI.joinPath(folder, '.claude', 'settings.json')); - paths.push(URI.joinPath(folder, '.claude', 'settings.local.json')); + paths.push({ uri: URI.joinPath(folder, '.claude', 'settings.json'), source: 'local' }); + paths.push({ uri: URI.joinPath(folder, '.claude', 'settings.local.json'), source: 'local' }); } - paths.push(URI.joinPath(this.envService.userHome, '.claude', 'settings.json')); + paths.push({ uri: URI.joinPath(this.envService.userHome, '.claude', 'settings.json'), source: 'user' }); return paths; } diff --git a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts index a082e4902451cf..e43c6ad91dccf6 100644 --- a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts +++ b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts @@ -31,9 +31,14 @@ export class CompletionsCoreContribution extends Disposable { const unificationStateValue = unificationState.read(reader); const configEnabled = configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsEnableGhCompletionsProvider, experimentationService).read(reader); const extensionUnification = unificationStateValue?.extensionUnification ?? false; + const copilotToken = this._copilotToken.read(reader); let hasInstantiatedProvider = false; - if (unificationStateValue?.codeUnification || extensionUnification || configEnabled || this._copilotToken.read(reader)?.isNoAuthUser) { + // Completions require a Copilot token to call the completions endpoint, so don't + // register the provider in air-gapped / signed-out scenarios — it would just fail + // with GitHubLoginFailedError on every keystroke. + const wantsProvider = unificationStateValue?.codeUnification || extensionUnification || configEnabled || copilotToken?.isNoAuthUser; + if (wantsProvider && copilotToken) { const provider = _copilotInlineCompletionItemProviderService.getOrCreateProvider(); reader.store.add( languages.registerInlineCompletionItemProvider( diff --git a/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx b/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx index 1f11ff3a750cec..db57b8976ff462 100644 --- a/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx +++ b/extensions/copilot/src/extension/context/node/resolvers/extensionApi.tsx @@ -96,6 +96,9 @@ export class VSCodeAPIContextElement extends PromptElement { const endpoint = await this.endpointProvider.getChatEndpoint(request); - const baseEndpoint = await this.endpointProvider.getChatEndpoint('copilot-utility'); // If it has a 0x multipler, it's free so don't switch them. If it's BYOK, it's free so don't switch them. if (endpoint.multiplier === 0 || request.model.vendor !== 'copilot' || endpoint.multiplier === undefined) { return request; @@ -285,6 +284,7 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c if (this._chatQuotaService.additionalUsageEnabled || !this._chatQuotaService.quotaExhausted) { return request; } + const baseEndpoint = await this.endpointProvider.getChatEndpoint('copilot-utility'); const baseLmModel = (await vscode.lm.selectChatModels({ id: baseEndpoint.model, family: baseEndpoint.family, vendor: 'copilot' }))[0]; if (!baseLmModel) { return request; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts index f2f7913837fb1f..cf6d03c886e98c 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts @@ -85,32 +85,61 @@ export class ConversationFeature implements IExtensionContribution { this._enabled = false; this._activated = false; - // Register Copilot token listener - this.registerCopilotTokenListener(); - const activationBlockerDeferred = new DeferredPromise(); this.activationBlocker = activationBlockerDeferred.p; + + // Activation and chat enablement can be driven by either a Copilot token OR the presence of a BYOK model. + let hasByokModels = false; + const reevaluate = () => { + const hasToken = !!authenticationService.copilotToken; + const shouldActivate = hasToken || hasByokModels; + if (hasToken) { + this.logService.info(`copilot token sku: ${authenticationService.copilotToken?.sku ?? ''}`); + } + this.enabled = shouldActivate; + this.activated = shouldActivate; + if (shouldActivate && !activationBlockerDeferred.isSettled) { + if (hasToken) { + markChatExtGlobal(ChatExtGlobalPerfMark.DidWaitForCopilotToken); + } + activationBlockerDeferred.complete(); + } + }; + if (authenticationService.copilotToken) { this.logService.info(`ConversationFeature: Copilot token already available`); - this.activated = true; - activationBlockerDeferred.complete(); } else { markChatExtGlobal(ChatExtGlobalPerfMark.WillWaitForCopilotToken); - this.logService.info(`ConversationFeature: Waiting for copilot token to activate conversation feature`); + this.logService.info(`ConversationFeature: Waiting for copilot token or BYOK model to activate conversation feature`); } - this._disposables.add(authenticationService.onDidAuthenticationChange(async () => { - const hasSession = !!authenticationService.copilotToken; - this.logService.info(`ConversationFeature: onDidAuthenticationChange has token: ${hasSession}`); - if (hasSession) { - markChatExtGlobal(ChatExtGlobalPerfMark.DidWaitForCopilotToken); - this.activated = true; - } else { - this.activated = false; + const refreshHasByokModels = async () => { + try { + const models = await vscode.lm.selectChatModels({}); + const value = models.some(m => m.vendor !== 'copilot'); + if (value !== hasByokModels) { + hasByokModels = value; + this.logService.info(`ConversationFeature: BYOK models ${value ? 'available' : 'unavailable'}`); + reevaluate(); + } + } catch (e) { + this.logService.warn(`ConversationFeature: failed to query language models: ${e}`); + } + }; + void refreshHasByokModels(); + this._disposables.add(vscode.lm.onDidChangeChatModels(() => void refreshHasByokModels())); + + // Always unblock activation when auth settles; chat enablement is driven by `reevaluate` independently. + // Without this, BYOK-only sessions can deadlock (the BYOK query needs this extension fully activated, + // while activation waits for the BYOK query to set `hasByokModels`). + this._disposables.add(authenticationService.onDidAuthenticationChange(() => { + reevaluate(); + if (!activationBlockerDeferred.isSettled) { + activationBlockerDeferred.complete(); } - - activationBlockerDeferred.complete(); })); + + reevaluate(); } get enabled() { @@ -170,8 +199,8 @@ export class ConversationFeature implements IExtensionContribution { } else { this._searchProviderRegistered = true; - // Don't register for no auth user - if (this.authenticationService.copilotToken?.isNoAuthUser) { + // Don't register for no auth user or BYOK-only users + if (!this.authenticationService.anyGitHubSession || this.authenticationService.copilotToken?.isNoAuthUser) { this.logService.debug('ConversationFeature: Skipping search provider registration - no GitHub session available'); return; } @@ -190,6 +219,13 @@ export class ConversationFeature implements IExtensionContribution { } this._settingsSearchProviderRegistered = true; + + // Don't register for no auth user or or BYOK-only users + if (!this.authenticationService.anyGitHubSession || this.authenticationService.copilotToken?.isNoAuthUser) { + this.logService.debug('ConversationFeature: Skipping settings search provider registration - no GitHub session available'); + return; + } + return vscode.ai.registerSettingsSearchProvider(this.settingsEditorSearchService); } @@ -217,6 +253,11 @@ export class ConversationFeature implements IExtensionContribution { } private registerParticipantDetectionProvider() { + // Many BYOK models are slow and we don't want to risk invalid detection with those, at least for now. + if (!this.authenticationService.anyGitHubSession) { + return; + } + if ('registerChatParticipantDetectionProvider' in vscode.chat) { const provider = this.instantiationService.createInstance(IntentDetector); return vscode.chat.registerChatParticipantDetectionProvider(provider); @@ -344,14 +385,6 @@ export class ConversationFeature implements IExtensionContribution { return disposables; } - private registerCopilotTokenListener() { - this._disposables.add(this.authenticationService.onDidAuthenticationChange(() => { - const chatEnabled = this.authenticationService.copilotToken !== undefined; - this.logService.info(`copilot token sku: ${this.authenticationService.copilotToken?.sku ?? ''}`); - this.enabled = chatEnabled ?? false; - })); - } - private registerTerminalQuickFixProviders() { const isEnabled = () => this.enabled; return combinedDisposable( diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 67e4d1d6e83be0..c0f2aa6c6341d8 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -572,6 +572,11 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } private async _getToken(): Promise { + if (!this._authenticationService.anyGitHubSession) { + this._logService.warn('[LanguageModelAccess] LanguageModel/Embeddings are not available without auth session'); + return undefined; + } + try { const copilotToken = await this._authenticationService.getCopilotToken(); return copilotToken; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts index 90afbdcb4fab42..4b7ba413009bb2 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/conversationFeature.test.ts @@ -135,4 +135,43 @@ suite('Conversation feature test suite', function () { conversationFeature.dispose(); } }); + + test('The feature activates without a Copilot token when a non-copilot (BYOK) language model is available', async function () { + sandbox.stub(vscode.lm, 'selectChatModels').resolves([ + { vendor: 'ollama', id: 'llama3', name: 'llama3', family: 'llama3' } as unknown as vscode.LanguageModelChat + ]); + sandbox.stub(vscode.lm, 'onDidChangeChatModels').returns({ dispose: () => { } }); + + const conversationFeature = instaService.createInstance(ConversationFeature); + try { + // No Copilot token is set; activation and enablement should be driven by BYOK availability. + await conversationFeature.activationBlocker; + assert.deepStrictEqual(conversationFeature.activated, true); + assert.deepStrictEqual(conversationFeature.enabled, true); + } finally { + conversationFeature.dispose(); + } + }); + + test('activationBlocker resolves on an auth change even when the BYOK query never settles', async function () { + // Reproduces the air-gapped startup deadlock: the BYOK detection query (which itself + // activates this extension's language-model providers) can hang until extension + // activation completes, while extension activation is waiting for `activationBlocker`. + // The auth-change event must unconditionally unblock activation regardless of token + // or BYOK availability. + sandbox.stub(vscode.lm, 'selectChatModels').returns(new Promise(() => { /* never resolves */ })); + sandbox.stub(vscode.lm, 'onDidChangeChatModels').returns({ dispose: () => { } }); + + const conversationFeature = instaService.createInstance(ConversationFeature); + try { + const authService = accessor.get(IAuthenticationService) as unknown as { fireAuthenticationChange(source: string): void }; + authService.fireAuthenticationChange('test'); + + await conversationFeature.activationBlocker; + assert.deepStrictEqual(conversationFeature.activated, false); + assert.deepStrictEqual(conversationFeature.enabled, false); + } finally { + conversationFeature.dispose(); + } + }); }); diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 778eab94c7d557..0c4579083e3c2f 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -17,6 +17,7 @@ import { IExtensionContributionFactory, asContributionFactory } from '../../comm import { CompletionsUnificationContribution } from '../../completions/vscode-node/completionsUnificationContribution'; import { ConfigurationMigrationContribution } from '../../configuration/vscode-node/configurationMigration'; import { ContextKeysContribution } from '../../contextKeys/vscode-node/contextKeys.contribution'; +import { ByokUtilityModelNotificationContribution } from '../../chatInputNotification/vscode-node/byokUtilityModel.contribution'; import { ChatInputNotificationContribution } from '../../chatInputNotification/vscode-node/chatInputNotification.contribution'; import { AiMappedEditsContrib } from '../../conversation/vscode-node/aiMappedEditsContrib'; import { ConversationFeature } from '../../conversation/vscode-node/conversationFeature'; @@ -76,6 +77,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(PowerStateLogger), asContributionFactory(ContextKeysContribution), asContributionFactory(ChatInputNotificationContribution), + asContributionFactory(ByokUtilityModelNotificationContribution), asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), asContributionFactory(LanguageModelAccess), diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index bc5a3920c3acb5..b3eec523548179 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -218,8 +218,13 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { } else { let tokenCountPromise: Promise | undefined; const countTokens = () => tokenCountPromise ??= chatEndpoint.acquireTokenizer().countMessagesTokens(messages); - const copilotToken = await this._authenticationService.getCopilotToken(); - usernameToScrub = copilotToken.username; + let copilotToken: CopilotToken | undefined; + try { + copilotToken = await this._authenticationService.getCopilotToken(); + } catch { + // BYOK / air-gapped: no Copilot token available. Continue without one. + } + usernameToScrub = copilotToken?.username ?? this._authenticationService.copilotToken?.username; const fetchResult = await this._fetchAndStreamChat( chatEndpoint, @@ -886,7 +891,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, secretKey: string | undefined, - copilotToken: CopilotToken, + copilotToken: CopilotToken | undefined, location: ChatLocation, ourRequestId: string, nChoices: number | undefined, @@ -965,7 +970,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, secretKey: string | undefined, - copilotToken: CopilotToken, + copilotToken: CopilotToken | undefined, location: ChatLocation, ourRequestId: string, nChoices: number | undefined, @@ -1020,9 +1025,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._logService.debug(`modelMaxResponseTokens ${request.max_tokens ?? 2048}`); this._logService.debug(`chat model ${chatEndpointInfo.model}`); - secretKey ??= copilotToken.token; - if (!secretKey) { - // If no key is set we error + secretKey ??= copilotToken?.token; + // BYOK endpoints may not need a secret key (e.g., Ollama local), they use getExtraHeaders instead. + if (!secretKey && !chatEndpointInfo.getExtraHeaders) { const urlOrRequestMetadata = stringifyUrlOrRequestMetadata(chatEndpointInfo.urlOrRequestMetadata); this._logService.error(`Failed to send request to ${urlOrRequestMetadata} due to missing key`); sendCommunicationErrorTelemetry(this._telemetryService, `Failed to send request to ${urlOrRequestMetadata} due to missing key`); @@ -1102,7 +1107,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { request: IEndpointBody, baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, - secretKey: string, + secretKey: string | undefined, location: ChatLocation, ourRequestId: string, turnId: string, @@ -1118,7 +1123,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const intent = locationToIntent(location); const agentInteractionType = interactionTypeOverride ?? intent; const additionalHeaders: Record = { - 'Authorization': `Bearer ${secretKey}`, + ...(secretKey ? { 'Authorization': `Bearer ${secretKey}` } : {}), 'X-Request-Id': ourRequestId, 'OpenAI-Intent': intent, 'X-GitHub-Api-Version': '2025-05-01', @@ -1288,7 +1293,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { request: IEndpointBody, baseTelemetryData: TelemetryData, finishedCb: FinishedCallback, - secretKey: string, + secretKey: string | undefined, location: ChatLocation, ourRequestId: string, nChoices: number | undefined, @@ -1405,7 +1410,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { chatEndpoint: IChatEndpoint, ourRequestId: string, request: IEndpointBody, - secretKey: string, + secretKey: string | undefined, location: ChatLocation, cancellationToken: CancellationToken, userInitiatedRequest?: boolean, diff --git a/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts b/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts index 3b4506792e0427..0e01cadaf5c05f 100644 --- a/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts +++ b/extensions/copilot/src/extension/prompts/node/base/promptRenderer.ts @@ -213,9 +213,32 @@ export async function renderPromptElementJSON

( // todo@lramos15: We should pass in endpoint provider rather than doing invoke function, but this was easier const endpoint = await instantiationService.invokeFunction(async (accessor) => { const endpointProvider = accessor.get(IEndpointProvider); - return await endpointProvider.getChatEndpoint('copilot-utility'); + try { + return await endpointProvider.getChatEndpoint('copilot-utility'); + } catch { + // JSON rendering issues no chat requests; fall back to a stub so + // tools keep working when no utility model is available. + return createStubPromptEndpoint(); + } }); const hydratedInstaService = instantiationService.createChild(new ServiceCollection([IPromptEndpoint, endpoint])); const renderer = new PromptRendererForJSON(ctor as any, props, tokenOptions, endpoint, hydratedInstaService); return await renderer.renderElementJSON(token); } + +function createStubPromptEndpoint(): IChatEndpoint { + const notImplemented = () => { throw new Error('No utility model available.'); }; + return { + modelMaxPromptTokens: 8192, + name: 'utility', + family: 'unknown', + model: 'copilot-utility', + isFallback: true, + acquireTokenizer: notImplemented, + makeChatRequest: notImplemented, + makeChatRequest2: notImplemented, + createRequestBody: notImplemented, + cloneWithTokenOverride: notImplemented, + processResponseFromChatEndpoint: notImplemented, + } as unknown as IChatEndpoint; +} diff --git a/extensions/copilot/src/extension/prompts/node/base/test/promptRenderer.spec.ts b/extensions/copilot/src/extension/prompts/node/base/test/promptRenderer.spec.ts new file mode 100644 index 00000000000000..82ee938372b721 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/base/test/promptRenderer.spec.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from 'vitest'; +import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider'; +import { Event } from '../../../../../util/vs/base/common/event'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { CompositeElement } from '../common'; +import { renderPromptElementJSON } from '../promptRenderer'; + +class ThrowingEndpointProvider implements IEndpointProvider { + declare readonly _serviceBrand: undefined; + readonly onDidModelsRefresh = Event.None; + async getChatEndpoint(): Promise { throw new Error('no utility model'); } + async getEmbeddingsEndpoint(): Promise { throw new Error('not implemented'); } + async getAllChatEndpoints(): Promise { return []; } + async getAllCompletionModels(): Promise { return []; } +} + +describe('renderPromptElementJSON', () => { + test('falls back to a stub endpoint when no utility model is available', async () => { + const testingServiceCollection = createExtensionUnitTestingServices(); + testingServiceCollection.define(IEndpointProvider, new ThrowingEndpointProvider()); + const accessor = testingServiceCollection.createTestingAccessor(); + + const result = await renderPromptElementJSON( + accessor.get(IInstantiationService), + CompositeElement, + {}, + ); + + expect(result.node).toBeDefined(); + }); +}); diff --git a/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx b/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx index 1f4c26d300fede..c2865f146940fe 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/newWorkspace/newWorkspace.tsx @@ -106,6 +106,9 @@ export class NewWorkspacePrompt extends PromptElement 0) { diff --git a/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx b/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx index 1b917dc99af683..4c17b93517398c 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/vscode.tsx @@ -140,6 +140,9 @@ export class VscodePrompt extends PromptElement => { const stream = new SpyChatResponseStream(); - const testRequest = new TestChatRequest(`You must use the get_errors tool to check the window for errors. It may fail, that's ok, just testing, don't retry.`); + // Keep the instruction unconditional. Any hedging language ("it may + // fail, that's ok", "it's fine if it errors") gives newer models cover + // to skip the invocation entirely and just narrate a plausible failure. + const testRequest = new TestChatRequest(`Call the get_errors tool now to check the current window for errors. You must invoke the tool exactly once, then reply with a brief summary of whatever it returned.`); testRequest.tools.set(ContributedToolName.GetErrors, true); const interactiveSession = instaService.createInstance(ChatParticipantRequestHandler, [], testRequest, stream, fakeToken, { agentName: '', agentId: '', intentId: Intent.Agent }, () => false, undefined); diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index 7a2acb01683c46..5e535b5fceebc5 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -428,8 +428,8 @@ function getErrorDetailsFromChatFetchErrorInner(fetchResult: ChatFetchError, cop case ChatFetchResponseType.BadRequest: case ChatFetchResponseType.Failed: details = fetchResult.serverRequestId - ? { message: l10n.t(`Sorry, your request failed. Please try again.\n\nCopilot Request id: {0}\n\nGH Request Id: {1}\n\nReason: {2}`, fetchResult.requestId, fetchResult.serverRequestId, fetchResult.reason) } - : { message: l10n.t(`Sorry, your request failed. Please try again.\n\nCopilot Request id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; + ? { message: l10n.t(`Sorry, your request failed. Please try again.\n\nClient Request Id: {0}\n\nGH Request Id: {1}\n\nReason: {2}`, fetchResult.requestId, fetchResult.serverRequestId, fetchResult.reason) } + : { message: l10n.t(`Sorry, your request failed. Please try again.\n\nClient Request Id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; break; case ChatFetchResponseType.NetworkError: details = { message: l10n.t(`Sorry, there was a network error. Please try again later. Request id: {0}\n\nReason: {1}`, fetchResult.requestId, fetchResult.reason) }; diff --git a/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts b/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts index 15f9771648a262..4cd2aabee4d616 100644 --- a/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts +++ b/extensions/copilot/src/platform/embeddings/common/remoteEmbeddingsComputer.ts @@ -64,6 +64,10 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer { }); try { return await logExecTime(this._logService, 'RemoteEmbeddingsComputer::computeEmbeddings', async () => { + // The remote embeddings endpoint requires GitHub authentication. + if (!this._authService.anyGitHubSession) { + return { type: embeddingType, values: [] }; + } // Determine endpoint type: use CAPI for no-auth users, otherwise use GitHub const copilotToken = await this._authService.getCopilotToken(); diff --git a/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts b/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts index b28243818078a9..be2b2ba7b4d1c3 100644 --- a/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts +++ b/extensions/copilot/src/platform/embeddings/common/vscodeIndex.ts @@ -115,6 +115,10 @@ abstract class RelatedInformationProviderEmbeddingsIndex new UrlContent(file.uri, file.content)), EmbeddingsComputeQos.Batch, token) + this.getEmbeddingsForFiles(authToken, embeddingType, files.map(file => new UrlContent(file.uri, file.content)), EmbeddingsComputeQos.Batch, token) ]), token); + if (!queryEmbedding) { + return files.map(() => []); + } + return this.computeChunkScores(fileChunksAndEmbeddings, queryEmbedding); } - private async computeEmbeddings(embeddingType: EmbeddingType, str: string, inputType: EmbeddingInputType, token: CancellationToken): Promise { + private async computeEmbeddings(embeddingType: EmbeddingType, str: string, inputType: EmbeddingInputType, token: CancellationToken): Promise { const embeddings = await this._embeddingsComputer.computeEmbeddings(embeddingType, [str], { inputType }, new TelemetryCorrelationId('UrlChunkEmbeddingsIndex::computeEmbeddings'), token); return embeddings.values[0]; } - private async getEmbeddingsForFiles(embeddingType: EmbeddingType, files: readonly UrlContent[], qos: EmbeddingsComputeQos, token: CancellationToken): Promise<(readonly FileChunkWithEmbedding[])[]> { + private async getEmbeddingsForFiles(authToken: string, embeddingType: EmbeddingType, files: readonly UrlContent[], qos: EmbeddingsComputeQos, token: CancellationToken): Promise<(readonly FileChunkWithEmbedding[])[]> { if (!files.length) { return []; } const batchInfo = new ComputeBatchInfo(); - this._logService.trace(`urlChunkEmbeddingsIndex: Getting auth token `); - const authToken = await this.tryGetAuthToken(); - if (!authToken) { - this._logService.error('urlChunkEmbeddingsIndex: Unable to get auth token'); - throw new Error('Unable to get auth token'); - } - const result = await Promise.all(files.map(async file => { const result = await this.getChunksAndEmbeddings(authToken, embeddingType, file, batchInfo, qos, token); - if (!result) { - return []; - } - return result; + return result ?? []; })); return result; } @@ -155,4 +158,4 @@ class SimpleUrlContentCache { const hash = await content.getContentHash(); this._cache.set(content.uri, { hash, value }); } -} \ No newline at end of file +} diff --git a/extensions/ipynb/notebook-src/tsconfig.json b/extensions/ipynb/notebook-src/tsconfig.json new file mode 100644 index 00000000000000..320321509ef211 --- /dev/null +++ b/extensions/ipynb/notebook-src/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "../notebook-out/", + "module": "es2020", + "lib": [ + "ES2024", + "DOM", + "DOM.Iterable" + ], + "types": [], + "skipLibCheck": true + } +} diff --git a/extensions/markdown-language-features/notebook/index.ts b/extensions/markdown-language-features/notebook/index.ts index 3d11a7574bd019..dd3387236f48ea 100644 --- a/extensions/markdown-language-features/notebook/index.ts +++ b/extensions/markdown-language-features/notebook/index.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import DOMPurify from 'dompurify'; +import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify'; import MarkdownIt from 'markdown-it'; +import type Token from 'markdown-it/lib/token.mjs'; import type { ActivationFunction } from 'vscode-notebook-renderer'; const allowedHtmlTags = Object.freeze(['a', @@ -121,7 +122,7 @@ const allowedSvgTags = Object.freeze([ 'vkern', ]); -const sanitizerOptions: DOMPurify.Config = { +const sanitizerOptions: DOMPurifyConfig = { ALLOWED_TAGS: [ ...allowedHtmlTags, ...allowedSvgTags, @@ -355,8 +356,8 @@ function addNamedHeaderRendering(md: MarkdownIt): void { const slugCounter = new Map(); const originalHeaderOpen = md.renderer.rules.heading_open; - md.renderer.rules.heading_open = (tokens: MarkdownIt.Token[], idx: number, options, env, self) => { - const title = tokens[idx + 1].children!.reduce((acc, t) => acc + t.content, ''); + md.renderer.rules.heading_open = (tokens: Token[], idx: number, options, env, self) => { + const title = tokens[idx + 1].children!.reduce((acc: string, t: Token) => acc + t.content, ''); let slug = slugify(title); if (slugCounter.has(slug)) { @@ -386,7 +387,7 @@ function addNamedHeaderRendering(md: MarkdownIt): void { function addLinkRenderer(md: MarkdownIt): void { const original = md.renderer.rules.link_open; - md.renderer.rules.link_open = (tokens: MarkdownIt.Token[], idx: number, options, env, self) => { + md.renderer.rules.link_open = (tokens: Token[], idx: number, options, env, self) => { const token = tokens[idx]; const href = token.attrGet('href'); if (typeof href === 'string' && href.startsWith('#')) { diff --git a/extensions/markdown-language-features/notebook/tsconfig.json b/extensions/markdown-language-features/notebook/tsconfig.json index 690bdf18b13495..c77f1df13e9d7b 100644 --- a/extensions/markdown-language-features/notebook/tsconfig.json +++ b/extensions/markdown-language-features/notebook/tsconfig.json @@ -3,17 +3,17 @@ "compilerOptions": { "rootDir": ".", "outDir": "./dist/", - "jsx": "react", "module": "esnext", + "moduleResolution": "bundler", "lib": [ "ES2024", "DOM", "DOM.Iterable" ], "types": [], - "typeRoots": [ - "../node_modules/@types" - ], "skipLibCheck": true - } + }, + "include": [ + "./**/*.ts" + ] } diff --git a/extensions/markdown-math/notebook/tsconfig.json b/extensions/markdown-math/notebook/tsconfig.json index 27545a8dcafb3d..b7ad1e77bde16d 100644 --- a/extensions/markdown-math/notebook/tsconfig.json +++ b/extensions/markdown-math/notebook/tsconfig.json @@ -1,9 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "./src", + "rootDir": ".", "outDir": "./dist/", - "jsx": "react", "module": "es2020", "lib": [ "ES2024", @@ -13,8 +12,6 @@ "types": [ "node" ], - "typeRoots": [ - "../node_modules/@types" - ] + "skipLibCheck": true } } diff --git a/extensions/mermaid-markdown-features/package.json b/extensions/mermaid-markdown-features/package.json index eb52dbb685d5b5..4058b7f97801af 100644 --- a/extensions/mermaid-markdown-features/package.json +++ b/extensions/mermaid-markdown-features/package.json @@ -82,26 +82,44 @@ "order": 0, "type": "string", "enum": [ + "vscode", "base", "forest", "dark", "default", "neutral" ], - "default": "default", + "enumDescriptions": [ + "%config.markdown-mermaid.theme.vscode.description%", + "%config.markdown-mermaid.theme.base.description%", + "%config.markdown-mermaid.theme.forest.description%", + "%config.markdown-mermaid.theme.dark.description%", + "%config.markdown-mermaid.theme.default.description%", + "%config.markdown-mermaid.theme.neutral.description%" + ], + "default": "vscode", "description": "%config.markdown-mermaid.lightModeTheme.description%" }, "markdown-mermaid.darkModeTheme": { "order": 1, "type": "string", "enum": [ + "vscode", "base", "forest", "dark", "default", "neutral" ], - "default": "dark", + "enumDescriptions": [ + "%config.markdown-mermaid.theme.vscode.description%", + "%config.markdown-mermaid.theme.base.description%", + "%config.markdown-mermaid.theme.forest.description%", + "%config.markdown-mermaid.theme.dark.description%", + "%config.markdown-mermaid.theme.default.description%", + "%config.markdown-mermaid.theme.neutral.description%" + ], + "default": "vscode", "description": "%config.markdown-mermaid.darkModeTheme.description%" }, "markdown-mermaid.languages": { @@ -170,6 +188,7 @@ { "id": "vscode.markdown-it.mermaid-extension", "displayName": "Markdown-It Mermaid Renderer", + "requiresMessaging": "optional", "entrypoint": { "extends": "vscode.markdown-it-renderer", "path": "./notebook-out/index.js" diff --git a/extensions/mermaid-markdown-features/package.nls.json b/extensions/mermaid-markdown-features/package.nls.json index ea7d4adb72f8c3..64bd5c7fd42b47 100644 --- a/extensions/mermaid-markdown-features/package.nls.json +++ b/extensions/mermaid-markdown-features/package.nls.json @@ -7,6 +7,12 @@ "config.title": "Mermaid", "config.markdown-mermaid.lightModeTheme.description": "Default Mermaid theme for light mode.", "config.markdown-mermaid.darkModeTheme.description": "Default Mermaid theme for dark mode.", + "config.markdown-mermaid.theme.vscode.description": "Mermaid theme derived from the current VS Code color theme.", + "config.markdown-mermaid.theme.base.description": "Built-in Mermaid theme. The only Mermaid theme that can be customized with theme variables.", + "config.markdown-mermaid.theme.forest.description": "Built-in Mermaid theme using shades of green.", + "config.markdown-mermaid.theme.dark.description": "Built-in Mermaid theme for dark backgrounds.", + "config.markdown-mermaid.theme.default.description": "The default built-in Mermaid theme. Works well with light backgrounds.", + "config.markdown-mermaid.theme.neutral.description": "Built-in Mermaid theme using a neutral grayscale palette. Suitable for black and white prints.", "config.markdown-mermaid.languages.description": "Default languages in Markdown.", "config.markdown-mermaid.maxTextSize.description": "The maximum allowed size of the user's text diagram.", "config.markdown-mermaid.mouseNavigation.enabled.description": "Controls when mouse-based navigation is enabled on Mermaid diagrams.", diff --git a/extensions/mermaid-markdown-features/preview-src/chat/index-editor.ts b/extensions/mermaid-markdown-features/preview-src/chat/index-editor.ts index 354e5aadadab18..557a4e21812826 100644 --- a/extensions/mermaid-markdown-features/preview-src/chat/index-editor.ts +++ b/extensions/mermaid-markdown-features/preview-src/chat/index-editor.ts @@ -13,7 +13,7 @@ initializeMermaidWebview(vscode, { defaultView: 'fit' }).then(panZoomHandler => return; } - const stopClickForEditMode = (e: MouseEvent) => { + const stopClickForEditMode = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; diff --git a/extensions/mermaid-markdown-features/preview-src/chat/mermaidWebview.ts b/extensions/mermaid-markdown-features/preview-src/chat/mermaidWebview.ts index 88554253fad8ca..1ed47ba5973059 100644 --- a/extensions/mermaid-markdown-features/preview-src/chat/mermaidWebview.ts +++ b/extensions/mermaid-markdown-features/preview-src/chat/mermaidWebview.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import mermaid, { MermaidConfig } from 'mermaid'; -import { createMermaidErrorElement, markVsCodeContextAsError } from '../shared'; +import { buildMermaidConfig, createMermaidErrorElement, loadExtensionConfig, markVsCodeContextAsError } from '../shared'; +import { VsCodeMermaidThemeTracker } from '../shared/vsCodeTheme'; import { VsCodeApi } from './vscodeApi'; interface PanZoomState { @@ -377,18 +378,11 @@ export class PanZoomHandler { } } -export function getMermaidTheme(): 'dark' | 'default' { - return document.body.classList.contains('vscode-dark') || (document.body.classList.contains('vscode-high-contrast') && !document.body.classList.contains('vscode-high-contrast-light')) - ? 'dark' - : 'default'; -} - /** * Unpersisted state */ interface LocalState { readonly mermaidSource: string; - readonly theme: 'dark' | 'default'; } interface PersistedState { @@ -402,14 +396,12 @@ interface PersistedState { async function rerenderMermaidDiagram( diagramElement: HTMLElement, diagramText: string, - newTheme: 'dark' | 'default' + themeTracker: VsCodeMermaidThemeTracker, ): Promise { diagramElement.textContent = diagramText; delete diagramElement.dataset.processed; - mermaid.initialize({ - theme: newTheme, - }); + mermaid.initialize(buildMermaidConfig(loadExtensionConfig(), themeTracker)); await mermaid.run({ nodes: [diagramElement] }); @@ -422,11 +414,10 @@ export async function initializeMermaidWebview(vscode: VsCodeApi, options?: PanZ } // Capture diagram state - const theme = getMermaidTheme(); const diagramText = diagram.textContent ?? ''; - let state: LocalState = { + const themeTracker = new VsCodeMermaidThemeTracker(); + const state: LocalState = { mermaidSource: diagramText, - theme }; // Save the mermaid source in the webview state @@ -449,11 +440,8 @@ export async function initializeMermaidWebview(vscode: VsCodeApi, options?: PanZ content.appendChild(diagram); wrapper.appendChild(content); - // Run mermaid - const config: MermaidConfig = { - startOnLoad: false, - theme, - }; + // Run mermaid using the selected VS Code-themed config + const config: MermaidConfig = buildMermaidConfig(loadExtensionConfig(), themeTracker); mermaid.initialize(config); try { await mermaid.run({ nodes: [diagram] }); @@ -481,25 +469,18 @@ export async function initializeMermaidWebview(vscode: VsCodeApi, options?: PanZ } }); - // Re-render when theme changes - new MutationObserver(() => { - const newTheme = getMermaidTheme(); - if (state?.theme === newTheme) { - return; - } - + // Re-render when the active VS Code theme changes. The tracker watches DOM mutations on the + // body (theme class / data attributes) and the document element (inline CSS variable updates + // from `workbench.colorCustomizations`), and only fires when the resolved colors actually + // change. + themeTracker.onDidChange(() => { const diagramNode = document.querySelector('.mermaid'); - if (!diagramNode || !(diagramNode instanceof HTMLElement)) { + if (!(diagramNode instanceof HTMLElement)) { return; } - - state = { - mermaidSource: state?.mermaidSource ?? '', - theme: newTheme - }; - - rerenderMermaidDiagram(diagramNode, state.mermaidSource, newTheme); - }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + rerenderMermaidDiagram(diagramNode, state.mermaidSource, themeTracker); + }); + themeTracker.observeDomChanges(); return panZoomHandler; } diff --git a/extensions/mermaid-markdown-features/preview-src/markdown/index.ts b/extensions/mermaid-markdown-features/preview-src/markdown/index.ts index 71d8ea184e7b10..7edfb77f8c8d60 100644 --- a/extensions/mermaid-markdown-features/preview-src/markdown/index.ts +++ b/extensions/mermaid-markdown-features/preview-src/markdown/index.ts @@ -3,13 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import mermaid, { MermaidConfig } from 'mermaid'; -import { loadExtensionConfig, registerMermaidAddons, renderMermaidBlocksInElement } from '../shared'; +import { buildMermaidConfig, loadExtensionConfig, registerMermaidAddons, renderMermaidBlocksInElement } from '../shared'; import { DiagramManager } from '../shared/diagramManager'; import { IDisposable } from '../shared/disposable'; +import { VsCodeMermaidThemeTracker } from '../shared/vsCodeTheme'; let currentAbortController: AbortController | undefined; let currentDisposables: IDisposable[] = []; const diagramManager = new DiagramManager(loadExtensionConfig()); +const themeTracker = new VsCodeMermaidThemeTracker(); async function init() { for (const disposable of currentDisposables) { @@ -22,15 +24,16 @@ async function init() { currentAbortController = new AbortController(); const signal = currentAbortController.signal; + // `vscode.markdown.updateContent` fires after theme switches refresh the preview, so resolve + // the theme variables from the live CSS variables before rebuilding mermaid's config. + themeTracker.refresh(); + const extConfig = loadExtensionConfig(); diagramManager.updateConfig(extConfig); const config: MermaidConfig = { - startOnLoad: false, + ...buildMermaidConfig(extConfig, themeTracker), maxTextSize: extConfig.maxTextSize, - theme: (document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast') - ? extConfig.darkModeTheme - : extConfig.lightModeTheme) as MermaidConfig['theme'], }; mermaid.initialize(config); diff --git a/extensions/mermaid-markdown-features/preview-src/notebook/index.ts b/extensions/mermaid-markdown-features/preview-src/notebook/index.ts index e271aac2a5b3e0..616659e273c079 100644 --- a/extensions/mermaid-markdown-features/preview-src/notebook/index.ts +++ b/extensions/mermaid-markdown-features/preview-src/notebook/index.ts @@ -6,8 +6,9 @@ import type MarkdownIt from 'markdown-it'; import mermaid from 'mermaid'; import type { RendererContext } from 'vscode-notebook-renderer'; import { extendMarkdownItWithMermaid } from '../../src/markdownMermaid/markdownIt'; -import { loadExtensionConfig, loadMermaidConfig, registerMermaidAddons, renderMermaidBlocksInElement } from '../shared'; +import { buildMermaidConfig, loadExtensionConfig, registerMermaidAddons, renderMermaidBlocksInElement } from '../shared'; import { DiagramManager } from '../shared/diagramManager'; +import { VsCodeMermaidThemeTracker } from '../shared/vsCodeTheme'; interface MarkdownItRenderer { extendMarkdownIt(fn: (md: MarkdownIt) => void): void; @@ -19,7 +20,8 @@ export async function activate(ctx: RendererContext) { throw new Error(`Could not load 'vscode.markdown-it-renderer'`); } - mermaid.initialize(loadMermaidConfig()); + const themeTracker = new VsCodeMermaidThemeTracker(); + mermaid.initialize(buildMermaidConfig(loadExtensionConfig(), themeTracker)); await registerMermaidAddons(); markdownItRenderer.extendMarkdownIt((md: MarkdownIt) => { @@ -32,7 +34,12 @@ export async function activate(ctx: RendererContext) { const result = render.call(this, tokens, options, env); const shadowRoot = document.getElementById(env?.outputItem.id)?.shadowRoot; - diagramManager.updateConfig(loadExtensionConfig()); + // The active VS Code theme may have changed since the last render, so re-resolve + // the theme variables before reinitializing mermaid for this batch of diagrams. + const extensionConfig = loadExtensionConfig(); + themeTracker.refresh(); + mermaid.initialize(buildMermaidConfig(extensionConfig, themeTracker)); + diagramManager.updateConfig(extensionConfig); const temp = document.createElement('div'); temp.innerHTML = result; diff --git a/extensions/mermaid-markdown-features/preview-src/shared/index.ts b/extensions/mermaid-markdown-features/preview-src/shared/index.ts index 55cf81fd4c9d53..8c1f8b378cb583 100644 --- a/extensions/mermaid-markdown-features/preview-src/shared/index.ts +++ b/extensions/mermaid-markdown-features/preview-src/shared/index.ts @@ -8,6 +8,7 @@ import zenuml from '@mermaid-js/mermaid-zenuml'; import mermaid, { MermaidConfig } from 'mermaid'; import { iconPacks } from './iconPackConfig'; import { ClickDragMode, MermaidExtensionConfig, ShowControlsMode } from './config'; +import { vsCodeMermaidTheme, VsCodeMermaidThemeTracker } from './vsCodeTheme'; /** * Creates the `

` node shown when a diagram fails to render.
@@ -132,9 +133,9 @@ export async function registerMermaidAddons() {
 	await mermaid.registerExternalDiagrams([zenuml]);
 }
 
-const defaultConfig: MermaidExtensionConfig = {
-	darkModeTheme: 'dark',
-	lightModeTheme: 'default',
+export const defaultExtensionConfig: MermaidExtensionConfig = {
+	darkModeTheme: vsCodeMermaidTheme,
+	lightModeTheme: vsCodeMermaidTheme,
 	maxTextSize: 50000,
 	clickDrag: ClickDragMode.Alt,
 	showControls: ShowControlsMode.OnHoverOrFocus,
@@ -146,23 +147,23 @@ export function loadExtensionConfig(): MermaidExtensionConfig {
 	const configSpan = document.getElementById('markdown-mermaid');
 	const configAttr = configSpan?.dataset.config;
 	if (!configAttr) {
-		return defaultConfig;
+		return defaultExtensionConfig;
 	}
 
 	try {
-		return { ...defaultConfig, ...JSON.parse(configAttr) };
+		return { ...defaultExtensionConfig, ...JSON.parse(configAttr) };
 	} catch {
-		return defaultConfig;
+		return defaultExtensionConfig;
 	}
 }
 
-export function loadMermaidConfig(): MermaidConfig {
-	const config = loadExtensionConfig();
+export function buildMermaidConfig(
+	extensionConfig: MermaidExtensionConfig,
+	vsCodeThemeTracker: VsCodeMermaidThemeTracker,
+): MermaidConfig {
 	return {
 		startOnLoad: false,
-		theme: (document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast')
-			? config.darkModeTheme
-			: config.lightModeTheme) as MermaidConfig['theme'],
+		...vsCodeThemeTracker.resolveMermaidTheme(extensionConfig),
 	};
 }
 
diff --git a/extensions/mermaid-markdown-features/preview-src/shared/vsCodeTheme.ts b/extensions/mermaid-markdown-features/preview-src/shared/vsCodeTheme.ts
new file mode 100644
index 00000000000000..81a7453bf1e196
--- /dev/null
+++ b/extensions/mermaid-markdown-features/preview-src/shared/vsCodeTheme.ts
@@ -0,0 +1,255 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { MermaidConfig } from 'mermaid';
+import { MermaidExtensionConfig } from './config';
+import { IDisposable } from './disposable';
+
+/**
+ * Identifier for the custom Mermaid theme that is derived from the current VS Code color theme.
+ */
+export const vsCodeMermaidTheme = 'vscode';
+
+/**
+ * Returns `true` when the active VS Code theme is a dark (or non-light high contrast) theme.
+ */
+function isDarkVsCodeTheme(): boolean {
+	return document.body.classList.contains('vscode-dark')
+		|| (document.body.classList.contains('vscode-high-contrast') && !document.body.classList.contains('vscode-high-contrast-light'));
+}
+
+/**
+ * Resolves a CSS color value (such as `var(--vscode-editor-background)`) to a hex color string
+ * by letting the browser compute it on a temporary element.
+ */
+function resolveCssColor(cssValue: string): string | undefined {
+	const probe = document.createElement('span');
+	probe.style.display = 'none';
+	probe.style.color = cssValue;
+	document.body.appendChild(probe);
+	try {
+		return rgbStringToHex(getComputedStyle(probe).color);
+	} finally {
+		probe.remove();
+	}
+}
+
+function rgbStringToHex(value: string): string | undefined {
+	const match = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
+	if (!match) {
+		return undefined;
+	}
+	const r = parseInt(match[1], 10);
+	const g = parseInt(match[2], 10);
+	const b = parseInt(match[3], 10);
+	const a = match[4] !== undefined ? Math.round(parseFloat(match[4]) * 255) : 255;
+	const hex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
+	return a < 255
+		? `#${hex(r)}${hex(g)}${hex(b)}${hex(a)}`
+		: `#${hex(r)}${hex(g)}${hex(b)}`;
+}
+
+function pickColor(...varNames: string[]): string | undefined {
+	for (const name of varNames) {
+		// Peek at the raw custom property first: setting `style.color = 'var(--missing)'`
+		// silently drops the declaration and the probe falls back to the inherited
+		// `color` (typically the webview foreground), which would otherwise mask later
+		// fallbacks in `varNames`.
+		if (!readCssVar(name)) {
+			continue;
+		}
+
+		const hex = resolveCssColor(`var(${name})`);
+		if (hex) {
+			return hex;
+		}
+	}
+	return undefined;
+}
+
+function readCssVar(name: string): string {
+	return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+/**
+ * The Mermaid `themeVariables` derived from the active VS Code color theme, plus a string
+ * fingerprint that changes whenever any of the resolved colors change.
+ */
+export interface VsCodeMermaidThemeVariables {
+	readonly variables: Record;
+	readonly fingerprint: string;
+}
+
+/**
+ * Resolves and caches Mermaid `themeVariables` derived from the active VS Code color theme.
+ *
+ * Each consumer should construct one tracker, read {@link value} when initializing mermaid, and
+ * subscribe to {@link onDidChange} (or call {@link refresh} after a theme-change signal) to keep
+ * its rendered diagrams in sync with theme switches.
+ */
+export class VsCodeMermaidThemeTracker {
+
+	private _value: VsCodeMermaidThemeVariables | undefined;
+	private readonly _listeners = new Set<() => void>();
+
+	/**
+	 * The Mermaid theme variables derived from the active VS Code theme. Computed lazily on
+	 * first access and memoized until {@link refresh} or {@link invalidate} is called.
+	 */
+	get value(): VsCodeMermaidThemeVariables {
+		return this._value ??= computeVsCodeMermaidThemeVariables();
+	}
+
+	/**
+	 * Recomputes the theme variables from the live CSS variables and fires {@link onDidChange}
+	 * listeners when the resolved colors actually changed. Returns `true` when listeners fired.
+	 */
+	refresh(): boolean {
+		const next = computeVsCodeMermaidThemeVariables();
+		if (next.fingerprint === this._value?.fingerprint) {
+			return false;
+		}
+
+		this._value = next;
+		for (const listener of this._listeners) {
+			listener();
+		}
+		return true;
+	}
+
+	/**
+	 * Drops the cached value without firing listeners. The next read of {@link value} will
+	 * recompute from the live CSS variables.
+	 */
+	invalidate(): void {
+		this._value = undefined;
+	}
+
+	/**
+	 * Subscribes to changes. The listener fires when {@link refresh} detects that the resolved
+	 * theme variables have changed; read {@link value} to get the new variables.
+	 */
+	onDidChange(listener: () => void): IDisposable {
+		this._listeners.add(listener);
+		return { dispose: () => this._listeners.delete(listener) };
+	}
+
+	/**
+	 * Resolves the Mermaid `theme` / `themeVariables` pair for the given extension config,
+	 * picking the dark or light slot based on the active VS Code theme and translating the
+	 * `'vscode'` sentinel into mermaid's `base` theme plus VS Code-derived variables.
+	 */
+	resolveMermaidTheme(extensionConfig: MermaidExtensionConfig): Pick {
+		const themeName = isDarkVsCodeTheme()
+			? extensionConfig.darkModeTheme
+			: extensionConfig.lightModeTheme;
+
+		if (themeName === vsCodeMermaidTheme) {
+			return {
+				theme: 'base',
+				themeVariables: this.value.variables,
+			};
+		}
+
+		return {
+			theme: themeName as MermaidConfig['theme'],
+			// Reset theme variables in case mermaid.initialize was previously called with the
+			// vscode theme: mermaid merges configs and stale variables would otherwise leak through.
+			themeVariables: {},
+		};
+	}
+
+	/**
+	 * Starts a {@link MutationObserver} on the body and document element so that theme switches
+	 * (class/data-attribute changes on `body`, CSS variable updates on `documentElement.style`)
+	 * trigger a {@link refresh}. Returns a disposable that stops observing.
+	 */
+	observeDomChanges(): IDisposable {
+		const observer = new MutationObserver(() => this.refresh());
+		observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-vscode-theme-id', 'data-vscode-theme-kind'] });
+		observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
+		return { dispose: () => observer.disconnect() };
+	}
+}
+
+function computeVsCodeMermaidThemeVariables(): VsCodeMermaidThemeVariables {
+	const variables: Record = {
+		darkMode: isDarkVsCodeTheme(),
+	};
+
+	const set = (key: string, ...varNames: string[]) => {
+		const color = pickColor(...varNames);
+		if (color) {
+			variables[key] = color;
+		}
+	};
+
+	// Canvas / text
+	set('background', '--vscode-editor-background');
+	set('textColor', '--vscode-charts-foreground', '--vscode-editor-foreground', '--vscode-foreground');
+	set('lineColor', '--vscode-chart-line', '--vscode-charts-lines', '--vscode-editor-foreground', '--vscode-foreground');
+
+	// Primary (default node) colors
+	set('primaryColor', '--vscode-editorWidget-background');
+	set('primaryTextColor', '--vscode-charts-foreground', '--vscode-editor-foreground', '--vscode-foreground');
+	set('primaryBorderColor', '--vscode-chart-line', '--vscode-editorWidget-border', '--vscode-focusBorder');
+	set('mainBkg', '--vscode-editorWidget-background');
+	set('nodeBorder', '--vscode-chart-line', '--vscode-editorWidget-border', '--vscode-focusBorder');
+
+	// Secondary colors
+	set('secondaryColor', '--vscode-input-background', '--vscode-editorWidget-background');
+	set('secondaryTextColor', '--vscode-input-foreground', '--vscode-foreground');
+	set('secondaryBorderColor', '--vscode-input-border', '--vscode-editorWidget-border');
+
+	// Tertiary / subgraph (cluster) colors
+	set('tertiaryColor', '--vscode-textBlockQuote-background', '--vscode-input-background');
+	set('tertiaryTextColor', '--vscode-foreground');
+	set('tertiaryBorderColor', '--vscode-textBlockQuote-border', '--vscode-editorWidget-border');
+	set('clusterBkg', '--vscode-textBlockQuote-background', '--vscode-input-background');
+	set('clusterBorder', '--vscode-textBlockQuote-border', '--vscode-editorWidget-border');
+
+	// Notes
+	set('noteBkgColor', '--vscode-textBlockQuote-background', '--vscode-editorWidget-background');
+	set('noteTextColor', '--vscode-foreground');
+	set('noteBorderColor', '--vscode-textBlockQuote-border', '--vscode-editorWidget-border');
+
+	// Errors
+	set('errorBkgColor', '--vscode-inputValidation-errorBackground', '--vscode-editorError-background');
+	set('errorTextColor', '--vscode-editorError-foreground', '--vscode-foreground');
+
+	// Misc
+	set('titleColor', '--vscode-charts-foreground', '--vscode-editor-foreground', '--vscode-foreground');
+	set('edgeLabelBackground', '--vscode-editor-background');
+
+	// Pie / palette slots — mermaid uses pie1..pie12 for pie chart slices and cScale0..cScale11
+	// as the accent palette for various diagram types. Map to the VS Code chart accent colors.
+	const chartPalette = [
+		'--vscode-charts-blue',
+		'--vscode-charts-green',
+		'--vscode-charts-orange',
+		'--vscode-charts-red',
+		'--vscode-charts-purple',
+		'--vscode-charts-yellow',
+	];
+	for (let i = 0; i < chartPalette.length; i++) {
+		set(`pie${i + 1}`, chartPalette[i]);
+		set(`cScale${i}`, chartPalette[i]);
+	}
+
+	const fontFamily = readCssVar('--vscode-font-family');
+	if (fontFamily) {
+		variables.fontFamily = fontFamily;
+	}
+
+	const fontSize = readCssVar('--vscode-font-size');
+	if (fontSize) {
+		variables.fontSize = fontSize;
+	}
+
+	return {
+		variables,
+		fingerprint: JSON.stringify(variables),
+	};
+}
diff --git a/extensions/mermaid-markdown-features/preview-src/tsconfig.json b/extensions/mermaid-markdown-features/preview-src/tsconfig.json
new file mode 100644
index 00000000000000..3a70235e98b832
--- /dev/null
+++ b/extensions/mermaid-markdown-features/preview-src/tsconfig.json
@@ -0,0 +1,22 @@
+{
+	"extends": "../../tsconfig.base.json",
+	"compilerOptions": {
+		"rootDir": "..",
+		"outDir": "./dist/",
+		"jsx": "react",
+		"lib": [
+			"ES2024",
+			"DOM",
+			"DOM.Iterable"
+		],
+		"types": [
+			"node"
+		],
+		"typeRoots": [
+			"../node_modules/@types"
+		]
+	},
+	"include": [
+		"./**/*"
+	]
+}
diff --git a/extensions/mermaid-markdown-features/src/chatOutputRenderer.ts b/extensions/mermaid-markdown-features/src/chatOutputRenderer.ts
index 9c11039d41fa27..829158dcbc7f67 100644
--- a/extensions/mermaid-markdown-features/src/chatOutputRenderer.ts
+++ b/extensions/mermaid-markdown-features/src/chatOutputRenderer.ts
@@ -8,6 +8,7 @@ import { MermaidCommandContext, MermaidWebviewManager } from './webviewManager';
 import { escapeHtmlText } from './util/html';
 import { generateUuid } from './util/uuid';
 import { disposeAll } from './util/dispose';
+import { renderMermaidConfigSpan } from './markdownMermaid/config';
 
 /**
  * Mime type used to identify Mermaid diagram data in chat output.
@@ -116,6 +117,7 @@ class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer {
 			
 
 			
+				${renderMermaidConfigSpan()}
 				
 				
 					${escapeHtmlText(mermaidSource)}
diff --git a/extensions/mermaid-markdown-features/src/editorManager.ts b/extensions/mermaid-markdown-features/src/editorManager.ts
index 0d5857abe75080..1b6b8b13474f21 100644
--- a/extensions/mermaid-markdown-features/src/editorManager.ts
+++ b/extensions/mermaid-markdown-features/src/editorManager.ts
@@ -7,6 +7,7 @@ import { generateUuid } from './util/uuid';
 import { MermaidWebviewManager } from './webviewManager';
 import { escapeHtmlText } from './util/html';
 import { Disposable } from './util/dispose';
+import { renderMermaidConfigSpan } from './markdownMermaid/config';
 
 export const mermaidEditorViewType = 'vscode.mermaid-markdown-features.preview';
 
@@ -276,6 +277,7 @@ class MermaidPreview extends Disposable {
 				
 			
 			
+				${renderMermaidConfigSpan()}
 				
diff --git a/extensions/mermaid-markdown-features/src/markdownMermaid/config.ts b/extensions/mermaid-markdown-features/src/markdownMermaid/config.ts index b3c5400649bc65..5ff473f5f6c98e 100644 --- a/extensions/mermaid-markdown-features/src/markdownMermaid/config.ts +++ b/extensions/mermaid-markdown-features/src/markdownMermaid/config.ts @@ -16,8 +16,9 @@ const enum ShowControlsMode { OnHoverOrFocus = 'onHoverOrFocus' } -const defaultMermaidTheme = 'default'; +const defaultMermaidTheme = 'vscode'; const validMermaidThemes = [ + 'vscode', 'base', 'forest', 'dark', @@ -29,27 +30,33 @@ function sanitizeMermaidTheme(theme: string | undefined): string { return typeof theme === 'string' && validMermaidThemes.includes(theme) ? theme : defaultMermaidTheme; } +export function buildMermaidConfigData() { + const config = vscode.workspace.getConfiguration(configSection); + return { + darkModeTheme: sanitizeMermaidTheme(config.get('darkModeTheme')), + lightModeTheme: sanitizeMermaidTheme(config.get('lightModeTheme')), + maxTextSize: config.get('maxTextSize'), + clickDrag: config.get('mouseNavigation.enabled', ClickDragMode.Alt), + showControls: config.get('controls.show', ShowControlsMode.OnHoverOrFocus), + resizable: config.get('resizable', true), + maxHeight: config.get('maxHeight', ''), + }; +} + export function injectMermaidConfig(md: MarkdownIt): MarkdownIt { const render = md.renderer.render; md.renderer.render = function (...args) { - const config = vscode.workspace.getConfiguration(configSection); - const configData = { - darkModeTheme: sanitizeMermaidTheme(config.get('darkModeTheme')), - lightModeTheme: sanitizeMermaidTheme(config.get('lightModeTheme')), - maxTextSize: config.get('maxTextSize'), - clickDrag: config.get('mouseNavigation.enabled', ClickDragMode.Alt), - showControls: config.get('controls.show', ShowControlsMode.OnHoverOrFocus), - resizable: config.get('resizable', true), - maxHeight: config.get('maxHeight', ''), - }; - - const escapedConfig = escapeHtmlAttribute(JSON.stringify(configData)); - return ` + return `${renderMermaidConfigSpan()} ${render.apply(md.renderer, args)}`; }; return md; } +export function renderMermaidConfigSpan(): string { + const escapedConfig = escapeHtmlAttribute(JSON.stringify(buildMermaidConfigData())); + return ``; +} + function escapeHtmlAttribute(str: string): string { return str .replace(/&/g, '&') diff --git a/scripts/chat-simulation/common/mock-llm-server.js b/scripts/chat-simulation/common/mock-llm-server.js index 4c072a48ab6acb..96eca3e0d3327c 100644 --- a/scripts/chat-simulation/common/mock-llm-server.js +++ b/scripts/chat-simulation/common/mock-llm-server.js @@ -174,6 +174,56 @@ function getDefaultScenarioChunks() { const MODEL = 'gpt-4o-2024-08-06'; +/** + * Additional model definitions the mock advertises beyond `MODEL` and + * `gpt-4o-mini`. `gpt-5.3-codex` is the Copilot CLI SDK's hard-coded default + * model; smoke tests/automation that exercise the CLI need it in the mock's + * /models list, otherwise the SDK fails with "No model available". + */ +const EXTRA_MODELS = [ + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex (Mock)', + version: '2025-01-01', + vendor: 'copilot', + model_picker_enabled: false, + is_chat_default: false, + is_chat_fallback: false, + billing: { is_premium: false, multiplier: 0 }, + capabilities: { + type: 'chat', + family: 'gpt-4o', + tokenizer: 'o200k_base', + limits: { max_prompt_tokens: 10000000, max_output_tokens: 131072, max_context_window_tokens: 10000000 }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false }, + }, + supported_endpoints: ['/chat/completions'], + }, + // Anthropic Claude model — required by the Claude Code session type, which + // filters endpoints for `modelProvider: 'Anthropic'`, `apiType: 'messages'`, + // `supportsToolCalls: true`, and `showInModelPicker: true` + // (see `ClaudeCodeModels._fetchAvailableEndpoints`). Routes to the + // `/v1/messages` mock handler which emits Anthropic-format SSE. + { + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5 (Mock)', + version: '2025-01-01', + vendor: 'Anthropic', + model_picker_enabled: true, + is_chat_default: false, + is_chat_fallback: false, + billing: { is_premium: false, multiplier: 0 }, + capabilities: { + type: 'chat', + family: 'claude-sonnet-4.5', + tokenizer: 'o200k_base', + limits: { max_prompt_tokens: 200000, max_output_tokens: 8192, max_context_window_tokens: 200000 }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true }, + }, + supported_endpoints: ['/v1/messages'], + }, +]; + /** * @param {string} content * @param {number} index @@ -417,7 +467,7 @@ function handleRequest(req, res) { if (path === '/models/session' && req.method === 'POST') { readBody().then(() => { json(200, { - available_models: [MODEL, 'gpt-4o-mini'], + available_models: [MODEL, 'gpt-4o-mini', ...EXTRA_MODELS.map(m => m.id)], session_token: 'perf-session-token-' + Date.now(), expires_at: Math.floor(Date.now() / 1000) + 3600, discounted_costs: {}, @@ -487,6 +537,7 @@ function handleRequest(req, res) { }, supported_endpoints: ['/chat/completions'], }, + ...EXTRA_MODELS, ], }); return; @@ -557,8 +608,11 @@ function handleRequest(req, res) { } // -- Messages API (DomainService.capiMessagesURL = /v1/messages) -- + // The Anthropic Messages API (used by the Claude Code session type) speaks + // a different SSE dialect than OpenAI Chat Completions, so dispatch to a + // dedicated handler that emits `message_start` / `content_block_*` events. if (path === '/v1/messages' && req.method === 'POST') { - readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res)); + readBody().then((/** @type {string} */ body) => handleMessagesApi(body, res)); return; } @@ -801,6 +855,155 @@ async function streamContent(res, chunks, isScenarioRequest) { } } +// ----- Anthropic Messages API ------------------------------------------------- + +/** + * Anthropic SSE writer that emits a complete message response per the + * `processResponseFromMessagesEndpoint` parser in `messagesApi.ts`. The + * sequence is: + * `event: message_start` → opening message envelope with model + usage + * `event: content_block_start` → opens a `text` content block at index 0 + * `event: content_block_delta` → one or more `text_delta` chunks + * `event: content_block_stop` + * `event: message_delta` → stop_reason + final usage + * `event: message_stop` + * + * Each event must be written as both an `event:` line and a `data:` line per + * the SSE spec; the Anthropic SDK's stream parser keys off the `event:` line. + * + * @param {http.ServerResponse} res + * @param {string} eventType + * @param {Record} payload + */ +function writeAnthropicEvent(res, eventType, payload) { + res.write(`event: ${eventType}\n`); + res.write(`data: ${JSON.stringify({ type: eventType, ...payload })}\n\n`); +} + +/** + * Stream a content scenario as an Anthropic Messages API SSE response. + * @param {http.ServerResponse} res + * @param {StreamChunk[]} chunks + * @param {boolean} isScenarioRequest + */ +async function streamAnthropicContent(res, chunks, isScenarioRequest) { + const messageId = `msg_mock_${Date.now()}`; + const model = 'claude-sonnet-4.5'; + + writeAnthropicEvent(res, 'message_start', { + message: { + id: messageId, + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); + + writeAnthropicEvent(res, 'content_block_start', { + index: 0, + content_block: { type: 'text', text: '' }, + }); + + let totalOutputTokens = 0; + for (const chunk of chunks) { + if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } + writeAnthropicEvent(res, 'content_block_delta', { + index: 0, + delta: { type: 'text_delta', text: chunk.content }, + }); + // Rough token estimate — only used by usage accounting in the receiver. + totalOutputTokens += Math.max(1, Math.ceil(chunk.content.length / 4)); + } + + writeAnthropicEvent(res, 'content_block_stop', { index: 0 }); + + writeAnthropicEvent(res, 'message_delta', { + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: totalOutputTokens }, + }); + + writeAnthropicEvent(res, 'message_stop', {}); + + res.end(); + + if (isScenarioRequest) { + serverEvents.emit('scenarioCompletion'); + } +} + +/** + * Anthropic-format request handler. Resolves the scenario from the request's + * `[scenario:...]` tag the same way as `handleChatCompletions` (searching the + * `messages[].content` array for either a string or an array of `{ type: + * 'text', text }` blocks), then streams the matching content turn as + * Anthropic SSE events. Multi-turn / thinking / tool-call scenarios fall + * back to their first content turn for now — Claude Code smoke tests only + * need a single text response. + * + * @param {string} body + * @param {http.ServerResponse} res + */ +async function handleMessagesApi(body, res) { + let scenarioId = DEFAULT_SCENARIO; + let isScenarioRequest = false; + /** @type {any[]} */ + let messages = []; + try { + const parsed = JSON.parse(body); + messages = parsed.messages || []; + const userMsgs = messages.filter((/** @type {any} */ m) => m.role === 'user'); + if (userMsgs.length > 0) { + const last = userMsgs[userMsgs.length - 1]; + const lastContent = typeof last.content === 'string' + ? last.content.substring(0, 100) + : Array.isArray(last.content) + ? last.content.map((/** @type {any} */ c) => c.text || '').join('').substring(0, 100) + : '(structured)'; + const ts = new Date().toISOString().slice(11, -1); + console.log(`[mock-llm] ${ts} → messages-api: ${messages.length} msgs, last user: "${lastContent}"`); + } + + for (let mi = messages.length - 1; mi >= 0; mi--) { + const msg = messages[mi]; + if (msg.role !== 'user') { continue; } + const content = typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map((/** @type {any} */ c) => c.text || '').join('') + : ''; + const match = content.match(/\[scenario:([^\]]+)\]/); + if (match && SCENARIOS[match[1]]) { + scenarioId = match[1]; + isScenarioRequest = true; + break; + } + } + } catch { } + + const scenario = SCENARIOS[scenarioId] || SCENARIOS[DEFAULT_SCENARIO]; + const chunks = isMultiTurnScenario(scenario) + ? getFirstContentTurn(scenario) + : /** @type {StreamChunk[]} */ (scenario); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Request-Id': 'perf-benchmark-' + Date.now(), + }); + + await streamAnthropicContent(res, chunks, isScenarioRequest); +} + /** * Stream thinking chunks followed by content chunks as an SSE response. * Thinking is emitted as `cot_summary` deltas, then a `cot_id` to close the diff --git a/src/vs/platform/agentHost/common/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts index eee3e7505bba49..5f0a94c815bd58 100644 --- a/src/vs/platform/agentHost/common/agentPluginManager.ts +++ b/src/vs/platform/agentHost/common/agentPluginManager.ts @@ -41,9 +41,9 @@ export interface IAgentPluginManager { * Syncs a set of client-provided customization refs to local storage. * * Each ref is copied to a local directory, respecting nonce-based - * caching. The optional {@link progress} callback fires as individual - * customizations complete or fail, allowing callers to publish - * incremental status updates. + * caching. The optional {@link progress} callback fires with the single + * customization that completed or failed, allowing callers to publish + * targeted incremental status updates. * * Concurrent calls for the same plugin URI are serialized so that * overlapping syncs do not clobber each other. @@ -51,5 +51,5 @@ export interface IAgentPluginManager { * @returns Final status for every customization, with `pluginDir` * defined when the sync was successful. */ - syncCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (status: SessionCustomization[]) => void): Promise; + syncCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (status: SessionCustomization) => void): Promise; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 6a188b838a4a36..0fcbc0a4b970dc 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -644,13 +644,13 @@ export interface IAgent { onArchivedChanged?(session: URI, isArchived: boolean): Promise; /** - * Receives client-provided customization refs and syncs them (e.g. copies - * plugin files to local storage). Returns per-customization status with - * local plugin directories. + * Receives client-provided customization refs for a session and syncs them + * (e.g. copies plugin files to local storage). The agent publishes + * customization state actions as the sync progresses. * * The agent MAY defer a client restart until all active sessions are idle. */ - setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise; + setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise; /** * Receives client-provided tool definitions to make available in a diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts index 6d6fb46a8d2c77..0e272769a9fa0f 100644 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ b/src/vs/platform/agentHost/node/agentPluginManager.ts @@ -68,32 +68,24 @@ export class AgentPluginManager implements IAgentPluginManager { async syncCustomizations( clientId: string, customizations: CustomizationRef[], - progress?: (status: SessionCustomization[]) => void, + progress?: (status: SessionCustomization) => void, ): Promise { await this._ensureCacheLoaded(); - // Build initial loading status and fire it immediately via progress - const statuses: SessionCustomization[] = customizations.map(c => ({ - customization: c, - enabled: true, - status: CustomizationStatus.Loading, - })); - progress?.([...statuses]); - // Sync each customization in parallel, serialized per URI - const results = await Promise.all(customizations.map((ref, i) => + const results = await Promise.all(customizations.map(ref => this._sequencer.queue(ref.uri, async (): Promise => { try { const pluginDir = await this._syncPlugin(clientId, ref); - statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Loaded }; - progress?.([...statuses]); - return { customization: statuses[i], pluginDir }; + const customization = { customization: ref, enabled: true, status: CustomizationStatus.Loaded }; + progress?.(customization); + return { customization, pluginDir }; } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.error(`[AgentPluginManager] Failed to sync plugin ${ref.uri}: ${message}`); - statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message }; - progress?.([...statuses]); - return { customization: statuses[i] }; + const customization = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message }; + progress?.(customization); + return { customization }; } }) )); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 490b34fccc3b2b..6d4e336819f67f 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -332,14 +332,16 @@ export class AgentSideEffects extends Disposable { return; } - // No active turn on the session. Most signals are silently dropped, - // but a `SessionTurnComplete` (idle) still needs to drive its - // post-turn side effects — flushing pending diff computation, - // recomputing diffs, and notifying the host. Tests routinely fire - // `idle` without first dispatching the matching `SessionTurnStarted` - // through the state manager. - if (signal.kind === 'action' && signal.action.type === ActionType.SessionTurnComplete) { - this._runTurnCompleteSideEffects(sessionKey, undefined); + // No active turn on the session. Non-action signals are silently + // dropped, but action signals can still target session-level state + // such as customizations, title, or configuration. A turnComplete + // action also drives post-turn side effects even when the matching + // turnStarted was not observed by this side-effects instance. + if (signal.kind === 'action') { + this._stateManager.dispatchServerAction(sessionKey, signal.action); + if (signal.action.type === ActionType.SessionTurnComplete) { + this._runTurnCompleteSideEffects(sessionKey, undefined); + } } } @@ -798,15 +800,7 @@ export class AgentSideEffects extends Disposable { agent.setClientTools(URI.parse(channel), clientId, action.activeClient?.tools ?? []); const refs = action.activeClient?.customizations ?? []; - agent.setClientCustomizations( - clientId, - refs, - () => { - this._publishSessionCustomizationsSoon(agent, channel); - }, - ).then(() => { - this._publishSessionCustomizationsSoon(agent, channel); - }).catch(err => { + agent.setClientCustomizations(URI.parse(channel), clientId, refs).catch(err => { this._logService.error('[AgentSideEffects] setClientCustomizations failed', err); }); break; @@ -971,4 +965,3 @@ export class AgentSideEffects extends Disposable { super.dispose(); } } - diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 774d33af997422..80e3060078b9c9 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -1078,7 +1078,7 @@ export class ClaudeAgent extends Disposable implements IAgent { // matching pending promise on the SDK session. } - setClientCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (results: ISyncedCustomization[]) => void): Promise { + setClientCustomizations(_session: URI, _clientId: string, _customizations: CustomizationRef[]): Promise { throw new Error('TODO: Phase 11'); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e47db9b0a43136..26bd7bdd193b41 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -30,6 +30,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { CustomizationRef, CustomizationStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type MessageAttachment, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -310,14 +311,18 @@ export class CopilotAgent extends Disposable implements IAgent { } async getSessionCustomizations(session: URI): Promise { + return this._plugins.getSessionCustomizations(await this._getSessionCustomizationDirectory(session)); + } + + private async _getSessionCustomizationDirectory(session: URI): Promise { const sessionId = AgentSession.id(session); const provisional = this._provisionalSessions.get(sessionId); if (provisional) { - return this._plugins.getSessionCustomizations(provisional.workingDirectory); + return provisional.workingDirectory; } const entry = this._sessions.get(sessionId); const metadata = entry ? undefined : await this._readSessionMetadata(session); - return this._plugins.getSessionCustomizations(entry?.customizationDirectory ?? metadata?.customizationDirectory ?? metadata?.workingDirectory); + return entry?.customizationDirectory ?? metadata?.customizationDirectory ?? metadata?.workingDirectory; } async authenticate(resource: string, token: string): Promise { @@ -760,7 +765,7 @@ export class CopilotAgent extends Disposable implements IAgent { const ac = this._getOrCreateActiveClient(sessionUri); ac.updateTools(config.activeClient.clientId, config.activeClient.tools); if (config.activeClient.customizations !== undefined) { - await this._plugins.sync(config.activeClient.clientId, config.activeClient.customizations); + await this._plugins.sync(config.activeClient.clientId, config.activeClient.customizations, config.workingDirectory); } } @@ -940,8 +945,11 @@ export class CopilotAgent extends Disposable implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { - return this._plugins.sync(clientId, customizations, progress); + async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { + const directory = await this._getSessionCustomizationDirectory(session); + return this._plugins.sync(clientId, customizations, directory, action => { + this._onDidSessionProgress.fire({ kind: 'action', session, action }); + }); } setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { @@ -1931,7 +1939,7 @@ class PluginController extends Disposable { }); } - public sync(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) { + public sync(clientId: string, customizations: CustomizationRef[], directory: URI | undefined, publish?: (action: SessionAction) => void) { const revision = ++this._clientRevision; this._clientCustomizations = customizations.map(customization => ({ customization: { @@ -1941,7 +1949,29 @@ class PluginController extends Disposable { status: CustomizationStatus.Loading, }, })); - progress?.(this._clientCustomizations.map(item => ({ customization: this._applyEnablement(item.customization) }))); + publish?.({ + type: ActionType.SessionCustomizationsChanged, + customizations: [...this.getSessionCustomizations(directory)], + }); + const published = new Map(); + for (const customization of this._clientCustomizations) { + const enabled = this._applyEnablement(customization.customization); + published.set(enabled.customization.uri, this._applyEnablement(customization.customization)); + } + const publishUpdate = (item: IResolvedCustomization) => { + const customization = this._applyEnablement(item.customization); + if (equals(published.get(customization.customization.uri), customization)) { + return; + } + published.set(customization.customization.uri, { ...customization }); + publish?.({ + type: ActionType.SessionCustomizationUpdated, + customization: customization.customization, + enabled: customization.enabled, + status: customization.status, + statusMessage: customization.statusMessage, + }); + }; const prev = this._clientSync; const promise = this._clientSync = prev.catch(err => { @@ -1952,18 +1982,15 @@ class PluginController extends Disposable { return; } - this._clientCustomizations = status.map(customization => ({ - customization: { - ...customization, - clientId, - }, - })); - progress?.(this._clientCustomizations.map(item => ({ customization: this._applyEnablement(item.customization) }))); + publishUpdate({ customization: { ...status, clientId } }); }); const resolved = await Promise.all(result.map(item => this._resolveSyncedCustomization(item, clientId))); if (revision === this._clientRevision) { this._clientCustomizations = resolved; + for (const item of resolved) { + publishUpdate(item); + } } return resolved; }); diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts index 7ac15fc57b212a..19514a21b334b5 100644 --- a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts @@ -88,18 +88,15 @@ suite('AgentPluginManager', () => { assert.strictEqual(results[1].pluginDir, undefined); }); - test('fires progress callback with loading, then loaded', async () => { + test('fires progress callback with changed customization status', async () => { await seedPluginDir('prog', { 'index.js': 'content' }); - const progressCalls: SessionCustomization[][] = []; - await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], statuses => { - progressCalls.push(statuses); + const progressCalls: SessionCustomization[] = []; + await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], status => { + progressCalls.push(status); }); - // At least two calls: initial loading + final loaded - assert.ok(progressCalls.length >= 2, `expected at least 2 progress calls, got ${progressCalls.length}`); - assert.strictEqual(progressCalls[0][0].status, CustomizationStatus.Loading); - assert.strictEqual(progressCalls[progressCalls.length - 1][0].status, CustomizationStatus.Loaded); + assert.deepStrictEqual(progressCalls.map(call => call.status), [CustomizationStatus.Loaded]); }); test('skips copy when nonce matches', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 9e625730e7925e..84ad3ba3b65fdf 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -23,7 +23,7 @@ import { ISessionDataService } from '../../common/sessionDataService.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { CustomizationStatus } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionCustomization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -864,7 +864,11 @@ suite('AgentSideEffects', () => { suite('handleAction — session/activeClientChanged', () => { - test('calls setClientCustomizations and dispatches customizationsChanged', async () => { + setup(() => { + disposables.add(sideEffects.registerProgressListener(agent)); + }); + + test('calls setClientCustomizations and dispatches customizationsChanged once', async () => { setupSession(); agent.getSessionCustomizations = async () => [ { @@ -908,7 +912,83 @@ suite('AgentSideEffects', () => { const customizationActions = envelopes .filter(e => e.action.type === ActionType.SessionCustomizationsChanged); - assert.ok(customizationActions.length >= 1, 'should dispatch at least one customizationsChanged'); + assert.strictEqual(customizationActions.length, 1, 'should dispatch one full customizationsChanged replacement'); + assert.strictEqual( + envelopes.filter(e => e.action.type === ActionType.SessionCustomizationUpdated).length, + 0, + 'should not dispatch customizationUpdated when progress matches the final state', + ); + }); + + test('dispatches customizationUpdated for sync progress after initial replacement', async () => { + setupSession(); + const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; + let currentCustomizations: readonly SessionCustomization[] = []; + agent.getSessionCustomizations = async () => currentCustomizations; + agent.setClientCustomizations = async (session, clientId, customizations) => { + agent.setClientCustomizationsCalls.push({ clientId, customizations }); + currentCustomizations = [{ + customization, + enabled: true, + status: CustomizationStatus.Loading, + }]; + agent.fireProgress({ + kind: 'action', + session, + action: { + type: ActionType.SessionCustomizationsChanged, + customizations: [...currentCustomizations], + }, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + currentCustomizations = [{ + customization, + enabled: true, + status: CustomizationStatus.Loaded, + }]; + agent.fireProgress({ + kind: 'action', + session, + action: { + type: ActionType.SessionCustomizationUpdated, + customization, + enabled: true, + status: CustomizationStatus.Loaded, + }, + }); + return currentCustomizations.map(customization => ({ customization })); + }; + + const envelopes: ActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + sideEffects.handleAction(sessionUri.toString(), { + type: ActionType.SessionActiveClientChanged, + activeClient: { + clientId: 'test-client', + tools: [], + customizations: [customization], + }, + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + const customizationsChanged = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged); + assert.strictEqual(customizationsChanged.length, 1); + const firstCustomizationsChanged = customizationsChanged[0].action; + assert.strictEqual(firstCustomizationsChanged.type, ActionType.SessionCustomizationsChanged); + assert.deepStrictEqual(firstCustomizationsChanged.customizations, [{ + customization, + enabled: true, + status: CustomizationStatus.Loading, + }]); + + const customizationUpdated = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationUpdated); + assert.deepStrictEqual(customizationUpdated.map(e => e.action), [{ + type: ActionType.SessionCustomizationUpdated, + customization, + enabled: true, + status: CustomizationStatus.Loaded, + }]); }); test('clears client customizations when activeClient has no customizations', () => { @@ -932,7 +1012,11 @@ suite('AgentSideEffects', () => { }]); const customizationActions = envelopes .filter(e => e.action.type === ActionType.SessionCustomizationsChanged); - assert.strictEqual(customizationActions.length, 0); + assert.strictEqual(customizationActions.length, 1); + assert.deepStrictEqual(customizationActions[0].action, { + type: ActionType.SessionCustomizationsChanged, + customizations: [], + }); }); test('clears client customizations when activeClient is null', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index c6a91d6a805b84..45e8882df1a8a9 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -48,7 +48,7 @@ class TestAgentPluginManager implements IAgentPluginManager { readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); - async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { + async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization) => void): Promise { return []; } } @@ -607,7 +607,7 @@ suite('CopilotAgent', () => { class SpyingPluginManager extends TestAgentPluginManager { public readonly calls: { clientId: string; customizations: CustomizationRef[] }[] = []; - override async syncCustomizations(clientId: string, customizations: CustomizationRef[], _progress?: (status: SessionCustomization[]) => void): Promise { + override async syncCustomizations(clientId: string, customizations: CustomizationRef[], _progress?: (status: SessionCustomization) => void): Promise { this.calls.push({ clientId, customizations: [...customizations] }); return []; } diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index d009f28aa453a9..0809a65b55b52f 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -169,7 +169,7 @@ export class MockAgent implements IAgent { return this.customizations; } - async setClientCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { + async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { this.setClientCustomizationsCalls.push({ clientId, customizations }); const results: ISyncedCustomization[] = customizations.map(c => ({ customization: { @@ -178,7 +178,14 @@ export class MockAgent implements IAgent { status: CustomizationStatus.Loaded, }, })); - progress?.(results); + this._onDidSessionProgress.fire({ + kind: 'action', + session, + action: { + type: ActionType.SessionCustomizationsChanged, + customizations: results.map(result => result.customization), + }, + }); return results; } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 380bb55f2e2951..81aa5de5c9c1da 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -78,7 +78,7 @@ Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: - **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. - **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[AICustomizationSources.extension, AICustomizationSources.builtin]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[AICustomizationSources.builtin]`. - **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. - **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 74f3d1157011d0..7af7ff1e6fa972 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -101,6 +101,8 @@ Each session operates on an **`ISessionWorkspace`** containing one or more **`IS Workspaces carry a `group` label (e.g., `"Local"`, `"Remote"`) used by the workspace picker to organize entries into tabs via the `SESSION_WORKSPACE_GROUP_LOCAL` / `SESSION_WORKSPACE_GROUP_REMOTE` constants. +Tasks with `runOptions.runOn === "worktreeCreated"` are dispatched client-side only for newly created sessions, after the session reports a concrete `gitRepository.workTreeUri`. Restored sessions and runtimes that declare `capabilities.runsWorktreeCreatedTasks` are skipped so setup tasks are not re-run on window open or double-run with server-side provisioning; untitled placeholders are deferred until they become committed worktree sessions. + ### Session Types An **`ISessionType`** identifies an agent backend (e.g., `'copilot-cli'`, `'copilot-cloud'`). Each provider declares which session types it supports and can dynamically update the list via `onDidChangeSessionTypes`. The management service exposes `getAllSessionTypes()` for UI pickers. @@ -116,13 +118,23 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups — ### Creating a New Session ``` -1. User picks a workspace in the workspace picker - → SessionsManagementService.createNewSession(providerId, workspaceUri, sessionTypeId?) - → Looks up provider in SessionsProvidersService - → Calls provider.createNewSession(workspaceUri, sessionTypeId) +1. User picks a folder in the workspace picker + → WorkspacePicker fires onDidSelectWorkspace(folderUri) + → SessionsManagementService.createNewSession(folderUri, options?) + → Iterates providers, picks the first one whose resolveWorkspace(folderUri) + succeeds (filtered by options.sessionTypeId when given) + → Calls provider.createNewSession(folderUri, sessionTypeId) → Returns ISession, set as activeSession -2. User types a message and sends +2. User picks a different session type for the same folder + → SessionTypePicker queries getSessionTypesForFolder(folderUri), + groups entries by provider, shows them in the dropdown + → On selection, fires onDidSelectSessionType({ providerId, sessionTypeId }) + → SessionsManagementService.createNewSession(folderUri, { providerId, sessionTypeId }) + routes through the picked provider — even when the same sessionType.id + is also offered by another provider + +3. User types a message and sends → SessionsManagementService.sendAndCreateChat(session, {query, attachedContext}) → Delegates to provider.sendAndCreateChat(sessionId, options) → Provider sends request, returns committed session @@ -168,4 +180,3 @@ Providers may fire `onDidReplaceSession` when a temporary (untitled) session is 5. Use `toSessionId(providerId, resource)` for session IDs 6. Fire `onDidChangeSessions` on every session change and `onDidReplaceSession` on untitled→committed transitions 7. Set `supportsLocalWorkspaces: true` if the provider can resolve local file-system workspaces - diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index fffac6383522c6..9f9f54485345cf 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -79,7 +79,7 @@ The **active session is always visible** even if it would be excluded by filters ### Find -A built-in find widget filters the list by session title and section label. When active, it bypasses workspace group capping so all matching sessions are visible. +A built-in find widget filters the list by session title and section label. When a search pattern is entered, it bypasses workspace group capping so all matching sessions are visible. Simply opening the find widget (without typing) does not reorder the list. ### Pinning diff --git a/src/vs/sessions/browser/sessionsSetUpService.ts b/src/vs/sessions/browser/sessionsSetUpService.ts index b533ba98ca398c..d3cedab3dec2e0 100644 --- a/src/vs/sessions/browser/sessionsSetUpService.ts +++ b/src/vs/sessions/browser/sessionsSetUpService.ts @@ -55,6 +55,9 @@ export interface ISessionsSetUpService { // --------------------------------------------------------------------------- function shouldSkipSessionsWelcome(environmentService: IWorkbenchEnvironmentService): boolean { + if (environmentService.enableSmokeTestDriver) { + return true; + } const envArgs = (environmentService as IWorkbenchEnvironmentService & { args?: Record }).args; if (envArgs?.['skip-sessions-welcome']) { return true; diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index fe31a079fb13dc..d34502a2e6a4c4 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -41,7 +41,7 @@ import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSource, AICustomizationSources, IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; //#region Context Keys @@ -423,16 +423,16 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource s.storage === BUILTIN_STORAGE); if (workspaceSkills.length > 0) { - groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceSkills.length)); + groups.push(this.createGroupItem(promptType, AICustomizationSources.local, workspaceSkills.length)); } if (userSkills.length > 0) { - groups.push(this.createGroupItem(promptType, PromptsStorage.user, userSkills.length)); + groups.push(this.createGroupItem(promptType, AICustomizationSources.user, userSkills.length)); } if (extensionSkills.length > 0) { - groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionSkills.length)); + groups.push(this.createGroupItem(promptType, AICustomizationSources.extension, extensionSkills.length)); } if (builtinSkills.length > 0) { - groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinSkills.length)); + groups.push(this.createGroupItem(promptType, AICustomizationSources.builtin, builtinSkills.length)); } return groups; @@ -494,29 +494,29 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource = { - [PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count), - [PromptsStorage.user]: localize('userWithCount', "User ({0})", count), - [PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count), - [PromptsStorage.plugin]: localize('pluginsWithCount', "Plugins ({0})", count), - [BUILTIN_STORAGE]: localize('builtinWithCount', "Built-in ({0})", count), + [AICustomizationSources.local]: localize('workspaceWithCount', "Workspace ({0})", count), + [AICustomizationSources.user]: localize('userWithCount', "User ({0})", count), + [AICustomizationSources.extension]: localize('extensionsWithCount', "Extensions ({0})", count), + [AICustomizationSources.plugin]: localize('pluginsWithCount', "Plugins ({0})", count), + [AICustomizationSources.builtin]: localize('builtinWithCount', "Built-in ({0})", count), }; const storageIcons: Record = { - [PromptsStorage.local]: workspaceIcon, - [PromptsStorage.user]: userIcon, - [PromptsStorage.extension]: extensionIcon, - [PromptsStorage.plugin]: pluginIcon, - [BUILTIN_STORAGE]: builtinIcon, + [AICustomizationSources.local]: workspaceIcon, + [AICustomizationSources.user]: userIcon, + [AICustomizationSources.extension]: extensionIcon, + [AICustomizationSources.plugin]: pluginIcon, + [AICustomizationSources.builtin]: builtinIcon, }; const storageSuffixes: Record = { - [PromptsStorage.local]: 'workspace', - [PromptsStorage.user]: 'user', - [PromptsStorage.extension]: 'extensions', - [PromptsStorage.plugin]: 'plugins', - [BUILTIN_STORAGE]: 'builtin', + [AICustomizationSources.local]: 'workspace', + [AICustomizationSources.user]: 'user', + [AICustomizationSources.extension]: 'extensions', + [AICustomizationSources.plugin]: 'plugins', + [AICustomizationSources.builtin]: 'builtin', }; return { diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c3c30af6edba7b..1f9fc9887ca132 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -40,6 +40,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js'; import { SessionsOpenerParticipantContribution } from './sessionsOpenerParticipant.js'; +import { WorktreeCreatedTaskDispatcher } from './worktreeCreatedTaskDispatcher.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; @@ -149,8 +150,7 @@ registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, Registe registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, SessionsOpenerParticipantContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(RegisterDefaultSessionTaskRunnersContribution.ID, RegisterDefaultSessionTaskRunnersContribution, WorkbenchPhase.BlockStartup); -// todo@connor4312: temp until bugfix: -// registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts index 258927e65adabc..de95843d55515a 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts @@ -5,10 +5,10 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, IHarnessDescriptor } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { BUILTIN_STORAGE } from '../common/builtinPromptsStorage.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { AICustomizationSources } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; /** * The session type that supports local harness customization. @@ -37,7 +37,7 @@ export class SessionsCustomizationHarnessService extends CustomizationHarnessSer @IPromptsService promptsService: IPromptsService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, ) { - const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; + const localExtras = [AICustomizationSources.extension, AICustomizationSources.builtin]; const localHarness = createVSCodeHarnessDescriptor(localExtras); super( diff --git a/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts index 81499021d96466..bba20e4f748c3d 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts @@ -9,7 +9,7 @@ import { autorun } from '../../../../base/common/observable.js'; import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter, applyStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index 5f2915a6f12c45..101d0e8b920908 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -30,12 +30,12 @@ export class MobileSessionTypePicker extends SessionTypePicker { constructor( @IActionWidgetService actionWidgetService: IActionWidgetService, @ISessionsManagementService sessionsManagementService: ISessionsManagementService, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IStorageService storageService: IStorageService, @ITelemetryService telemetryService: ITelemetryService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { - super(actionWidgetService, sessionsManagementService, sessionsProvidersService, storageService, telemetryService); + super(actionWidgetService, sessionsManagementService, _sessionsProvidersService, storageService, telemetryService); } override render(container: HTMLElement, options?: { className?: string }): void { @@ -57,18 +57,27 @@ export class MobileSessionTypePicker extends SessionTypePicker { super._showPicker(); return; } - if (this._allProviderSessionTypes.length <= 1) { + if (this._folderSessionTypes.length <= 1) { return; } - const supportedTypeIds = new Set(this._supportedSessionTypes.map(t => t.id)); - const sheetItems: IMobilePickerSheetItem[] = this._allProviderSessionTypes.map(type => ({ - id: type.id, - label: type.label, - icon: type.icon, - disabled: !supportedTypeIds.has(type.id), - checked: type.id === this._sessionType, - })); + // Build sheet items — composite id is `providerId\u0000sessionTypeId` + // so we can map back to the right provider on selection. Use the + // provider's label as a section title on the first item of each + // group so the sheet visually separates providers. + const sheetItems: IMobilePickerSheetItem[] = []; + let lastProviderId: string | undefined; + for (const { providerId, sessionType } of this._folderSessionTypes) { + const isFirstInGroup = providerId !== lastProviderId; + lastProviderId = providerId; + sheetItems.push({ + id: `${providerId}\u0000${sessionType.id}`, + label: sessionType.label, + icon: sessionType.icon, + checked: providerId === this._picked?.providerId && sessionType.id === this._picked?.sessionTypeId, + sectionTitle: isFirstInGroup ? (this._sessionsProvidersService.getProvider(providerId)?.label ?? providerId) : undefined, + }); + } const trigger = this._triggerElement; trigger.setAttribute('aria-expanded', 'true'); @@ -80,7 +89,10 @@ export class MobileSessionTypePicker extends SessionTypePicker { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); if (id !== undefined) { - this._handleSelectedSessionType(id); + const [providerId, sessionTypeId] = id.split('\u0000'); + if (providerId && sessionTypeId) { + this._handleSelectedSessionType({ providerId, sessionTypeId }); + } } }); } diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts index 1340d8e4ac5bb5..89fe9a440a6eab 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts @@ -85,7 +85,7 @@ export function buildMobileWorkspacePickerRows( // scoped to a single host via the host picker, so the host // indication is redundant — render every workspace as a folder // to match the inline folder search results below. - const isWorkspaceRow = !!data?.selection; + const isWorkspaceRow = !!data?.folderUri; const icon = isWorkspaceRow ? Codicon.folder : item.group?.icon; rows.push({ sheetItem: { @@ -220,7 +220,11 @@ export async function showMobileWorkspacePickerSheet( const sheetItems: IMobilePickerSheetItem[] = []; flattened.forEach((entry, idx) => { const id = `${SEARCH_RESULT_ID_PREFIX}${idx}`; - folderRunById.set(id, () => dispatch({ selection: { providerId: entry.providerId, workspace: entry.workspace } })); + const folderUri = entry.workspace.folders[0]?.root; + if (!folderUri) { + return; + } + folderRunById.set(id, () => dispatch({ folderUri, providerId: entry.providerId })); folderLabelById.set(id, entry.workspace.label); sheetItems.push({ id, diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 62dcc073994b6d..3d04e13269d0df 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -20,13 +20,13 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize } from '../../../../nls.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; -import { WorkspacePicker, IWorkspaceSelection } from './sessionWorkspacePicker.js'; +import { WorkspacePicker } from './sessionWorkspacePicker.js'; import { WebWorkspacePicker } from './webWorkspacePicker.js'; +import { IPreferredSessionType } from './sessionTypePicker.js'; import { NewChatInputWidget } from './newChatInput.js'; import { NoAgentHostEmptyState } from './noAgentHostEmptyState.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -55,7 +55,6 @@ class NewChatWidget extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IAquariumService private readonly aquariumService: IAquariumService, @IAgentHostFilterService private readonly agentHostFilterService: IAgentHostFilterService, @@ -90,19 +89,25 @@ class NewChatWidget extends Disposable { renderSessionTypePickerInControls: false, })); - this._register(this._workspacePicker.onDidSelectWorkspace(async workspace => { - if (workspace) { - const selectedSessionType = this._newChatInput.sessionTypePicker.selectedType; - const validSessionTypes = this.sessionsProvidersService.getProvider(workspace.providerId)?.getSessionTypes(workspace.workspace.folders[0].root); - const validSessionType = selectedSessionType ? validSessionTypes?.find(type => type.id === selectedSessionType) : validSessionTypes?.[0]; - await this._onWorkspaceSelected(workspace, validSessionType?.id); + this._register(this._workspacePicker.onDidSelectWorkspace(async folderUri => { + if (folderUri) { + // Carry over the user's preferred session type if some + // provider can still serve it for this folder; otherwise + // fall back to the provider's natural default by passing + // only the folder URI. + const picked = this._newChatInput.sessionTypePicker.selectedPick; + const folderTypes = picked ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) : undefined; + const validForFolder = picked && folderTypes!.some(t => + (picked.providerId === undefined || t.providerId === picked.providerId) + && t.sessionType.id === picked.sessionTypeId); + await this._onWorkspaceSelected(folderUri, validForFolder ? picked : undefined); } else { await this._onWorkspaceSelected(undefined, undefined); } this._newChatInput.focus(); })); - this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async sessionType => { - await this._onWorkspaceSelected(this._workspacePicker.selectedProject, sessionType); + this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async pick => { + await this._onWorkspaceSelected(this._workspacePicker.selectedFolderUri, pick); this._newChatInput.focus(); })); } @@ -134,9 +139,9 @@ class NewChatWidget extends Disposable { // picker fires onDidSelectWorkspace and our listener handles it. // Skip if an active session already exists (restored by openNewSessionView // from a pending new session when navigating back from another session). - const restoredProject = this._workspacePicker.selectedProject; - if (!this._syncWorkspacePickerFromActiveSession() && restoredProject) { - this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); + const restoredFolderUri = this._workspacePicker.selectedFolderUri; + if (!this._syncWorkspacePickerFromActiveSession() && restoredFolderUri) { + this._createNewSession(restoredFolderUri, this._newChatInput.sessionTypePicker.selectedPick); } chatWidgetContainer.classList.add('revealed'); @@ -159,26 +164,29 @@ class NewChatWidget extends Disposable { } const sessionWorkspace = activeSession.workspace.get(); - if (sessionWorkspace) { - this._workspacePicker.setSelectedWorkspace( - { providerId: activeSession.providerId, workspace: sessionWorkspace }, - /* fireEvent */ false, - ); + const folderUri = sessionWorkspace?.folders[0]?.root; + if (folderUri) { + this._workspacePicker.setSelectedWorkspace(folderUri, { fireEvent: false }); } return true; } - private _createNewSession(selection: IWorkspaceSelection, sessionTypeId: string | undefined): void { - const provider = this.sessionsProvidersService.getProviders().find(p => p.id === selection.providerId); - const repoUri = selection.workspace.folders[0].root; - - // Drop the carried-over sessionTypeId if it doesn't apply to this provider — - // happens when the picker upgrades to a different provider after restore and - // the previous active session's type (e.g. EH CLI's "agents") doesn't exist - // on the new provider (e.g. agent host). - if (sessionTypeId && provider && !provider.getSessionTypes(repoUri).some(t => t.id === sessionTypeId)) { - sessionTypeId = undefined; + private _createNewSession(folderUri: URI, pick: IPreferredSessionType | undefined): void { + // If the carried-over pick can no longer be served for this folder + // (the picker upgraded to a different provider after restore), drop + // it so the management service picks the natural default. When the + // pick has no providerId yet (legacy stored preference), we accept + // any provider that offers the same sessionTypeId. + let effectivePick = pick; + if (effectivePick) { + const available = this.sessionsManagementService.getSessionTypesForFolder(folderUri); + const matches = available.some(t => + (effectivePick!.providerId === undefined || t.providerId === effectivePick!.providerId) + && t.sessionType.id === effectivePick!.sessionTypeId); + if (!matches) { + effectivePick = undefined; + } } // Session types may not be available yet (e.g., agent host still connecting). @@ -187,22 +195,31 @@ class NewChatWidget extends Disposable { // agent-host-specific UI (model picker etc.) until the user re-picks the workspace. // If the connection fails, the picker fires onDidSelectWorkspace(undefined) which // clears the pending wait via _onWorkspaceSelected. - if (provider && !sessionTypeId && provider.getSessionTypes(repoUri).length === 0 && provider.onDidChangeSessionTypes) { + const availableNow = this.sessionsManagementService.getSessionTypesForFolder(folderUri); + if (availableNow.length === 0) { const pendingStore = new DisposableStore(); this._pendingSessionTypeWait.value = pendingStore; - - pendingStore.add(provider.onDidChangeSessionTypes(() => { - if (provider.getSessionTypes(repoUri).length > 0) { + pendingStore.add(this.sessionsManagementService.onDidChangeSessionTypes(() => { + if (this.sessionsManagementService.getSessionTypesForFolder(folderUri).length > 0) { this._pendingSessionTypeWait.clear(); - this._createNewSession(selection, sessionTypeId); + this._createNewSession(folderUri, pick); } })); - return; } + // Fall back to the provider associated with the recently-picked + // workspace (e.g. Local Agent Host) when the session type picker has + // no explicit pick yet. This preserves the user's historical provider + // association across iteration-order changes in the providers list. + const fallbackProviderId = this._workspacePicker.selectedResolved?.providerId; + try { - this.sessionsManagementService.createNewSession(selection.providerId, repoUri, sessionTypeId); + this.sessionsManagementService.createNewSession(folderUri, effectivePick + ? { providerId: effectivePick.providerId, sessionTypeId: effectivePick.sessionTypeId } + : fallbackProviderId + ? { providerId: fallbackProviderId } + : undefined); } catch (e) { this.logService.error('Failed to create new session:', e); } @@ -212,13 +229,13 @@ class NewChatWidget extends Disposable { * Returns the workspace URI for the context picker based on the current workspace selection. */ private _getContextFolderUri(): URI | undefined { - return this._workspacePicker.selectedProject?.workspace.folders[0]?.root; + return this._workspacePicker.selectedFolderUri; } private _renderWorkspacePicker(container: HTMLElement): IDisposable { const pickersRow = dom.append(container, dom.$('.session-workspace-picker')); const pickersLabel = dom.append(pickersRow, dom.$('.session-workspace-picker-label')); - pickersLabel.textContent = this._workspacePicker.selectedProject + pickersLabel.textContent = this._workspacePicker.selectedFolderUri ? localize('newSessionIn', "New session in") : localize('newSessionChooseWorkspace', "Start by picking a"); @@ -227,8 +244,8 @@ class NewChatWidget extends Disposable { withLabel.textContent = localize('newSessionWith', "with"); this._newChatInput.sessionTypePicker.render(pickersRow, { className: 'sessions-chat-session-type-picker' }); return this._workspacePicker.onDidSelectWorkspace(() => { - const workspace = this._workspacePicker.selectedProject; - pickersLabel.textContent = workspace + const folderUri = this._workspacePicker.selectedFolderUri; + pickersLabel.textContent = folderUri ? localize('newSessionIn', "New session in") : localize('newSessionChooseWorkspace', "Start by picking a"); }); @@ -369,23 +386,23 @@ class NewChatWidget extends Disposable { * Handles a workspace selection from the workspace picker. * Requests folder trust if needed and creates a new session. */ - private async _onWorkspaceSelected(selection: IWorkspaceSelection | undefined, sessionTypeId: string | undefined): Promise { + private async _onWorkspaceSelected(folderUri: URI | undefined, pick: IPreferredSessionType | undefined): Promise { // Cancel any in-flight wait for a previous selection. this._pendingSessionTypeWait.clear(); - if (!selection) { + if (!folderUri) { this.sessionsManagementService.unsetNewSession(); return; } - if (selection.workspace.requiresWorkspaceTrust) { - const workspaceUri = selection.workspace.folders[0]?.root; - if (workspaceUri && !await this._requestFolderTrust(workspaceUri)) { + const resolved = this.sessionsManagementService.resolveWorkspace(folderUri); + if (resolved?.workspace.requiresWorkspaceTrust) { + if (!await this._requestFolderTrust(folderUri)) { return; } } - this._createNewSession(selection, sessionTypeId); + this._createNewSession(folderUri, pick); } prefillInput(text: string): void { @@ -400,8 +417,8 @@ class NewChatWidget extends Disposable { this._newChatInput.sendQuery(text); } - selectWorkspace(workspace: IWorkspaceSelection): void { - this._workspacePicker.setSelectedWorkspace(workspace); + selectWorkspace(folderUri: URI, providerId?: string): void { + this._workspacePicker.setSelectedWorkspace(folderUri, { providerId }); } } @@ -459,8 +476,8 @@ export class NewChatViewPane extends ViewPane { this._widget?.sendQuery(text); } - selectWorkspace(workspace: IWorkspaceSelection): void { - this._widget?.selectWorkspace(workspace); + selectWorkspace(folderUri: URI, providerId?: string): void { + this._widget?.selectWorkspace(folderUri, providerId); } override setVisible(visible: boolean): void { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index d041d4dc1db6bb..3b8292999ab050 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -11,10 +11,10 @@ import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js' import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ISession, ISessionType } from '../../../services/sessions/common/session.js'; +import { ISession } from '../../../services/sessions/common/session.js'; import { Emitter } from '../../../../base/common/event.js'; import { isWeb } from '../../../../base/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -23,14 +23,59 @@ import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; export const STORAGE_KEY_LAST_SESSION_TYPE = 'sessions.lastSelectedSessionType'; +/** + * A picked session type, paired with the provider that serves it. Two + * providers can advertise the same session type id (e.g. both expose + * 'copilot-cli'), so callers need both to route session creation to the + * right provider. + */ +export interface IPickedSessionType { + readonly providerId: string; + readonly sessionTypeId: string; +} + +/** + * A stored or in-memory preference. When the providerId is unknown (legacy + * storage that only persisted the session type id, or a pick made before + * any folder was known) the picker resolves a provider lazily once the + * active folder is established. + */ +export interface IPreferredSessionType { + readonly providerId?: string; + readonly sessionTypeId: string; +} + +interface IStoredSessionTypePick { + readonly providerId?: string; + readonly sessionTypeId: string; +} + +/** + * Row item rendered inside the session type picker — carries both the + * provider id and the session type so we can dispatch creation through + * the correct provider when the same type is offered by multiple providers. + */ +interface ISessionTypePickerItem { + readonly providerId: string; + readonly sessionTypeId: string; + readonly label: string; + readonly checked?: boolean; +} + export class SessionTypePicker extends Disposable { - protected _sessionType: string | undefined; - protected readonly _onDidSelectSessionType = this._register(new Emitter()); + /** + * The currently displayed pick. May be missing `providerId` when restored + * from legacy storage that only persisted the session type id — it will + * be resolved to a concrete provider lazily when consumers create a + * session. + */ + protected _picked: IPreferredSessionType | undefined; + protected readonly _onDidSelectSessionType = this._register(new Emitter()); readonly onDidSelectSessionType = this._onDidSelectSessionType.event; - protected _supportedSessionTypes: ISessionType[] = []; - protected _allProviderSessionTypes: ISessionType[] = []; + /** Session types the active session's folder can be served by, across all providers. */ + protected _folderSessionTypes: IProviderSessionType[] = []; private readonly _renderDisposables = this._register(new DisposableStore()); protected _triggerElement: HTMLElement | undefined; @@ -45,25 +90,20 @@ export class SessionTypePicker extends Disposable { super(); // Restore the previously selected session type from storage - this._sessionType = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); + this._picked = this._readStoredPick(); const refresh = (session: ISession | undefined) => { if (session) { - const provider = this.sessionsProvidersService.getProvider(session.providerId); - this._supportedSessionTypes = provider?.getSessionTypes(session.resource) ?? []; - const providerTypes = provider ? [...provider.sessionTypes] : []; - const providerTypeIds = new Set(providerTypes.map(t => t.id)); - this._allProviderSessionTypes = [ - ...providerTypes, - ...this._supportedSessionTypes.filter(t => !providerTypeIds.has(t.id)), - ]; - this._sessionType = session.sessionType; + const folderUri = session.workspace.get()?.folders[0]?.root; + this._folderSessionTypes = folderUri ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) : []; + // The active session's actual type wins over any stored preference + // for trigger-label rendering. + this._picked = { providerId: session.providerId, sessionTypeId: session.sessionType }; } else { - this._supportedSessionTypes = []; - this._allProviderSessionTypes = []; - // Preserve the stored session type when no active session exists, + this._folderSessionTypes = []; + // Preserve the stored pick when no active session exists, // so it can be used as the default for the next new session. - this._sessionType = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); + this._picked = this._readStoredPick(); } this._updateTriggerLabel(); }; @@ -79,8 +119,8 @@ export class SessionTypePicker extends Disposable { })); } - get selectedType(): string | undefined { - return this._sessionType; + get selectedPick(): IPreferredSessionType | undefined { + return this._picked; } render(container: HTMLElement, options?: { className?: string }): void { @@ -127,38 +167,67 @@ export class SessionTypePicker extends Disposable { return; } - if (this._allProviderSessionTypes.length <= 1) { - return; - } - const session = this.sessionsManagementService.activeSession.get(); if (!session) { return; } - const supportedTypeIds = new Set(this._supportedSessionTypes.map(t => t.id)); + // Recompute types fresh at open time so a late-registering provider + // (e.g. Local Agent Host whose session types are populated only after + // agent discovery) shows up without waiting for the refresh event to + // land before the user clicks. + const folderUri = session.workspace.get()?.folders[0]?.root; + const folderTypes = folderUri + ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) + : this._folderSessionTypes; + this._folderSessionTypes = folderTypes; + + if (folderTypes.length <= 1) { + return; + } - const items: IActionListItem[] = this._allProviderSessionTypes.map(type => ({ - kind: ActionListItemKind.Action, - label: type.label, - group: { title: '', icon: type.icon }, - disabled: !supportedTypeIds.has(type.id), - item: type.id === this._sessionType ? { ...type, checked: true } : type, - })); + // Group items by provider so the dropdown shows a provider header + // followed by that provider's types. Insert a separator between + // adjacent providers' types so the grouping is visually clear. + const providersService = this.sessionsProvidersService; + const groupedItems: IActionListItem[] = []; + let lastProviderId: string | undefined; + for (const { providerId, sessionType } of folderTypes) { + const provider = providersService.getProvider(providerId); + const groupTitle = provider?.label ?? providerId; + const isFirstInGroup = providerId !== lastProviderId; + if (isFirstInGroup && lastProviderId !== undefined) { + groupedItems.push({ kind: ActionListItemKind.Separator, label: '' }); + } + lastProviderId = providerId; + const isCurrent = this._picked?.providerId === providerId && this._picked?.sessionTypeId === sessionType.id; + const item: ISessionTypePickerItem = isCurrent + ? { providerId, sessionTypeId: sessionType.id, label: sessionType.label, checked: true } + : { providerId, sessionTypeId: sessionType.id, label: sessionType.label }; + groupedItems.push({ + kind: ActionListItemKind.Action, + label: sessionType.label, + group: { + title: isFirstInGroup ? groupTitle : '', + icon: sessionType.icon, + }, + item, + }); + } const triggerElement = this._triggerElement; - const delegate: IActionListDelegate = { - onSelect: (type) => { + const delegate: IActionListDelegate = { + onSelect: (item) => { this.actionWidgetService.hide(); - this._handleSelectedSessionType(type.id); + this._handleSelectedSessionType(item); }, onHide: () => { triggerElement.focus(); }, }; - this.actionWidgetService.show( + this.actionWidgetService.show( 'sessionTypePicker', false, - items, + groupedItems, delegate, this._triggerElement, undefined, @@ -167,6 +236,7 @@ export class SessionTypePicker extends Disposable { getAriaLabel: (item) => item.label ?? '', getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), }, + { showGroupTitleOnFirstItem: true }, ); } @@ -174,33 +244,63 @@ export class SessionTypePicker extends Disposable { * Handles the user picking a session type. Emits `newChatPickerClosed` * telemetry (with the previously selected type read from storage, or * the in-memory field when nothing is stored), and — when the - * selection actually changed — persists the new type and fires + * selection actually changed — persists the new pick and fires * {@link onDidSelectSessionType}. * * Shared between desktop (action-widget popup) and mobile (bottom * sheet) presentations so both surfaces report identical telemetry. */ - protected _handleSelectedSessionType(typeId: string): void { - const beforeId = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE) ?? this._sessionType; - const beforeLabel = this._allProviderSessionTypes.find(t => t.id === beforeId)?.label; - const afterLabel = this._allProviderSessionTypes.find(t => t.id === typeId)?.label; + protected _handleSelectedSessionType(pick: IPickedSessionType): void { + const stored = this._readStoredPick(); + const beforeId = stored?.sessionTypeId ?? this._picked?.sessionTypeId; + const beforeLabel = this._folderSessionTypes.find(t => t.sessionType.id === beforeId)?.sessionType.label; + const afterLabel = this._folderSessionTypes.find(t => t.providerId === pick.providerId && t.sessionType.id === pick.sessionTypeId)?.sessionType.label; reportNewChatPickerClosed(this.telemetryService, { id: 'NewChatSessionTypePicker', name: 'NewChatSessionTypePicker', optionIdBefore: beforeId, - optionIdAfter: typeId, + optionIdAfter: pick.sessionTypeId, optionLabelBefore: beforeLabel, optionLabelAfter: afterLabel, isPII: false, }); - if (typeId !== this._sessionType) { - this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, typeId, StorageScope.PROFILE, StorageTarget.MACHINE); - this._onDidSelectSessionType.fire(typeId); + const changed = pick.providerId !== this._picked?.providerId || pick.sessionTypeId !== this._picked?.sessionTypeId; + if (changed) { + this._writeStoredPick(pick); + this._onDidSelectSessionType.fire(pick); } } + private _readStoredPick(): IPreferredSessionType | undefined { + const raw = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); + if (!raw) { + return undefined; + } + // Try parsing as the new JSON shape first; fall back to the legacy + // shape where only the sessionTypeId string was stored. + try { + const parsed = JSON.parse(raw) as IStoredSessionTypePick; + if (parsed && typeof parsed.sessionTypeId === 'string') { + return typeof parsed.providerId === 'string' + ? { providerId: parsed.providerId, sessionTypeId: parsed.sessionTypeId } + : { sessionTypeId: parsed.sessionTypeId }; + } + } catch { + // Not JSON — fall through to legacy raw-string handling. + } + // Legacy raw string was just the session type id. Resolution to a + // provider happens lazily once the active folder is known. + return { sessionTypeId: raw }; + } + + private _writeStoredPick(pick: IPickedSessionType): void { + this._picked = pick; + const stored: IStoredSessionTypePick = { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId }; + this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); + } + private _updateTriggerLabel(): void { if (!this._triggerElement) { return; @@ -214,20 +314,23 @@ export class SessionTypePicker extends Disposable { // Note: the existing CSS rule on `.session-workspace-picker-with-label` // uses `:has(+ .sessions-chat-session-type-picker .action-label.hidden)` // to also hide the "with" connector when the trigger is hidden. - const hideForSingleHarness = isWeb && this._allProviderSessionTypes.length <= 1; - if (this._allProviderSessionTypes.length === 0 || hideForSingleHarness) { + const hideForSingleHarness = isWeb && this._folderSessionTypes.length <= 1; + if (this._folderSessionTypes.length === 0 || hideForSingleHarness) { this._triggerElement.classList.add('hidden'); return; } this._triggerElement.classList.remove('hidden'); - const currentType = this._allProviderSessionTypes.find(t => t.id === this._sessionType); + const currentType = this._folderSessionTypes.find(t => + t.providerId === this._picked?.providerId && t.sessionType.id === this._picked?.sessionTypeId)?.sessionType + ?? this._folderSessionTypes.find(t => t.sessionType.id === this._picked?.sessionTypeId)?.sessionType; const modeIcon = currentType?.icon ?? Codicon.terminal; - const modeLabel = currentType?.label ?? this._sessionType ?? ''; + const modeLabel = currentType?.label ?? this._picked?.sessionTypeId ?? ''; dom.append(this._triggerElement, renderIcon(modeIcon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = modeLabel; + const chevron = dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); chevron.classList.add('sessions-chat-dropdown-chevron'); diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 954d3c2a363b2a..b20d8ab52ba48f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -24,7 +24,6 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -61,9 +60,19 @@ const TABBED_PICKER_WIDTH = 360; const RESTORE_CONNECT_GRACE_MS = 5000; /** - * A workspace selection from the picker, pairing the workspace with its owning provider. + * A workspace entry as resolved from a folder URI for rendering. The + * `providerId` is the provider that resolved the URI (first match in + * iteration order). For local URIs that any local provider can resolve, + * this is the first registered local provider; for remote URIs it is the + * remote provider for that authority. + * + * Selection now flows out of the picker as a plain folder URI — the + * provider is rediscovered at session-creation time by + * {@link ISessionsManagementService.createNewSession}. The `providerId` + * carried here is only used internally for rendering (connection state, + * grouping into tabs). */ -export interface IWorkspaceSelection { +export interface IResolvedFolderWorkspace { readonly providerId: string; readonly workspace: ISessionWorkspace; } @@ -71,10 +80,14 @@ export interface IWorkspaceSelection { /** * Stored recent workspace entry. The `checked` flag marks the currently * selected workspace so we only need a single storage key. + * + * `providerId` is retained for backwards compatibility with previously + * stored entries; new entries are written without it. When reading, + * entries are resolved by iterating registered providers. */ interface IStoredRecentWorkspace { readonly uri: UriComponents; - readonly providerId: string; + readonly providerId?: string; readonly checked: boolean; } @@ -82,7 +95,9 @@ interface IStoredRecentWorkspace { * Item type used in the action list. */ export interface IWorkspacePickerItem { - readonly selection?: IWorkspaceSelection; + readonly folderUri?: URI; + /** The resolved workspace (used for unavailable-provider checks). */ + readonly providerId?: string; readonly browseActionIndex?: number; readonly checked?: boolean; /** Command to execute when this item is selected. */ @@ -101,12 +116,13 @@ type IWorkspacePickerAction = IAction & { icon?: ThemeIcon; hoverContent?: strin */ export class WorkspacePicker extends Disposable { - protected readonly _onDidSelectWorkspace = this._register(new Emitter()); - readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; + protected readonly _onDidSelectWorkspace = this._register(new Emitter()); + readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; protected readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; - private _selectedWorkspace: IWorkspaceSelection | undefined; + private _selectedFolderUri: URI | undefined; + private _selectedResolved: IResolvedFolderWorkspace | undefined; /** * Set to `true` once the user has explicitly picked or cleared a workspace. @@ -124,9 +140,6 @@ export class WorkspacePicker extends Disposable { */ private readonly _connectionStatusWatch = this._register(new MutableDisposable()); - /** Provider ID chosen during the last local folder browse. */ - private _selectedLocalProviderId: string | undefined; - protected _triggerElement: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); private readonly _tabbedWidget: TabbedActionListWidget; @@ -148,8 +161,19 @@ export class WorkspacePicker extends Disposable { /** Cached VS Code recent folder URIs, resolved lazily. */ private _vsCodeRecentFolderUris: URI[] = []; - get selectedProject(): IWorkspaceSelection | undefined { - return this._selectedWorkspace; + get selectedFolderUri(): URI | undefined { + return this._selectedFolderUri; + } + + /** + * Returns the currently selected folder resolved to a workspace via the + * first provider that can resolve it. Used internally for rendering + * (label, icon, group). The provider association is not part of the + * picker's public contract — callers should use {@link selectedFolderUri} + * and let the management service rediscover the provider. + */ + get selectedResolved(): IResolvedFolderWorkspace | undefined { + return this._selectedResolved; } constructor( @@ -165,7 +189,6 @@ export class WorkspacePicker extends Disposable { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IQuickInputService private readonly quickInputService: IQuickInputService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -182,9 +205,10 @@ export class WorkspacePicker extends Disposable { })); // Restore selected workspace from storage - this._selectedWorkspace = this._restoreSelectedWorkspace(); - if (this._selectedWorkspace) { - this._watchForConnectionFailure(this._selectedWorkspace); + const restored = this._restoreSelectedWorkspace(); + this._applySelection(restored); + if (this._selectedResolved) { + this._watchForConnectionFailure(this._selectedResolved); } // React to provider registrations/removals: re-validate the current @@ -192,24 +216,28 @@ export class WorkspacePicker extends Disposable { // from storage so we upgrade from any fallback to the user's actual // stored selection once its provider arrives. this._register(this.sessionsProvidersService.onDidChangeProviders(() => { - if (this._selectedWorkspace) { - const providers = this.sessionsProvidersService.getProviders(); - if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) { - this._selectedWorkspace = undefined; + if (this._selectedFolderUri) { + // Re-resolve in case the previous resolving provider was removed. + const reresolved = this._resolveFolder(this._selectedFolderUri); + if (!reresolved) { + this._selectedFolderUri = undefined; + this._selectedResolved = undefined; this._connectionStatusWatch.clear(); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(undefined); + } else { + this._selectedResolved = reresolved; } } if (!this._userHasPicked) { - const restored = this._restoreSelectedWorkspace(); - if (restored && !this._isSelectedWorkspace(restored)) { - this._selectedWorkspace = restored; + const restoredNow = this._restoreSelectedWorkspace(); + if (restoredNow && !this._isSelectedFolder(restoredNow.workspace.folders[0]?.root)) { + this._applySelection(restoredNow); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); - this._onDidSelectWorkspace.fire(restored); - this._watchForConnectionFailure(restored); + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + this._watchForConnectionFailure(restoredNow); } } })); @@ -291,7 +319,7 @@ export class WorkspacePicker extends Disposable { // so picking a tab during one open of the picker doesn't permanently // override auto-tab. if (tabs.length > 0) { - const selectedGroup = this._selectedWorkspace?.workspace.group; + const selectedGroup = this._selectedResolved?.workspace.group; if (!this._userPickedTab && selectedGroup && tabs.some(t => t.id === selectedGroup)) { this._activeTab = selectedGroup; } @@ -450,14 +478,14 @@ export class WorkspacePicker extends Disposable { item.run(); } else if (item.commandId) { this.commandService.executeCommand(item.commandId); - } else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) { + } else if (item.folderUri && item.providerId && this._isProviderUnavailable(item.providerId)) { // Workspace belongs to an unavailable remote — ignore selection return; } if (item.browseActionIndex !== undefined) { this._executeBrowseAction(item.browseActionIndex); - } else if (item.selection) { - this._selectProject(item.selection); + } else if (item.folderUri) { + this._selectFolder(item.folderUri); } } @@ -470,25 +498,32 @@ export class WorkspacePicker extends Disposable { */ private _reportPickerClosed(item: IWorkspacePickerItem): void { const beforeFromStorage = this._restoreCheckedWorkspace(); - const before = beforeFromStorage ?? this._selectedWorkspace; - const after = item.selection; + const before = beforeFromStorage ?? this._selectedResolved; + const afterUri = item.folderUri; + const afterResolved = afterUri ? this._resolveFolder(afterUri) : undefined; reportNewChatPickerClosed(this.telemetryService, { id: 'NewChatWorkspacePicker', name: 'NewChatWorkspacePicker', optionIdBefore: before?.workspace?.uri.toString(), - optionIdAfter: after?.workspace?.uri.toString(), + optionIdAfter: afterResolved?.workspace?.uri.toString(), optionLabelBefore: before?.workspace?.label, - optionLabelAfter: after?.workspace?.label, + optionLabelAfter: afterResolved?.workspace?.label, isPII: true, }); } /** - * Programmatically set the selected project. - * @param fireEvent Whether to fire the onDidSelectWorkspace event. Defaults to true. + * Programmatically set the selected workspace by folder URI. + * @param folderUri The folder URI to select. + * @param options.fireEvent Whether to fire the onDidSelectWorkspace event. Defaults to true. + * @param options.providerId Optional providerId hint that wins over any historical + * recent entry's provider. Use when the caller knows which provider should + * own the resulting session (e.g. "New Session" invoked from a workspace + * section in the sessions list, where the existing sessions for the + * workspace were created by a specific provider). */ - setSelectedWorkspace(project: IWorkspaceSelection, fireEvent = true): void { - this._selectProject(project, fireEvent); + setSelectedWorkspace(folderUri: URI, options?: { fireEvent?: boolean; providerId?: string }): void { + this._selectFolder(folderUri, options?.fireEvent ?? true, options?.providerId); } /** @@ -510,7 +545,8 @@ export class WorkspacePicker extends Disposable { this._hidePicker(); this._userHasPicked = true; this._connectionStatusWatch.clear(); - this._selectedWorkspace = undefined; + this._selectedFolderUri = undefined; + this._selectedResolved = undefined; // Clear checked state from all recents const recents = this._getStoredRecentWorkspaces(); const updated = recents.map(p => ({ ...p, checked: false })); @@ -523,21 +559,61 @@ export class WorkspacePicker extends Disposable { * Clears the selection if it matches the given URI. */ removeFromRecents(uri: URI): void { - if (this._selectedWorkspace && this.uriIdentityService.extUri.isEqual(this._selectedWorkspace.workspace.folders[0]?.root, uri)) { + if (this._selectedFolderUri && this.uriIdentityService.extUri.isEqual(this._selectedFolderUri, uri)) { this.clearSelection(); } } - private _selectProject(selection: IWorkspaceSelection, fireEvent = true): void { + private _selectFolder(folderUri: URI, fireEvent = true, providerIdHint?: string): void { this._userHasPicked = true; this._connectionStatusWatch.clear(); - this._selectedWorkspace = selection; - this._persistSelectedWorkspace(selection); + // Prefer the caller-supplied providerId hint, then the historical + // providerId stored in the recents for this URI, so re-picking a + // Local Agent Host folder restores the Local Agent Host association + // even when another provider also resolves the URI. + const storedProviderId = this._getStoredRecentWorkspaces() + .find(r => this.uriIdentityService.extUri.isEqual(URI.revive(r.uri), folderUri)) + ?.providerId; + const resolved = this._resolveFolder(folderUri, providerIdHint ?? storedProviderId); + this._selectedFolderUri = folderUri; + this._selectedResolved = resolved; + this._persistSelectedFolder(folderUri, resolved?.providerId); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); if (fireEvent) { - this._onDidSelectWorkspace.fire(selection); + this._onDidSelectWorkspace.fire(folderUri); + } + } + + /** + * Apply a restored selection without firing events or persisting. Used + * during construction and after provider list changes. + */ + private _applySelection(resolved: IResolvedFolderWorkspace | undefined): void { + this._selectedResolved = resolved; + this._selectedFolderUri = resolved?.workspace.folders[0]?.root; + } + + /** + * Iterate providers and return the first resolution of the folder URI. + * When `preferredProviderId` is given, that provider is tried first so a + * user's historical pick survives provider iteration order changes. + */ + private _resolveFolder(folderUri: URI, preferredProviderId?: string): IResolvedFolderWorkspace | undefined { + if (preferredProviderId) { + const preferred = this.sessionsProvidersService.getProvider(preferredProviderId); + const workspace = preferred?.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: preferredProviderId, workspace }; + } + } + for (const provider of this.sessionsProvidersService.getProviders()) { + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: provider.id, workspace }; + } } + return undefined; } /** @@ -553,14 +629,9 @@ export class WorkspacePicker extends Disposable { try { const workspace = await action.run(); if (workspace) { - let providerId = action.providerId; - if (!providerId) { - // Picker-owned local action — use the provider chosen during browse - providerId = this._selectedLocalProviderId ?? ''; - this._selectedLocalProviderId = undefined; - } - if (providerId) { - this._selectProject({ providerId, workspace }); + const folderUri = workspace.folders[0]?.root; + if (folderUri) { + this._selectFolder(folderUri); } } } catch { @@ -592,9 +663,9 @@ export class WorkspacePicker extends Disposable { } /** - * Opens a folder picker dialog and resolves the selected folder through - * a provider that supports local workspaces. When multiple providers - * support local workspaces, shows a quick pick to choose the provider first. + * Opens a folder picker dialog and returns the chosen URI. The folder's + * provider is rediscovered later by the management service when the + * session is created — no provider quick-pick is needed here. */ private async _browseForLocalFolder(): Promise { const localProviders = this.sessionsProvidersService.getProviders().filter(p => p.supportsLocalWorkspaces); @@ -602,18 +673,6 @@ export class WorkspacePicker extends Disposable { return undefined; } - let provider = localProviders[0]; - if (localProviders.length > 1) { - const picked = await this.quickInputService.pick( - localProviders.map(p => ({ label: p.label, provider: p })), - { placeHolder: localize('pickLocalProvider', "Select a provider") }, - ); - if (!picked) { - return undefined; - } - provider = picked.provider; - } - const result = await this.fileDialogService.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, @@ -623,8 +682,16 @@ export class WorkspacePicker extends Disposable { return undefined; } - this._selectedLocalProviderId = provider.id; - return provider.resolveWorkspace(result[0]); + // Resolve through any local provider so the returned ISessionWorkspace + // carries a label/icon for the browse-action handshake; the actual + // provider used to create the session is rediscovered at creation time. + for (const provider of localProviders) { + const workspace = provider.resolveWorkspace(result[0]); + if (workspace) { + return workspace; + } + } + return undefined; } /** True when the picker is currently scoped to a single tab. */ @@ -646,16 +713,16 @@ export class WorkspacePicker extends Disposable { const allProviders = this.sessionsProvidersService.getProviders(); const providerIds = new Set(allProviders.map(p => p.id)); const tabFilter = this._isTabFiltered() - ? (w: IWorkspaceSelection) => w.workspace.group === this._activeTab + ? (w: IResolvedFolderWorkspace) => w.workspace.group === this._activeTab : undefined; const ownRecentWorkspaces = this._getRecentWorkspaces() .filter(w => providerIds.has(w.providerId)) - .filter(w => !tabFilter || tabFilter({ providerId: w.providerId, workspace: w.workspace })); + .filter(w => !tabFilter || tabFilter(w)); // Merge VS Code recent folders (resolved through providers, deduplicated) const vsCodeRecents = this._getVSCodeRecentWorkspaces() .filter(w => providerIds.has(w.providerId)) - .filter(w => !tabFilter || tabFilter({ providerId: w.providerId, workspace: w.workspace })); + .filter(w => !tabFilter || tabFilter(w)); const ownRecentCount = ownRecentWorkspaces.length; const recentWorkspaces = [...ownRecentWorkspaces, ...vsCodeRecents]; @@ -666,16 +733,19 @@ export class WorkspacePicker extends Disposable { const provider = allProviders.find(p => p.id === providerId); const connectionStatus = provider && isAgentHostProvider(provider) ? provider.connectionStatus?.get() : undefined; const isDisconnected = RemoteAgentHostConnectionStatus.isDisconnected(connectionStatus) || RemoteAgentHostConnectionStatus.isIncompatible(connectionStatus); - const selection: IWorkspaceSelection = { providerId, workspace }; - const selected = this._isSelectedWorkspace(selection); + const folderUri = workspace.folders[0]?.root; + if (!folderUri) { + continue; + } + const selected = this._isSelectedFolder(folderUri); items.push({ kind: ActionListItemKind.Action, label: workspace.label, description: workspace.description, group: { title: '', icon: workspace.icon }, disabled: isDisconnected, - item: { selection, checked: selected || undefined }, - onRemove: isOwnRecent ? () => this._removeRecentWorkspace(selection) : () => this._removeVSCodeRecentWorkspace(selection), + item: { folderUri, providerId, checked: selected || undefined }, + onRemove: isOwnRecent ? () => this._removeRecentWorkspace(folderUri) : () => this._removeVSCodeRecentWorkspace(folderUri), }); } @@ -784,7 +854,7 @@ export class WorkspacePicker extends Disposable { } dom.clearNode(this._triggerElement); - const workspace = this._selectedWorkspace?.workspace; + const workspace = this._selectedResolved?.workspace; const label = workspace ? workspace.label : localize('pickWorkspace', "workspace"); const icon = workspace ? workspace.icon : Codicon.project; @@ -795,8 +865,7 @@ export class WorkspacePicker extends Disposable { dom.append(this._triggerElement, renderIcon(icon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = label; - const chevron = dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - chevron.classList.add('sessions-chat-dropdown-chevron'); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)).classList.add('sessions-chat-dropdown-chevron'); } /** @@ -812,27 +881,18 @@ export class WorkspacePicker extends Disposable { return !RemoteAgentHostConnectionStatus.isConnected(provider.connectionStatus.get()); } - protected _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { - if (!this._selectedWorkspace) { - return false; - } - if (this._selectedWorkspace.providerId !== selection.providerId) { + protected _isSelectedFolder(folderUri: URI | undefined): boolean { + if (!this._selectedFolderUri || !folderUri) { return false; } - const selectedUri = this._selectedWorkspace.workspace.folders[0]?.root; - const candidateUri = selection.workspace.folders[0]?.root; - return this.uriIdentityService.extUri.isEqual(selectedUri, candidateUri); + return this.uriIdentityService.extUri.isEqual(this._selectedFolderUri, folderUri); } - private _persistSelectedWorkspace(selection: IWorkspaceSelection): void { - const uri = selection.workspace.folders[0]?.root; - if (!uri) { - return; - } - this._addRecentWorkspace(selection.providerId, selection.workspace, true); + private _persistSelectedFolder(folderUri: URI, providerId: string | undefined): void { + this._addRecentFolder(folderUri, providerId, true); } - private _restoreSelectedWorkspace(): IWorkspaceSelection | undefined { + private _restoreSelectedWorkspace(): IResolvedFolderWorkspace | undefined { // Try the checked entry first const checked = this._restoreCheckedWorkspace(); if (checked) { @@ -844,22 +904,17 @@ export class WorkspacePicker extends Disposable { // to be ready: we don't want to silently land on, e.g., a disconnected // remote workspace that the user never picked. try { - const providers = this.sessionsProvidersService.getProviders(); - const providerIds = new Set(providers.map(p => p.id)); const storedRecents = this._getStoredRecentWorkspaces(); - for (const stored of storedRecents) { - if (!providerIds.has(stored.providerId)) { + const uri = URI.revive(stored.uri); + const resolved = this._resolveFolder(uri, stored.providerId); + if (!resolved) { continue; } - if (this._isProviderUnavailable(stored.providerId)) { + if (this._isProviderUnavailable(resolved.providerId)) { continue; } - const uri = URI.revive(stored.uri); - const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri); - if (workspace) { - return { providerId: stored.providerId, workspace }; - } + return resolved; } return undefined; } catch { @@ -868,26 +923,24 @@ export class WorkspacePicker extends Disposable { } /** - * Restore only the checked (previously selected) workspace if its provider - * is registered. The provider's connection status is intentionally NOT - * checked — we honor the user's explicit pick even if the remote is still - * connecting or currently disconnected. The trigger label reflects the - * connection state separately (spinner / grayed). + * Restore only the checked (previously selected) workspace if any + * provider can resolve its URI. The provider's connection status is + * intentionally NOT checked — we honor the user's explicit pick even + * if the remote is still connecting or currently disconnected. The + * trigger label reflects the connection state separately + * (spinner / grayed). */ - private _restoreCheckedWorkspace(): IWorkspaceSelection | undefined { + private _restoreCheckedWorkspace(): IResolvedFolderWorkspace | undefined { try { - const providers = this.sessionsProvidersService.getProviders(); - const providerIds = new Set(providers.map(p => p.id)); const storedRecents = this._getStoredRecentWorkspaces(); - for (const stored of storedRecents) { - if (!stored.checked || !providerIds.has(stored.providerId)) { + if (!stored.checked) { continue; } const uri = URI.revive(stored.uri); - const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri); - if (workspace) { - return { providerId: stored.providerId, workspace }; + const resolved = this._resolveFolder(uri, stored.providerId); + if (resolved) { + return resolved; } } return undefined; @@ -911,8 +964,8 @@ export class WorkspacePicker extends Disposable { * * Has no effect once the user makes an explicit pick (`_userHasPicked`). */ - private _watchForConnectionFailure(selection: IWorkspaceSelection): void { - const provider = this.sessionsProvidersService.getProvider(selection.providerId); + private _watchForConnectionFailure(resolved: IResolvedFolderWorkspace): void { + const provider = this.sessionsProvidersService.getProvider(resolved.providerId); if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) { return; } @@ -921,13 +974,19 @@ export class WorkspacePicker extends Disposable { return; } + const folderUri = resolved.workspace.folders[0]?.root; + if (!folderUri) { + return; + } + const store = new DisposableStore(); this._connectionStatusWatch.value = store; const fallback = () => { this._connectionStatusWatch.clear(); - if (!this._userHasPicked && this._isSelectedWorkspace(selection)) { - this._selectedWorkspace = undefined; + if (!this._userHasPicked && this._isSelectedFolder(folderUri)) { + this._selectedFolderUri = undefined; + this._selectedResolved = undefined; this._updateTriggerLabel(); this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(undefined); @@ -957,15 +1016,11 @@ export class WorkspacePicker extends Disposable { // -- Recent workspaces storage -- - private _addRecentWorkspace(providerId: string, workspace: ISessionWorkspace, checked: boolean): void { - const uri = workspace.folders[0]?.root; - if (!uri) { - return; - } + private _addRecentFolder(folderUri: URI, providerId: string | undefined, checked: boolean): void { const recents = this._getStoredRecentWorkspaces(); const filtered = recents.map(p => { // Remove the entry being re-added (it will go to the front) - if (p.providerId === providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri)) { + if (this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri)) { return undefined; } // Clear checked from all other entries when marking checked @@ -975,55 +1030,45 @@ export class WorkspacePicker extends Disposable { return p; }).filter((p): p is IStoredRecentWorkspace => p !== undefined); - const entry: IStoredRecentWorkspace = { uri: uri.toJSON(), providerId, checked }; + const entry: IStoredRecentWorkspace = { uri: folderUri.toJSON(), providerId, checked }; const updated = [entry, ...filtered].slice(0, MAX_RECENT_WORKSPACES); this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); } - protected _getRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] { + protected _getRecentWorkspaces(): IResolvedFolderWorkspace[] { return this._getStoredRecentWorkspaces() .map(stored => { const uri = URI.revive(stored.uri); - const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri); - if (!workspace) { - return undefined; - } - return { providerId: stored.providerId, workspace }; + return this._resolveFolder(uri, stored.providerId); }) - .filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined); + .filter((w): w is IResolvedFolderWorkspace => w !== undefined); } - protected _removeRecentWorkspace(selection: IWorkspaceSelection): void { - const uri = selection.workspace.folders[0]?.root; - if (!uri) { - return; - } + protected _removeRecentWorkspace(folderUri: URI): void { const recents = this._getStoredRecentWorkspaces(); const updated = recents.filter(p => - !(p.providerId === selection.providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri)) + !this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri) ); this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); // Clear current selection if it was the removed workspace - if (this._isSelectedWorkspace(selection)) { + if (this._isSelectedFolder(folderUri)) { this._hidePicker(); - this._selectedWorkspace = undefined; + this._selectedFolderUri = undefined; + this._selectedResolved = undefined; this._updateTriggerLabel(); this._onDidSelectWorkspace.fire(undefined); } } - protected _removeVSCodeRecentWorkspace(selection: IWorkspaceSelection): void { - const uri = selection.workspace.folders[0]?.root; - if (!uri) { - return; - } - this.workspacesService.removeRecentlyOpened([uri]); + protected _removeVSCodeRecentWorkspace(folderUri: URI): void { + this.workspacesService.removeRecentlyOpened([folderUri]); // Clear current selection if it was the removed workspace - if (this._isSelectedWorkspace(selection)) { + if (this._isSelectedFolder(folderUri)) { this._hidePicker(); - this._selectedWorkspace = undefined; + this._selectedFolderUri = undefined; + this._selectedResolved = undefined; this._updateTriggerLabel(); this._onDidSelectWorkspace.fire(undefined); } @@ -1065,7 +1110,7 @@ export class WorkspacePicker extends Disposable { * providers, excluding any URIs already present in the sessions' own * recent workspace history. */ - protected _getVSCodeRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] { + protected _getVSCodeRecentWorkspaces(): IResolvedFolderWorkspace[] { if (this._vsCodeRecentFolderUris.length === 0) { return []; } @@ -1074,21 +1119,15 @@ export class WorkspacePicker extends Disposable { const ownRecents = this._getStoredRecentWorkspaces(); const ownUris = new Set(ownRecents.map(r => URI.revive(r.uri).toString())); - const providers = this.sessionsProvidersService.getProviders(); - const result: { providerId: string; workspace: ISessionWorkspace }[] = []; + const result: IResolvedFolderWorkspace[] = []; for (const folderUri of this._vsCodeRecentFolderUris) { if (ownUris.has(folderUri.toString())) { continue; } - for (const provider of providers) { - if (this._isProviderUnavailable(provider.id)) { - continue; - } - const workspace = provider.resolveWorkspace(folderUri); - if (workspace) { - result.push({ providerId: provider.id, workspace }); - } + const resolved = this._resolveFolder(folderUri); + if (resolved && !this._isProviderUnavailable(resolved.providerId)) { + result.push(resolved); } if (result.length >= 10) { break; diff --git a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts index a57d983dc8ed41..1189125cb6db8c 100644 --- a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts @@ -14,14 +14,13 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; -import { IWorkspacePickerItem, IWorkspaceSelection, WorkspacePicker } from './sessionWorkspacePicker.js'; +import { IWorkspacePickerItem, WorkspacePicker } from './sessionWorkspacePicker.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from './mobile/mobileWorkspacePickerSheet.js'; import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; @@ -58,7 +57,6 @@ export class WebWorkspacePicker extends WorkspacePicker { @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @IFileDialogService fileDialogService: IFileDialogService, - @IQuickInputService quickInputService: IQuickInputService, @ITelemetryService telemetryService: ITelemetryService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @@ -76,7 +74,6 @@ export class WebWorkspacePicker extends WorkspacePicker { contextKeyService, instantiationService, fileDialogService, - quickInputService, telemetryService, ); @@ -116,8 +113,8 @@ export class WebWorkspacePicker extends WorkspacePicker { private _onScopedHostChanged(): void { const scopedProviderId = this._agentHostFilterService.selectedProviderId; - const current = this.selectedProject; - if (current && scopedProviderId !== undefined && current.providerId === scopedProviderId) { + const currentResolved = this.selectedResolved; + if (currentResolved && scopedProviderId !== undefined && currentResolved.providerId === scopedProviderId) { this._onDidChangeSelection.fire(); return; } @@ -126,8 +123,11 @@ export class WebWorkspacePicker extends WorkspacePicker { ? this._getRecentWorkspaces().find(w => w.providerId === scopedProviderId) : undefined; if (firstRecent) { - this.setSelectedWorkspace({ providerId: firstRecent.providerId, workspace: firstRecent.workspace }); - return; + const folderUri = firstRecent.workspace.folders[0]?.root; + if (folderUri) { + this.setSelectedWorkspace(folderUri); + return; + } } this.clearSelection(); @@ -149,14 +149,18 @@ export class WebWorkspacePicker extends WorkspacePicker { // 1. Recent workspaces for the scoped provider const recents = this._getRecentWorkspaces().filter(w => w.providerId === scopedProviderId); for (const { workspace, providerId } of recents) { - const selection: IWorkspaceSelection = { providerId, workspace }; + const folderUri = workspace.folders[0]?.root; + if (!folderUri) { + continue; + } + const checked = this._isSelectedFolder(folderUri); items.push({ kind: ActionListItemKind.Action, label: workspace.label, description: workspace.description, group: { title: '', icon: workspace.icon }, - item: { selection, checked: this._isSelectedWorkspace(selection) || undefined }, - onRemove: () => this._removeRecentWorkspace(selection), + item: { folderUri, providerId, checked: checked || undefined }, + onRemove: () => this._removeRecentWorkspace(folderUri), }); } diff --git a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts index 290bf5f13cb57c..80270cdfc12354 100644 --- a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts +++ b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/com import { autorun } from '../../../../base/common/observable.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; -import { ISession } from '../../../services/sessions/common/session.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsTasksService } from './sessionsTasksService.js'; @@ -15,19 +15,15 @@ const LOG_PREFIX = '[WorktreeCreatedTaskDispatcher]'; /** * Workbench contribution that runs all tasks tagged with - * `runOptions.runOn === 'worktreeCreated'` once per session, when the session - * first appears and finishes loading. + * `runOptions.runOn === 'worktreeCreated'` once per newly-created session, + * when the session first reports an actual git worktree. * * Sessions whose runtime already runs these tasks server-side (signalled via * {@link ISessionCapabilities.runsWorktreeCreatedTasks}) are skipped to avoid * double-execution. * - * We deliberately don't persist any "already ran" marker across reloads: - * worktree creation itself is a one-shot event, setup tasks are conventionally - * idempotent (`npm install`, `pip install -r ...`), and the cost of running - * them again on the rare case where the agents window reloads while the same - * session is still attached is much smaller than the leak / state-management - * cost of tracking them indefinitely. + * We deliberately ignore sessions that predate this contribution so restored + * sessions don't re-run setup tasks when the agents window opens. */ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbenchContribution { @@ -36,6 +32,7 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe // Track per-session disposables (one per in-flight session subscription) so // we tear them down when the session is removed. private readonly _sessionDisposables = this._register(new DisposableMap()); + private readonly _dispatchedSessions = new Set(); constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @@ -44,7 +41,6 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe ) { super(); - // Bootstrap: handle sessions that are already known when we start up. for (const session of this._sessionsManagementService.getSessions()) { this._trackSession(session); } @@ -53,15 +49,26 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe } private _onDidChangeSessions(e: ISessionsChangeEvent): void { + const removedTrackedSessions: ISession[] = []; + for (const session of e.removed) { + if (this._sessionDisposables.get(session.sessionId) && !this._dispatchedSessions.has(session.sessionId)) { + removedTrackedSessions.push(session); + } + this._sessionDisposables.deleteAndDispose(session.sessionId); + this._dispatchedSessions.delete(session.sessionId); + } for (const session of e.added) { this._trackSession(session); } - for (const session of e.removed) { - this._sessionDisposables.deleteAndDispose(session.sessionId); + const replacement = e.added.length === 0 && e.changed.length === 1 && removedTrackedSessions.length === 1 + ? removedTrackedSessions[0] + : undefined; + for (const session of e.changed) { + this._trackSession(session, replacement?.providerId === session.providerId && replacement.sessionType === session.sessionType); } } - private _trackSession(session: ISession): void { + private _trackSession(session: ISession, allowReadySession = false): void { if (session.capabilities.runsWorktreeCreatedTasks) { // The session's runtime already runs these tasks itself. return; @@ -69,23 +76,42 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe if (this._sessionDisposables.get(session.sessionId)) { return; } + if (!allowReadySession && !this._isPendingWorktreeSession(session)) { + return; + } const store = new DisposableStore(); this._sessionDisposables.set(session.sessionId, store); - // Wait for the session to finish loading, then dispatch any pending - // worktreeCreated tasks once. Set `dispatched` synchronously before - // the await so any re-firing of the autorun observes it and bails. + // Wait for the session to finish loading and report an actual worktree, + // then dispatch any pending worktreeCreated tasks once. Set + // `dispatched` synchronously before the await so any re-firing of the + // autorun observes it and bails. let dispatched = false; store.add(autorun(reader => { if (session.loading.read(reader) || dispatched) { return; } + if (session.status.read(reader) === SessionStatus.Untitled) { + return; + } + if (!session.workspace.read(reader)?.folders.some(folder => !!folder.gitRepository?.workTreeUri)) { + return; + } dispatched = true; + this._dispatchedSessions.add(session.sessionId); void this._dispatchWorktreeCreatedTasks(session); })); } + private _isPendingWorktreeSession(session: ISession): boolean { + return session.status.get() === SessionStatus.Untitled || session.loading.get() || !this._hasWorktree(session); + } + + private _hasWorktree(session: ISession): boolean { + return session.workspace.get()?.folders.some(folder => !!folder.gitRepository?.workTreeUri) ?? false; + } + private async _dispatchWorktreeCreatedTasks(session: ISession): Promise { let tasks; try { diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts index d6b0fb72fd0bea..8a2b0718dcbb55 100644 --- a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -5,10 +5,10 @@ import { URI } from '../../../../base/common/uri.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSource } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; // Re-export from common for backward compatibility -export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +export type { AICustomizationSource as AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; /** @@ -16,7 +16,7 @@ export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCus */ export interface IBuiltinPromptPath { readonly uri: URI; - readonly storage: AICustomizationPromptsStorage; + readonly storage: AICustomizationSource; readonly type: PromptsType; readonly name?: string; readonly description?: string; diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 98859ecf376a07..974336d7ea077c 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -59,16 +59,14 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon } private tryResolveAndSelect(folderUri: URI): boolean { - for (const provider of this.sessionsProvidersService.getProviders()) { - const workspace = provider.resolveWorkspace(folderUri); - if (workspace) { - this.viewsService.openView(SessionsViewId).then(view => { - view?.selectWorkspace({ providerId: provider.id, workspace }); - }); - return true; - } + const resolved = this.sessionsManagementService.resolveWorkspace(folderUri); + if (!resolved) { + return false; } - return false; + this.viewsService.openView(SessionsViewId).then(view => { + view?.selectWorkspace(folderUri); + }); + return true; } } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index bb7865f0f9d0f1..7ce387622b47a5 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -29,7 +29,7 @@ import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../ import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; -import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js'; +import { WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; import { IWorkspacesService } from '../../../../../platform/workspaces/common/workspaces.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -45,10 +45,23 @@ const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; // ---- Mock providers --------------------------------------------------------- +// Maps mock provider id → URI path prefix it resolves. In production, the +// URI's authority/scheme determines which provider can resolve it; the +// tests use file URIs only, so we map provider ids to their conventional +// path roots (e.g. /remote, /local, /copilot, /agent-host). +const MOCK_PROVIDER_PATH_PREFIXES: Record = { + 'agenthost-remote-1': '/remote', + 'local-1': '/local', + 'default-copilot': '/copilot', + 'local-agent-host': '/agent-host', +}; + function createMockProvider(id: string, opts?: { connectionStatus?: ISettableObservable; browseActions?: readonly ISessionWorkspaceBrowseAction[]; }): ISessionsProvider { + const pathPrefix = MOCK_PROVIDER_PATH_PREFIXES[id]; + const canResolve = (uri: URI) => !pathPrefix || uri.path === pathPrefix || uri.path.startsWith(`${pathPrefix}/`); const base = { id, label: `Provider ${id}`, @@ -56,20 +69,25 @@ function createMockProvider(id: string, opts?: { sessionTypes: [], onDidChangeSessionTypes: Event.None, browseActions: opts?.browseActions ?? [], - resolveWorkspace: (uri: URI): ISessionWorkspace => ({ - uri, - label: uri.path.substring(1) || uri.path, - icon: Codicon.folder, - folders: [{ - root: uri, - workingDirectory: uri, - name: uri.path.substring(1) || uri.path, - description: undefined, - gitRepository: { uri, workTreeUri: undefined, baseBranchName: undefined, gitHubInfo: constObservable(undefined) }, - }], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }), + resolveWorkspace: (uri: URI): ISessionWorkspace | undefined => { + if (!canResolve(uri)) { + return undefined; + } + return { + uri, + label: uri.path.substring(1) || uri.path, + icon: Codicon.folder, + folders: [{ + root: uri, + workingDirectory: uri, + name: uri.path.substring(1) || uri.path, + description: undefined, + gitRepository: { uri, workTreeUri: undefined, baseBranchName: undefined, gitHubInfo: constObservable(undefined) }, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; + }, onDidChangeSessions: Event.None, getSessions: () => [], createNewSession: () => { throw new Error('Not implemented'); }, @@ -178,7 +196,7 @@ function createTestPicker( // ---- Assertion helpers ------------------------------------------------------ function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: string | undefined, message?: string): void { - assert.strictEqual(picker.selectedProject?.providerId, expectedProviderId, message); + assert.strictEqual(picker.selectedResolved?.providerId, expectedProviderId, message); } // ---- Tests ------------------------------------------------------------------ @@ -237,7 +255,7 @@ suite('WorkspacePicker - Connection Status', () => { assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored synchronously'); - const events: Array = []; + const events: Array = []; disposables.add(picker.onDidSelectWorkspace(e => events.push(e))); // Advance past the grace period. @@ -287,11 +305,7 @@ suite('WorkspacePicker - Connection Status', () => { const picker = createTestPicker(disposables, providersService, storage); // User picks a local workspace while the remote is still trying to connect. - const localPick: IWorkspaceSelection = { - providerId: 'local-1', - workspace: localProvider.resolveWorkspace(URI.file('/local/picked'))!, - }; - picker.setSelectedWorkspace(localPick, false); + picker.setSelectedWorkspace(URI.file('/local/picked'), { fireEvent: false }); // Grace period elapses; remote still disconnected — must not affect user pick. await timeout(10_000); @@ -344,7 +358,7 @@ suite('WorkspacePicker - Connection Status', () => { assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored while connecting'); - const events: Array = []; + const events: Array = []; disposables.add(picker.onDidSelectWorkspace(e => events.push(e))); // SSH tunnel begins. @@ -409,7 +423,7 @@ suite('WorkspacePicker - Connection Status', () => { remoteStatus.set(RemoteAgentHostConnectionStatus.connected, undefined); assertSelectedProvider(picker, 'agenthost-remote-1'); assert.strictEqual( - picker.selectedProject?.workspace.folders[0]?.root.path, + picker.selectedResolved?.workspace.folders[0]?.root.path, '/remote/project', ); }); @@ -431,19 +445,15 @@ suite('WorkspacePicker - Connection Status', () => { // Select the local workspace const resolvedWorkspace = localProvider.resolveWorkspace(URI.file('/local/project')); assert.ok(resolvedWorkspace, 'resolveWorkspace should resolve file:// URIs'); - const localWorkspace: IWorkspaceSelection = { - providerId: 'local-1', - workspace: resolvedWorkspace, - }; - picker.setSelectedWorkspace(localWorkspace, false); + picker.setSelectedWorkspace(URI.file('/local/project'), { fireEvent: false }); // Verify storage: only the local entry should be checked const raw = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); assert.ok(raw, 'Storage should have recent workspaces'); - const stored = JSON.parse(raw!) as { providerId: string; checked: boolean }[]; + const stored = JSON.parse(raw!) as { uri: { path: string }; checked: boolean }[]; const checkedEntries = stored.filter(e => e.checked); assert.strictEqual(checkedEntries.length, 1, 'Only one entry should be checked'); - assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked'); + assert.strictEqual(checkedEntries[0].uri.path, '/local/project', 'The local entry should be checked'); }); test('local provider is never treated as unavailable', () => { @@ -511,11 +521,7 @@ suite('WorkspacePicker - Connection Status', () => { assertSelectedProvider(picker, undefined, 'No fallback while checked entry pending'); // User explicitly picks a Copilot workspace. - const copilotPick: IWorkspaceSelection = { - providerId: 'default-copilot', - workspace: copilotProvider.resolveWorkspace(URI.file('/copilot/picked'))!, - }; - picker.setSelectedWorkspace(copilotPick, false); + picker.setSelectedWorkspace(URI.file('/copilot/picked'), { fireEvent: false }); assertSelectedProvider(picker, 'default-copilot', 'User pick is honored'); // Now the late provider for the (still-stored) checked entry arrives. diff --git a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts index 78f4aa005a6be4..cbe7903f476e58 100644 --- a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts @@ -20,10 +20,33 @@ import { WorktreeCreatedTaskDispatcher } from '../../browser/worktreeCreatedTask interface ITestSession { readonly session: ISession; readonly loading: ReturnType>; + readonly status: ReturnType>; + readonly workspace: ReturnType>; } -function makeSession(opts: { id?: string; runsWorktreeCreatedTasks?: boolean; loading?: boolean } = {}): ITestSession { +function makeWorkspace(hasWorktree: boolean): ISessionWorkspace { + const root = URI.parse('file:///repo'); + const workTreeUri = hasWorktree ? URI.parse('file:///repo-worktree') : undefined; + return { + uri: root, + label: 'repo', + icon: Codicon.folder, + folders: [{ + root, + workingDirectory: workTreeUri ?? root, + name: 'repo', + description: undefined, + gitRepository: { uri: root, workTreeUri, baseBranchName: undefined, gitHubInfo: constObservable(undefined) }, + }], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }; +} + +function makeSession(opts: { id?: string; runsWorktreeCreatedTasks?: boolean; loading?: boolean; status?: SessionStatus; hasWorktree?: boolean } = {}): ITestSession { const loading = observableValue('loading', opts.loading ?? false); + const status = observableValue('status', opts.status ?? SessionStatus.InProgress); + const workspace = observableValue('workspace', makeWorkspace(opts.hasWorktree ?? true)); const chat = { resource: URI.parse('file:///session') } as IChat; const session: ISession = { sessionId: opts.id ?? 'test:session', @@ -32,10 +55,10 @@ function makeSession(opts: { id?: string; runsWorktreeCreatedTasks?: boolean; lo sessionType: 'background', icon: Codicon.copilot, createdAt: new Date(), - workspace: constObservable(undefined as ISessionWorkspace | undefined), + workspace, title: observableValue('title', 'session'), updatedAt: observableValue('updatedAt', new Date()), - status: observableValue('status', SessionStatus.Untitled), + status, changesets: constObservable([]), changes: constObservable([]), modelId: observableValue('modelId', undefined), @@ -49,7 +72,7 @@ function makeSession(opts: { id?: string; runsWorktreeCreatedTasks?: boolean; lo mainChat: chat, capabilities: { supportsMultipleChats: false, runsWorktreeCreatedTasks: opts.runsWorktreeCreatedTasks }, }; - return { session, loading }; + return { session, loading, status, workspace }; } function entry(label: string, runOn?: 'worktreeCreated' | 'folderOpen' | 'default'): ISessionTaskWithTarget { @@ -124,13 +147,15 @@ suite('WorktreeCreatedTaskDispatcher', () => { test('runs worktreeCreated tasks once for a newly added session', async () => { createDispatcher(); - const { session } = makeSession({ id: 'a' }); + const { session, workspace } = makeSession({ id: 'a', hasWorktree: false }); tasks.setTasks(session.sessionId, [ entry('setup', 'worktreeCreated'), entry('lint'), ]); mgmt.emitter.fire({ added: [session], removed: [], changed: [] }); await settle(); + workspace.set(makeWorkspace(true), undefined); + await settle(); assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); }); @@ -138,12 +163,13 @@ suite('WorktreeCreatedTaskDispatcher', () => { test('runTask failures are logged but do not abort the loop', async () => { createDispatcher(); tasks.runTaskFails = true; - const { session } = makeSession({ id: 'a' }); + const { session, workspace } = makeSession({ id: 'a', hasWorktree: false }); tasks.setTasks(session.sessionId, [ entry('setup-a', 'worktreeCreated'), entry('setup-b', 'worktreeCreated'), ]); mgmt.emitter.fire({ added: [session], removed: [], changed: [] }); + workspace.set(makeWorkspace(true), undefined); await settle(); // Both tasks are attempted even though each throws. @@ -173,11 +199,13 @@ suite('WorktreeCreatedTaskDispatcher', () => { test('per-session task lists do not cross-contaminate', async () => { createDispatcher(); - const { session: sessionA } = makeSession({ id: 'a' }); - const { session: sessionB } = makeSession({ id: 'b' }); + const { session: sessionA, workspace: workspaceA } = makeSession({ id: 'a', hasWorktree: false }); + const { session: sessionB, workspace: workspaceB } = makeSession({ id: 'b', hasWorktree: false }); tasks.setTasks(sessionA.sessionId, [entry('setup-a', 'worktreeCreated')]); tasks.setTasks(sessionB.sessionId, [entry('setup-b', 'worktreeCreated')]); mgmt.emitter.fire({ added: [sessionA, sessionB], removed: [], changed: [] }); + workspaceA.set(makeWorkspace(true), undefined); + workspaceB.set(makeWorkspace(true), undefined); await settle(); // Each task fires against its own session. @@ -227,7 +255,7 @@ suite('WorktreeCreatedTaskDispatcher', () => { assert.deepStrictEqual(tasks.ranTasks, []); }); - test('handles sessions present at startup', async () => { + test('does not run for sessions present at startup', async () => { const { session } = makeSession({ id: 'a' }); tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); mgmt.sessions = [session]; @@ -235,6 +263,107 @@ suite('WorktreeCreatedTaskDispatcher', () => { createDispatcher(); await settle(); + assert.deepStrictEqual(tasks.ranTasks, []); + }); + + test('tracks pending untitled sessions present at startup', async () => { + const { session, status, workspace } = makeSession({ + id: 'a', + status: SessionStatus.Untitled, + hasWorktree: false + }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + mgmt.sessions = [session]; + + createDispatcher(); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, []); + + status.set(SessionStatus.InProgress, undefined); + workspace.set(makeWorkspace(true), undefined); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); + }); + + test('does not run for restored sessions reported as added after startup', async () => { + createDispatcher(); + const { session } = makeSession({ id: 'a' }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + mgmt.emitter.fire({ added: [session], removed: [], changed: [] }); + await settle(); + + assert.deepStrictEqual(tasks.ranTasks, []); + }); + + test('runs for committed replacement of tracked pending session', async () => { + createDispatcher(); + const { session: pending } = makeSession({ id: 'pending', hasWorktree: false }); + const { session: committed } = makeSession({ id: 'committed', hasWorktree: true }); + tasks.setTasks(committed.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.emitter.fire({ added: [pending], removed: [], changed: [] }); + await settle(); + mgmt.emitter.fire({ added: [], removed: [pending], changed: [committed] }); + await settle(); + + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'committed' }]); + }); + + test('does not treat mixed changed sessions as pending replacements', async () => { + createDispatcher(); + const { session: pending } = makeSession({ id: 'pending', hasWorktree: false }); + const { session: committed } = makeSession({ id: 'committed', hasWorktree: true }); + const { session: restored } = makeSession({ id: 'restored', hasWorktree: true }); + tasks.setTasks(committed.sessionId, [entry('setup-committed', 'worktreeCreated')]); + tasks.setTasks(restored.sessionId, [entry('setup-restored', 'worktreeCreated')]); + + mgmt.emitter.fire({ added: [pending], removed: [], changed: [] }); + await settle(); + mgmt.emitter.fire({ added: [], removed: [pending], changed: [committed, restored] }); + await settle(); + + assert.deepStrictEqual(tasks.ranTasks, []); + }); + + test('does not treat dispatched sessions as pending replacements', async () => { + createDispatcher(); + const { session: dispatched, workspace } = makeSession({ id: 'dispatched', hasWorktree: false }); + const { session: changed } = makeSession({ id: 'changed', hasWorktree: true }); + tasks.setTasks(dispatched.sessionId, [entry('setup-dispatched', 'worktreeCreated')]); + tasks.setTasks(changed.sessionId, [entry('setup-changed', 'worktreeCreated')]); + + mgmt.emitter.fire({ added: [dispatched], removed: [], changed: [] }); + workspace.set(makeWorkspace(true), undefined); + await settle(); + mgmt.emitter.fire({ added: [], removed: [dispatched], changed: [changed] }); + await settle(); + + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup-dispatched', sessionId: 'dispatched' }]); + }); + + test('waits for a worktree before running', async () => { + createDispatcher(); + const { session, workspace } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + mgmt.emitter.fire({ added: [session], removed: [], changed: [] }); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, []); + + workspace.set(makeWorkspace(true), undefined); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); + }); + + test('waits for untitled sessions to start before running', async () => { + createDispatcher(); + const { session, status } = makeSession({ id: 'a', status: SessionStatus.Untitled }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + mgmt.emitter.fire({ added: [session], removed: [], changed: [] }); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, []); + + status.set(SessionStatus.InProgress, undefined); + await settle(); assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 299743f86a2bf7..87d84c1e419c7d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -898,7 +898,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error(`Agent session URI has no provider scheme: ${meta.session.toString()}`); } return new AgentHostSessionAdapter(meta, this.id, this.resourceSchemeForProvider(provider), provider, { - icon: this.icon, + icon: this.iconForAgentProvider(provider) ?? this.icon, loading: this.authenticationPending, mapDiffUri: this._diffUriMapper(), gitHubService: this._gitHubService, @@ -1065,7 +1065,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement workspace, sessionType, providerId: this.id, - icon: this.icon, + icon: sessionType.icon, resourceScheme: this.resourceSchemeForProvider(sessionType.id), authenticationPending: this.authenticationPending, logService: this._logService, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index b6cdf51131d79f..c3ffacc6dfdc48 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -123,7 +123,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const uriForDescription = project?.uri ?? workingDirectory; const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined; const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, workingDirectory ?? project?.uri); - return LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, this._localLabel, gitHubInfo, gitState, description, branchProtectionPatterns); + return LocalAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, gitHubInfo, gitState, description, branchProtectionPatterns); }, }; } @@ -143,8 +143,13 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide // -- Workspaces ---------------------------------------------------------- - static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { - return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_LOCAL }, gitHubInfo, gitState); + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitHubInfo: IObservable, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined { + // Intentionally pass `undefined` for `providerLabel` so the workspace + // label matches the one produced by `resolveWorkspace` (and by other + // providers serving the same folder). Sessions list grouping uses + // `workspace.label` as the group key — divergent labels would surface + // the same folder as multiple groups. + return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel: undefined, fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_LOCAL }, gitHubInfo, gitState); } resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined { @@ -154,7 +159,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const folderName = basename(repositoryUri) || repositoryUri.path; return { uri: repositoryUri, - label: `${folderName} [${this._localLabel}]`, + label: folderName, description: this._labelService.getUriLabel(dirname(repositoryUri), { relative: false }), group: SESSION_WORKSPACE_GROUP_LOCAL, icon: Codicon.folder, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index f6c9eab8b0f846..f5c88849b37b6e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -398,15 +398,36 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + test('session icons match the session type icon', () => { + agentHost.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'claude-code', displayName: 'Claude', description: '', models: [] } as AgentInfo, + { provider: 'unknown-agent', displayName: 'Unknown', description: '', models: [] } as AgentInfo, + ]); + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'cli-sess', { title: 'CLI', provider: 'copilotcli' }); + fireSessionAdded(agentHost, 'claude-sess', { title: 'Claude', provider: 'claude-code' }); + fireSessionAdded(agentHost, 'unknown-sess', { title: 'Unknown', provider: 'unknown-agent' }); + + assert.deepStrictEqual( + provider.getSessions().map(s => ({ sessionType: s.sessionType, icon: s.icon.id })).sort((a, b) => a.sessionType.localeCompare(b.sessionType)), + [ + { sessionType: 'claude-code', icon: 'claude' }, + { sessionType: 'copilotcli', icon: 'copilot' }, + { sessionType: 'unknown-agent', icon: 'vm' }, + ], + ); + }); + // ---- Workspace resolution ------- - test('resolveWorkspace builds workspace from URI with [Local] tag', () => { + test('resolveWorkspace builds workspace from URI', () => { const provider = createProvider(disposables, agentHost); const uri = URI.parse('file:///home/user/project'); const ws = provider.resolveWorkspace(uri); assert.ok(ws, 'resolveWorkspace should resolve file:// URIs'); - assert.strictEqual(ws.label, 'project [Local]'); + assert.strictEqual(ws.label, 'project'); assert.strictEqual(ws.folders.length, 1); assert.strictEqual(ws.folders[0].root.toString(), uri.toString()); assert.strictEqual(ws.requiresWorkspaceTrust, true); @@ -564,13 +585,13 @@ suite('LocalAgentHostSessionsProvider', () => { repository: workspace?.folders[0]?.root.toString(), workingDirectory: workspace?.folders[0]?.workingDirectory?.toString(), }, { - label: 'vscode [Local]', + label: 'vscode', repository: projectUri.toString(), workingDirectory: workingDirectory.toString(), }); })); - test('listed session with only workingDirectory (no project) shows folder name with [Local] tag', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('listed session with only workingDirectory (no project) shows folder name', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const workingDirectory = URI.file('/home/user/standalone-folder'); agentHost.addSession(createSession('wd-only-1', { summary: 'WD-only Session', @@ -582,7 +603,7 @@ suite('LocalAgentHostSessionsProvider', () => { await timeout(0); const workspace = provider.getSessions()[0].workspace.get(); - assert.strictEqual(workspace?.label, 'standalone-folder [Local]'); + assert.strictEqual(workspace?.label, 'standalone-folder'); })); test('uses model metadata as selected model for listed sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -645,7 +666,7 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(session.providerId, provider.id); assert.strictEqual(session.status.get(), SessionStatus.Untitled); assert.ok(session.workspace.get()); - assert.strictEqual(session.workspace.get()?.label, 'my-project [Local]'); + assert.strictEqual(session.workspace.get()?.label, 'my-project'); assert.strictEqual(session.sessionType, provider.sessionTypes[0].id); assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} }); }); @@ -728,7 +749,7 @@ suite('LocalAgentHostSessionsProvider', () => { }, { listedSessions: 0, resolvedResource: session.resource.toString(), - resolvedWorkspaceLabel: 'my-project [Local]', + resolvedWorkspaceLabel: 'my-project', }); }); @@ -1020,7 +1041,7 @@ suite('LocalAgentHostSessionsProvider', () => { const workspace = wsSession!.workspace.get(); assert.ok(workspace); - assert.strictEqual(workspace!.label, 'myrepo [Local]'); + assert.strictEqual(workspace!.label, 'myrepo'); })); test('session adapter without working directory has no workspace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index f999a4aa3a8355..190caee59140b0 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -842,6 +842,14 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { return; } + const folder = this.sessionWorkspace.folders[0]; + const baseGitRepo: ISessionGitRepository = folder.gitRepository ?? { + uri: folder.root, + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable(undefined), + }; + this._register(autorun((reader) => { const state = repo.state.read(reader); const head = state.HEAD; @@ -854,9 +862,9 @@ class LocalNewSession extends Disposable implements ICopilotChatSession { this._workspaceData.set({ ...this.sessionWorkspace, folders: [{ - ...this.sessionWorkspace.folders[0], + ...folder, gitRepository: { - ...this.sessionWorkspace.folders[0].gitRepository!, + ...baseGitRepo, branchName, upstreamBranchName, uncommittedChanges, @@ -2867,7 +2875,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const removedData: ICopilotChatSession[] = []; for (const [key, adapter] of this._sessionCache) { - if (!currentKeys.has(key) && adapter !== this._currentNewSession) { + if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) { removedData.push(adapter); cacheChanged = true; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index e46805cbf8089f..5004fae052387b 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -593,10 +593,14 @@ async function promptForRemoteFolder( if (!workspace) { return; } + const folderUri = workspace.folders[0]?.root; + if (!folderUri) { + return; + } sessionsManagementService.openNewSessionView(); const view = await viewsService.openView(SessionsViewId, true); - view?.selectWorkspace({ providerId: provider.id, workspace }); + view?.selectWorkspace(folderUri); } registerAction2(class extends Action2 { @@ -939,10 +943,14 @@ async function promptForTunnelFolder( if (!workspace) { return; } + const folderUri = workspace.folders[0]?.root; + if (!folderUri) { + return; + } sessionsManagementService.openNewSessionView(); const view = await viewsService.openView(SessionsViewId, true); - view?.selectWorkspace({ providerId: provider.id, workspace }); + view?.selectWorkspace(folderUri); } registerAction2(class extends Action2 { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 22091f6a46bacf..594d87c92012c2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -20,11 +20,9 @@ import { ActionType } from '../../../../../platform/agentHost/common/state/sessi import { ROOT_STATE_URI, type AgentInfo, type CustomizationRef } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { BUILTIN_STORAGE } from '../../../chat/common/builtinPromptsStorage.js'; import { AgentCustomizationSyncProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; @@ -178,7 +176,7 @@ export function createRemoteAgentHarnessDescriptor( itemProvider: AgentCustomizationItemProvider, syncProvider: AgentCustomizationSyncProvider, ): IHarnessDescriptor { - const allSources = [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE]; + const allSources = [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.plugin, AICustomizationSources.extension, AICustomizationSources.builtin]; const filter: IStorageSourceFilter = { sources: allSources }; return { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index 90f3d576f7a96c..390288ea9561d2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -9,8 +9,11 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { ActionType, type ActionEnvelope, type INotification, type StateAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { CustomizationStatus, type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType, isSessionAction, type ActionEnvelope, type INotification, type StateAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { CustomizationStatus, type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { StateComponents, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; +import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { IFileService, type IFileContent, type IFileStat, type IFileStatResult } from '../../../../../../platform/files/common/files.js'; @@ -18,11 +21,10 @@ import { PromptsType } from '../../../../../../workbench/contrib/chat/common/pro import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { createRemoteAgentCustomizationItemProvider, RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js'; import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { PromptsStorage } from '../../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from '../../../../../../workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -38,6 +40,8 @@ class MockAgentConnection extends mock() { private _rootStateValue: RootState = { agents: [] }; override readonly rootState; + private readonly _sessionStates = new Map(); + readonly dispatchedActions: { channel: string; action: StateAction }[] = []; constructor() { @@ -60,7 +64,30 @@ class MockAgentConnection extends mock() { this.dispatchedActions.push({ channel, action }); } + override getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined { + if (kind !== StateComponents.Session) { + return undefined; + } + const self = this; + const channel = resource.toString(); + if (!self._sessionStates.has(channel)) { + return undefined; + } + const subscription: IAgentSubscription = { + get value() { return self._sessionStates.get(channel); }, + get verifiedValue() { return self._sessionStates.get(channel); }, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + return subscription as IAgentSubscription; + } + fireAction(envelope: ActionEnvelope): void { + if (isSessionAction(envelope.action)) { + const current = this._sessionStates.get(envelope.channel) ?? {} as SessionState; + this._sessionStates.set(envelope.channel, sessionReducer(current, envelope.action)); + } this._onDidAction.fire(envelope); } @@ -798,7 +825,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { id: harnessId, label: 'Remote Agent Host (test)', icon: ThemeIcon.fromId(Codicon.remote.id), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.plugin] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.plugin] }), itemProvider: provider, }; const harnessService = disposables.add(new CustomizationHarnessServiceBase([descriptor], harnessId, new MockPromptsService())); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index b3e9675c0d6398..fd8ca7155671c0 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -761,7 +761,7 @@ export class SessionsList extends Disposable implements ISessionsList { private readonly expandedWorkspaceGroups = new Set(); private expandedMoreFolders = false; private openWindowSourceFolder: URI | undefined; - private findOpen = false; + private hasFindPattern = false; private suspendCollapseStatePersistence = false; private readonly _onDidUpdate = this._register(new Emitter()); @@ -938,10 +938,29 @@ export class SessionsList extends Disposable implements ISessionsList { } })); + let isFindOpen = false; + let findPattern = ''; + const updateFindPatternState = () => { + const hasFindPattern = isFindOpen && findPattern.length > 0; + if (hasFindPattern !== this.hasFindPattern) { + this.hasFindPattern = hasFindPattern; + this.update(); + } + }; + this._register(this.tree.onDidChangeFindOpenState(open => { - this.findOpen = open; + isFindOpen = open; this._onDidChangeFindOpenState.fire(open); - this.update(); + updateFindPatternState(); + })); + + // Only treat the find as "active" for layout purposes (bypassing workspace + // capping and per-group limits) once the user has actually typed a pattern + // and the find widget is open. Opening the empty find widget should not + // reorder the list, and closing find should restore the capped layout. + this._register(this.tree.onDidChangeFindPattern(pattern => { + findPattern = pattern; + updateFindPatternState(); })); this._register(this._sessionsManagementService.onDidChangeSessions(() => { @@ -1020,10 +1039,11 @@ export class SessionsList extends Disposable implements ISessionsList { const hasTodaySessions = sections.some(s => s.id === 'today' && s.sessions.length > 0); // Partition workspace sections into "primary" (meets criteria) and "more" - // when grouping by workspace. Find widget bypasses partitioning. When the - // user has chosen "Show All Sessions" (uncapped), show every workspace - // group inline instead of hiding some behind a "more workspaces" entry. - const partitionFolders = grouping === SessionsGrouping.Workspace && !this.findOpen && this.workspaceGroupCapped; + // when grouping by workspace. An active find pattern bypasses partitioning + // so all matching sessions are visible. When the user has chosen + // "Show All Sessions" (uncapped), show every workspace group inline instead + // of hiding some behind a "more workspaces" entry. + const partitionFolders = grouping === SessionsGrouping.Workspace && !this.hasFindPattern && this.workspaceGroupCapped; const moreFolderSectionIds = new Set(); if (partitionFolders) { const workspaceSections = sections.filter(s => s.id.startsWith('workspace:')); @@ -1072,7 +1092,7 @@ export class SessionsList extends Disposable implements ISessionsList { const isWorkspaceGroup = grouping === SessionsGrouping.Workspace && section.id.startsWith('workspace:'); const exceedsLimit = isWorkspaceGroup - && !this.findOpen + && !this.hasFindPattern && section.sessions.length > SessionsList.WORKSPACE_GROUP_LIMIT; const isExpanded = exceedsLimit && (this.expandedWorkspaceGroups.has(section.label) || !this.workspaceGroupCapped); const isCapped = exceedsLimit && !isExpanded; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 2d3326d3e80010..8eb2e6a6b64577 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -353,9 +353,12 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { const commandService = accessor.get(ICommandService); sessionsManagementService.openNewSessionView(); const view = await viewsService.openView(NewChatViewId, true); - const workspace = context.sessions[0].workspace.get(); - if (view && workspace) { - view.selectWorkspace({ providerId: context.sessions[0].providerId, workspace }); + const session = context.sessions[0]; + const workspace = session.workspace.get(); + const folderUri = workspace?.folders[0]?.root; + const providerId = session.providerId; + if (view && folderUri) { + view.selectWorkspace(folderUri, providerId); } // On mobile web, the sidebar drawer covers the viewport; close it so // the new session view becomes visible after creation. Routes through diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 6c895badd237a1..daf2315c5da264 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -19,7 +19,7 @@ import { IAgentPluginService } from '../../../../../workbench/contrib/chat/commo import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { getChatSessionType } from '../../../../../workbench/contrib/chat/common/model/chatUri.js'; -import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAICustomizationListItem } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem, SESSIONS_CUSTOMIZATIONS_SIDEBAR_MODE_SETTING, SessionsCustomizationsSidebarMode } from '../../browser/customizationsToolbar.contribution.js'; @@ -156,7 +156,7 @@ function createMockHarnessService(hiddenSections: readonly string[] = []): ICust label: 'Fixture', icon: ThemeIcon.fromId('vm'), hiddenSections, - getStorageSourceFilter: () => ({ sources: [] }), + getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), }; return new class extends mock() { override readonly activeSessionResource = observableValue('mockActiveSessionResource', URI.parse(`${descriptor.id}:///session`)); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 44574e34a6da92..8f0d07ef4ba865 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -17,10 +17,10 @@ import { IProgressService } from '../../../../platform/progress/common/progress. import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, ActiveSessionWorkspaceIsVirtualContext, IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; -import { ActiveSessionSupportsMultiChatContext, IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; +import { ActiveSessionSupportsMultiChatContext, IActiveSession, ICreateNewSessionOptions, IProviderSessionType, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; -import { IChat, ISession, SessionStatus, ISessionType } from '../common/session.js'; +import { IChat, ISession, ISessionWorkspace, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { SessionsNavigation } from './sessionNavigation.js'; @@ -194,6 +194,29 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return [...this._sessionTypes]; } + getSessionTypesForFolder(folderUri: URI): IProviderSessionType[] { + const result: IProviderSessionType[] = []; + for (const provider of this.sessionsProvidersService.getProviders()) { + if (!provider.resolveWorkspace(folderUri)) { + continue; + } + for (const sessionType of provider.getSessionTypes(folderUri)) { + result.push({ providerId: provider.id, sessionType }); + } + } + return result; + } + + resolveWorkspace(folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { + for (const provider of this.sessionsProvidersService.getProviders()) { + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: provider.id, workspace }; + } + } + return undefined; + } + private _collectSessionTypes(): ISessionType[] { const types: ISessionType[] = []; const seen = new Set(); @@ -209,13 +232,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private _updateSessionTypes(): void { - const newTypes = this._collectSessionTypes(); - const changed = this._sessionTypes.length !== newTypes.length - || this._sessionTypes.some((t, i) => t.id !== newTypes[i].id || t.label !== newTypes[i].label); - if (changed) { - this._sessionTypes = newTypes; - this._onDidChangeSessionTypes.fire(); - } + // Always fire — the deduplicated flat list (used by surfaces that + // only need a set of type ids) may be unchanged, but the per-folder + // result of {@link getSessionTypesForFolder} can change whenever any + // provider's types or the set of providers changes, because each + // entry is keyed by (providerId × sessionType) rather than by type + // id alone. + this._sessionTypes = this._collectSessionTypes(); + this._onDidChangeSessionTypes.fire(); } /** @@ -312,24 +336,51 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.setActiveSession(undefined); } - createNewSession(providerId: string, repositoryUri: URI, sessionTypeId?: string): ISession { + createNewSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession { this._startOpenSession(); if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } - const provider = this.sessionsProvidersService.getProviders().find(p => p.id === providerId); - if (!provider) { - throw new Error(`Sessions provider '${providerId}' not found`); + const providers = this.sessionsProvidersService.getProviders(); + let provider: ISessionsProvider | undefined; + + if (options?.providerId) { + provider = providers.find(p => p.id === options.providerId); + if (!provider) { + throw new Error(`Sessions provider '${options.providerId}' not found`); + } + if (!provider.resolveWorkspace(folderUri)) { + throw new Error(`Sessions provider '${options.providerId}' cannot resolve folder '${folderUri.toString()}'`); + } + } else { + // Iterate providers and pick the first one that can resolve the folder. + // When a specific session type was requested, also require the provider to + // advertise that type for the folder. + for (const candidate of providers) { + if (!candidate.resolveWorkspace(folderUri)) { + continue; + } + if (options?.sessionTypeId && !candidate.getSessionTypes(folderUri).some(t => t.id === options.sessionTypeId)) { + continue; + } + provider = candidate; + break; + } + if (!provider) { + throw new Error(`No sessions provider can resolve folder '${folderUri.toString()}'`); + } } + let sessionTypeId = options?.sessionTypeId; if (!sessionTypeId) { - sessionTypeId = provider.getSessionTypes(repositoryUri)[0]?.id; + sessionTypeId = provider.getSessionTypes(folderUri)[0]?.id; if (!sessionTypeId) { - throw new Error(`No session types available for provider '${providerId}'`); + throw new Error(`No session types available for provider '${provider.id}'`); } } - const session = provider.createNewSession(repositoryUri, sessionTypeId); + + const session = provider.createNewSession(folderUri, sessionTypeId); this._pendingNewSession = session; this.setActiveSession(session); return session; diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 9dcc445e65bd99..ba89c83614de34 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -9,9 +9,38 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IChat, ISession, ISessionType } from './session.js'; +import { IChat, ISession, ISessionType, ISessionWorkspace } from './session.js'; import { ISendRequestOptions } from './sessionsProvider.js'; +/** + * A (provider, session-type) pair returned by + * {@link ISessionsManagementService.getSessionTypesForFolder} so the UI can + * group session types by provider when more than one provider can serve the + * same folder. + */ +export interface IProviderSessionType { + readonly providerId: string; + readonly sessionType: ISessionType; +} + +/** + * Options for {@link ISessionsManagementService.createNewSession}. + */ +export interface ICreateNewSessionOptions { + /** + * Force creation through a specific provider. When omitted, the service + * iterates registered providers and picks the first one whose + * {@link ISessionsProvider.resolveWorkspace} succeeds for the folder URI + * (and, when `sessionTypeId` is given, whose `getSessionTypes` includes it). + */ + readonly providerId?: string; + /** + * The session type to use. When omitted, defaults to the first type the + * chosen provider advertises for the folder URI. + */ + readonly sessionTypeId?: string; +} + export const ActiveSessionSupportsMultiChatContext = new RawContextKey('activeSessionSupportsMultiChat', false, localize('activeSessionSupportsMultiChat', "Whether the active session supports multiple chats")); /** @@ -56,6 +85,21 @@ export interface ISessionsManagementService { */ getAllSessionTypes(): ISessionType[]; + /** + * Get all session types that can serve the given workspace URI, across all + * registered providers. Returns one entry per (provider × supported type), + * so the UI can group types by provider when more than one provider can + * serve the same workspace. + */ + getSessionTypesForFolder(folderUri: URI): IProviderSessionType[]; + + /** + * Resolve a workspace URI to a workspace using the first provider whose + * {@link ISessionsProvider.resolveWorkspace} succeeds. Returns `undefined` + * when no registered provider can resolve the URI. + */ + resolveWorkspace(workspaceUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined; + /** * Fires when available session types change (providers added/removed). */ @@ -99,10 +143,16 @@ export interface ISessionsManagementService { openNewSessionView(): void; /** - * Create a new session for the given workspace. - * Delegates to the provider identified by providerId. + * Create a new session for the given folder. + * + * When `options.providerId` is omitted, iterates registered providers and + * picks the first one whose {@link ISessionsProvider.resolveWorkspace} + * succeeds for `folderUri` (and, when `options.sessionTypeId` is given, + * whose `getSessionTypes` includes it). When `options.sessionTypeId` is + * omitted, defaults to the chosen provider's first advertised type for + * the folder. */ - createNewSession(providerId: string, workspaceUri: URI, sessionTypeId?: string): ISession; + createNewSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession; /** * Unset the new session @@ -136,12 +186,16 @@ export interface ISessionsManagementService { /** Archive a session. */ archiveSession(session: ISession): Promise; + /** Unarchive a session. */ unarchiveSession(session: ISession): Promise; + /** Delete a session. */ deleteSession(session: ISession): Promise; + /** Delete a single chat from a session by its URI. */ deleteChat(session: ISession, chatUri: URI): Promise; + /** Rename a chat within a session. */ renameChat(session: ISession, chatUri: URI, title: string): Promise; } diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index e78e5cb3da698d..0f5c2f8f33f27c 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -93,23 +93,23 @@ export interface ISessionsProvider { * Resolve a workspace for the given repository URI. * Returns `undefined` when the provider cannot handle the given URI * (e.g. wrong scheme or authority). - * @param repositoryUri The URI of the repository to resolve the workspace for. + * @param workspaceUri The URI of the repository to resolve the workspace for. */ - resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; + resolveWorkspace(workspaceUri: URI): ISessionWorkspace | undefined; /** - * Create a new session for the given repository URI. + * Create a new session for the given workspace URI. * The provider should not add this session to its session list until the first request is sent. - * @param repositoryUri The URI of the repository to create the session for. + * @param workspaceUri The URI of the repository to create the session for. * @param sessionTypeId The ID of the session type to create. */ - createNewSession(repositoryUri: URI, sessionTypeId: string): ISession; + createNewSession(workspaceUri: URI, sessionTypeId: string): ISession; /** - * Get the session types supported for a given repository URI. - * @param repositoryUri The URI of the repository to get session types for. + * Get the session types supported for a given workspace URI. + * @param workspaceUri The URI of the workspace to get session types for. */ - getSessionTypes(repositoryUri: URI): ISessionType[]; + getSessionTypes(workspaceUri: URI): ISessionType[]; /** * Rename a chat within a session. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index a35ea82cacd9ef..2d73e597e5bc95 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -11,8 +11,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { Codicon } from '../../../../../base/common/codicons.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { IActiveSession, ISessionsManagementService } from '../../common/sessionsManagement.js'; -import { IChat, ISession, ISessionType, SessionStatus } from '../../common/session.js'; +import { IActiveSession, ICreateNewSessionOptions, IProviderSessionType, ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { IChat, ISession, ISessionType, ISessionWorkspace, SessionStatus } from '../../common/session.js'; import { SessionsNavigation } from '../../browser/sessionNavigation.js'; import { Event } from '../../../../../base/common/event.js'; import { ISendRequestOptions } from '../../common/sessionsProvider.js'; @@ -127,6 +127,8 @@ class MockSessionStore implements ISessionsManagementService { } getAllSessionTypes(): ISessionType[] { return []; } + getSessionTypesForFolder(_folderUri: URI): IProviderSessionType[] { return []; } + resolveWorkspace(_folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { return undefined; } async openSession(sessionResource: URI): Promise { this._openedResource = sessionResource; @@ -155,7 +157,7 @@ class MockSessionStore implements ISessionsManagementService { } } restoreLastActiveSession(): Promise { throw new Error('not implemented'); } - createNewSession(_providerId: string, _workspaceUri: URI, _sessionTypeId?: string): ISession { throw new Error('not implemented'); } + createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } unsetNewSession(): void { throw new Error('not implemented'); } sendAndCreateChat(_session: ISession, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } sendRequest(_session: ISession, _chat: IChat, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 2eedd905975efc..28f0728f7d2f17 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -47,7 +47,7 @@ import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatS import { NotebookDto } from './mainThreadNotebookDto.js'; import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; -import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -767,6 +767,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA uri: URI.revive(item.uri), type: item.type, name: item.name, + source: item.source, description: item.description, groupKey: item.groupKey, badge: item.badge, @@ -809,7 +810,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA getStorageSourceFilter: () => ({ // Extension-provided harnesses manage their own items via the provider, // so we show all sources for storage-filter-based flows. - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension, BUILTIN_STORAGE], + sources: AICustomizationSources.all }), itemProvider, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 83c858754ee3e0..420927c0a32ebb 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1724,7 +1724,7 @@ export interface ExtHostChatAgentsShape2 { $onDidChangePlugins(): void; } -export type IChatResourceSourceDto = 'local' | 'user' | 'extension' | 'plugin'; +export type IChatResourceSourceDto = 'local' | 'user' | 'extension' | 'plugin' | 'builtin'; export interface IChatResourceDto { readonly uri: UriComponents; @@ -1781,6 +1781,7 @@ export interface IChatSessionCustomizationItemDto { readonly uri: UriComponents; readonly type: string; readonly name: string; + readonly source: IChatResourceSourceDto; readonly description?: string; readonly groupKey?: string; readonly badge?: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 98035103bdc587..cd22ac00906b7b 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -845,6 +845,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS type: typeConvert.ChatSessionCustomizationType.from(item.type), name: item.name, description: item.description, + source: item.source, groupKey: item.groupKey, badge: item.badge, badgeTooltip: item.badgeTooltip, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index df39c66c8cba08..2bc3313b6ddcda 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -226,7 +226,7 @@ class OpenBrowserSettingsAction extends Action2 { constructor() { super({ id: OpenBrowserSettingsAction.ID, - title: localize2('browser.openSettingsAction', 'Open Browser Settings'), + title: localize2('browser.openSettingsAction', 'Browser Settings'), category: BrowserActionCategory, icon: Codicon.settingsGear, f1: false, diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index babb7cc2495f56..16df0501617912 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -109,8 +109,16 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } getPluginInstallUri(plugin: IMarketplacePlugin): URI { + if (plugin.sourceDescriptor.kind !== PluginSourceKind.RelativePath) { + return this.getPluginSourceInstallUri(plugin.sourceDescriptor); + } const repoDir = this.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); - return this._getPluginDir(repoDir, plugin.source); + const normalizedSource = plugin.source.trim().replace(/^\.?\/+|\/+$/g, ''); + const pluginDir = normalizedSource ? joinPath(repoDir, normalizedSource) : repoDir; + if (!isEqualOrParent(pluginDir, repoDir)) { + throw new Error(`Invalid plugin source path '${plugin.source}'`); + } + return pluginDir; } async ensureRepository(marketplace: IMarketplaceReference, options?: IEnsureRepositoryOptions): Promise { @@ -270,15 +278,6 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } } - private _getPluginDir(repoDir: URI, source: string): URI { - const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); - const pluginDir = normalizedSource ? joinPath(repoDir, normalizedSource) : repoDir; - if (!isEqualOrParent(pluginDir, repoDir)) { - throw new Error(`Invalid plugin source path '${source}'`); - } - return pluginDir; - } - getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI { return this.getPluginSource(sourceDescriptor.kind).getInstallUri(this._cacheRoot, sourceDescriptor); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index f9aec3f9b12f2c..b695d3a6994261 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -10,10 +10,9 @@ import { ResourceMap } from '../../../../../../base/common/map.js'; import { extname } from '../../../../../../base/common/path.js'; import { joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { CustomizationStatus, type SessionCustomization, type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationStatus, StateComponents, type SessionCustomization, type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; -import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -23,6 +22,7 @@ import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agen import { SKILL_FILENAME } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptFileParser } from '../../../common/promptSyntax/promptFileParser.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; +import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; const REMOTE_HOST_GROUP = 'remote-host'; @@ -34,7 +34,6 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto readonly onDidChange: Event = this._onDidChange.event; private _agentCustomizations: readonly CustomizationRef[]; - private readonly _sessionCustomizationsCache = new Map(); /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; children: readonly ICustomizationItem[] }>(); @@ -60,8 +59,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto })); this._register(this._connection.onDidAction(envelope => { - if (envelope.action.type === ActionType.SessionCustomizationsChanged) { - this._sessionCustomizationsCache.set(envelope.channel, envelope.action.customizations); + if (envelope.action.type === ActionType.SessionCustomizationsChanged || envelope.action.type === ActionType.SessionCustomizationUpdated) { this._onDidChange.fire(); } })); @@ -118,7 +116,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto type: 'plugin', name: customization.displayName, description: customization.description, - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, status: toStatusString(sessionCustomization?.status), statusMessage: sessionCustomization?.statusMessage, enabled: sessionCustomization?.enabled ?? true, @@ -149,7 +147,8 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto plugins.push({ item, nonce: customization.nonce, status: undefined, statusMessage: undefined, enabled: undefined, childGroupKey: REMOTE_HOST_GROUP, isBundleItem: false }); } const sessionUri = this._resolveSessionUri(sessionResource); - const sessionCustomizations = this._sessionCustomizationsCache.get(sessionUri.toString()) ?? []; + const sessionState = this._connection.getSubscriptionUnmanaged(StateComponents.Session, sessionUri)?.value; + const sessionCustomizations = sessionState && !(sessionState instanceof Error) ? sessionState.customizations ?? [] : []; for (const sessionCustomization of sessionCustomizations) { const isBundleItem = isSyntheticBundle(sessionCustomization.customization); const isClientSynced = sessionCustomization.clientId !== undefined; @@ -166,7 +165,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto items.set(customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId), item); } else { // create a dummy parent item for the synthetic bundle, it does not go into the items map, just need it to expand. - item = { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', storage: PromptsStorage.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined }; + item = { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', name: '', source: AICustomizationSources.plugin, groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined }; } // Always expand plugin contents so individual files are visible. @@ -324,7 +323,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto type: promptType, name: displayName, description, - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, groupKey, extensionId: undefined, pluginUri: isBundleItem ? undefined : pluginUri, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 16ec66b3ac622d..679e5bd17d69be 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -6,6 +6,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Event } from '../../../../../../base/common/event.js'; +import { equals } from '../../../../../../base/common/objects.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; @@ -26,7 +27,7 @@ import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js'; import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js'; @@ -36,6 +37,7 @@ import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { AgentHostSessionListController } from './agentHostSessionListController.js'; import { LoggingAgentConnection } from './loggingAgentConnection.js'; import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; +import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; export { AgentHostSessionHandler } from './agentHostSessionHandler.js'; export { AgentHostSessionListController } from './agentHostSessionListController.js'; @@ -204,7 +206,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr icon: ThemeIcon.fromId(Codicon.server.id), hiddenSections: [], hideGenerateButton: true, - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin] }), + getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), syncProvider, itemProvider, })); @@ -212,6 +214,9 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const customizations = observableValue('agentCustomizations', []); const updateCustomizations = async () => { const refs = await resolveCustomizationRefs(this._promptsService, syncProvider, this._agentPluginService, bundler, sessionType); + if (equals(customizations.get(), refs)) { + return; + } customizations.set(refs, undefined); }; store.add(syncProvider.onDidChange(() => updateCustomizations())); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index a1f2a142f2af80..37e79da2f7b016 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -8,7 +8,7 @@ import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSource, AICustomizationSources, BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptPath, IPromptsService, matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { type ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; @@ -41,7 +41,7 @@ export const SYNCABLE_STORAGE_SOURCES: readonly PromptsStorage[] = [ export interface ILocalCustomizationFile { readonly uri: URI; readonly type: PromptsType; - readonly storage: AICustomizationPromptsStorage; + readonly source: AICustomizationSource; readonly disabled: boolean; readonly pluginUri?: URI; readonly extensionId?: string; @@ -73,13 +73,13 @@ export async function enumerateLocalCustomizationsForHarness( SYNCABLE_STORAGE_SOURCES.map(storage => promptsService.listPromptFilesForStorage(type, storage, token)), ); for (let i = 0; i < lists.length; i++) { - const storage = SYNCABLE_STORAGE_SOURCES[i]; + const source = SYNCABLE_STORAGE_SOURCES[i]; for (const file of lists[i]) { if (matchesSessionType(file.sessionTypes, sessionType)) { result.push({ uri: file.uri, type, - storage, + source, pluginUri: file.pluginUri, extensionId: file.extension?.identifier.value, disabled: syncProvider.isDisabled(file.uri), @@ -110,7 +110,7 @@ export async function enumerateLocalCustomizationsForHarness( result.push({ uri: file.uri, type: PromptsType.skill, - storage: BUILTIN_STORAGE, + source: BUILTIN_STORAGE, disabled: syncProvider.isDisabled(file.uri), }); } @@ -145,7 +145,7 @@ export async function resolveCustomizationRefs( const looseFiles: { uri: URI; type: PromptsType }[] = []; for (const entry of enabled) { - if (entry.storage === PromptsStorage.plugin) { + if (entry.source === AICustomizationSources.plugin) { const plugin = plugins.find(p => isEqualOrParent(entry.uri, p.uri)); if (!plugin) { continue; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 2c74654a7bba7e..0204dd4740bb76 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -23,7 +23,7 @@ import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -479,17 +479,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }, )); - // When the customizations observable changes, re-dispatch - // activeClientChanged for sessions where this client is already - // the active client. This avoids overwriting another client's - // active status on sessions we're only observing. + // When this client's exposed customization list changes, update sessions + // where this client is already active without reclaiming observed sessions. if (config.customizations) { this._register(autorun(reader => { const refs = config.customizations!.read(reader); for (const [sessionResource] of this._activeSessions) { const backendSession = this._resolveSessionUri(sessionResource); const state = this._getSessionState(backendSession.toString()); - if (state?.activeClient?.clientId === this._config.connection.clientId) { + if (state?.activeClient?.clientId === this._config.connection.clientId && !equals(state.activeClient.customizations ?? [], refs)) { this._dispatchActiveClient(backendSession, refs); } } @@ -655,10 +653,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } catch (err) { this._logService.warn(`[AgentHost] Failed to subscribe to existing session: ${resolvedSession.toString()}`, err); } - - // Claim the active client role with current customizations - const customizations = this._config.customizations?.get() ?? []; - this._dispatchActiveClient(resolvedSession, customizations); } const session = this._instantiationService.createInstance( AgentHostChatSession, @@ -813,12 +807,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } - // Claim the active-client role and publish current tools + - // customizations. The eager `connection.createSession` from the - // sessions provider couldn't include them because the handler - // owns them; this dispatch is the equivalent of the - // `activeClient` parameter on the legacy createSession call. - this._dispatchActiveClient(resolvedSession, this._config.customizations?.get() ?? []); + this._ensureActiveClientForMessage(resolvedSession); } const completedTurn = await this._handleTurn(resolvedSession, request, progress, cancellationToken); @@ -941,6 +930,23 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.dispatch(channel.toString(), action); } + private _getCurrentActiveClient(customizations: CustomizationRef[] = this._config.customizations?.get() ?? []): SessionActiveClient { + return { + clientId: this._config.connection.clientId, + tools: this._clientToolsObs.get().map(toolDataToDefinition), + customizations, + }; + } + + private _ensureActiveClientForMessage(backendSession: URI): void { + const state = this._getSessionState(backendSession.toString()); + const activeClient = this._getCurrentActiveClient(); + if (equals(state?.activeClient, activeClient)) { + return; + } + this._dispatchActiveClient(backendSession, activeClient.customizations ?? []); + } + /** * Dispatches `session/activeClientChanged` to claim the active client * role for this session and publish the current customizations and @@ -949,11 +955,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _dispatchActiveClient(backendSession: URI, customizations: CustomizationRef[]): void { this._dispatchAction(backendSession, { type: ActionType.SessionActiveClientChanged, - activeClient: { - clientId: this._config.connection.clientId, - tools: this._clientToolsObs.get().map(toolDataToDefinition), - customizations, - }, + activeClient: this._getCurrentActiveClient(customizations), }); } @@ -1788,13 +1790,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const questions: IChatQuestion[] = (inputReq.questions ?? []).map((q): IChatQuestion => { + let title = q.title; + let message = q.message; + if (!title) { + const EOL = q.message.indexOf('\n'); + title = EOL === -1 ? q.message : q.message.substring(0, EOL).trim(); + message = EOL === -1 ? '' : q.message.substring(EOL + 1).trim(); + } + const detailedMessage = new MarkdownString(message, { isTrusted: false }); + switch (q.kind) { case SessionInputQuestionKind.SingleSelect: return { id: q.id, type: 'singleSelect', - title: q.title ?? q.message, - description: q.title !== undefined ? q.message : undefined, + title, + detailedMessage, required: q.required, allowFreeformInput: q.allowFreeformInput ?? true, options: q.options.map(o => ({ id: o.id, label: o.label, value: o.id })), @@ -1803,8 +1814,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return { id: q.id, type: 'multiSelect', - title: q.title ?? q.message, - description: q.title !== undefined ? q.message : undefined, + title, + detailedMessage, required: q.required, allowFreeformInput: q.allowFreeformInput ?? true, options: q.options.map(o => ({ id: o.id, label: o.label, value: o.id })), @@ -1813,8 +1824,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return { id: q.id, type: 'text', - title: q.title ?? q.message, - description: q.title !== undefined ? q.message : undefined, + title, + detailedMessage, required: q.required, defaultValue: q.defaultValue, }; @@ -1822,8 +1833,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return { id: q.id, type: 'text', - title: q.title ?? q.message, - description: q.title !== undefined ? q.message : undefined, + title, + detailedMessage, required: q.required, }; } @@ -1930,6 +1941,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC })); } + /** * Handle a URL-style {@link SessionInputRequest} by rendering a * {@link ChatElicitationRequestPart} that prompts the user to open the diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 7c0881f3c372bd..8aba67a2132478 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -7,8 +7,8 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; -import { type AICustomizationPromptsStorage, AICustomizationManagementSection, BUILTIN_STORAGE, sectionToPromptType } from './aiCustomizationManagement.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter, AICustomizationSources, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { type AICustomizationSource, AICustomizationManagementSection, sectionToPromptType } from './aiCustomizationManagement.js'; import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; @@ -16,7 +16,7 @@ import { IAgentPluginService } from '../../common/plugins/agentPluginService.js' * Snapshot of the list widget's internal state, passed in to avoid coupling. */ export interface IDebugWidgetState { - readonly allItems: readonly { readonly name?: string; readonly storage?: AICustomizationPromptsStorage; readonly groupKey?: string; readonly syncable?: boolean; readonly pluginUri?: URI }[]; + readonly allItems: readonly { readonly name?: string; readonly source?: AICustomizationSource; readonly groupKey?: string; readonly syncable?: boolean; readonly pluginUri?: URI }[]; readonly displayEntries: readonly { type: string; label?: string; count?: number; collapsed?: boolean }[]; } @@ -154,9 +154,7 @@ async function appendProviderData(lines: string[], provider: ICustomizationItemP if (item.description) { lines.push(` desc: ${item.description}`); } - if (item.storage) { - lines.push(` storage: ${item.storage}`); - } + lines.push(` source: ${item.source}`); if (item.groupKey) { lines.push(` groupKey: ${item.groupKey}`); } @@ -263,18 +261,18 @@ async function appendFilteredData(lines: string[], promptsService: IPromptsServi function appendWidgetState(lines: string[], state: IDebugWidgetState): void { lines.push('--- Stage 3: Widget State (loadItems → filterItems) ---'); lines.push(` allItems (after loadItems): ${state.allItems.length}`); - lines.push(` local: ${state.allItems.filter(i => i.storage === PromptsStorage.local).length}`); - lines.push(` user: ${state.allItems.filter(i => i.storage === PromptsStorage.user).length}`); - lines.push(` extension: ${state.allItems.filter(i => i.storage === PromptsStorage.extension).length}`); - lines.push(` plugin: ${state.allItems.filter(i => i.storage === PromptsStorage.plugin).length}`); - lines.push(` built-in: ${state.allItems.filter(i => i.storage === BUILTIN_STORAGE).length}`); + lines.push(` local: ${state.allItems.filter(i => i.source === AICustomizationSources.local).length}`); + lines.push(` user: ${state.allItems.filter(i => i.source === AICustomizationSources.user).length}`); + lines.push(` extension: ${state.allItems.filter(i => i.source === AICustomizationSources.extension).length}`); + lines.push(` plugin: ${state.allItems.filter(i => i.source === AICustomizationSources.plugin).length}`); + lines.push(` built-in: ${state.allItems.filter(i => i.source === AICustomizationSources.builtin).length}`); const syncableCount = state.allItems.filter(i => i.syncable).length; if (syncableCount > 0) { lines.push(` syncable: ${syncableCount}`); } for (const item of state.allItems) { - const flags: string[] = [`storage=${item.storage ?? '?'}`, `groupKey=${item.groupKey ?? '(none)'}`]; + const flags: string[] = [`storage=${item.source ?? '?'}`, `groupKey=${item.groupKey ?? '(none)'}`]; if (item.syncable) { flags.push('syncable'); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index 908e39ae743e60..1dbc84a0b61923 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -7,8 +7,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; -import { type AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { type AICustomizationSource, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; /** * Icon for the AI Customization view container (sidebar). @@ -83,13 +82,13 @@ export const mcpServerIcon = registerIcon('ai-customization-mcp-server', Codicon /** * Returns the icon for a given storage type. */ -export function storageToIcon(storage: AICustomizationPromptsStorage): ThemeIcon { - switch (storage) { - case PromptsStorage.local: return workspaceIcon; - case PromptsStorage.user: return userIcon; - case PromptsStorage.extension: return extensionIcon; - case PromptsStorage.plugin: return pluginIcon; - case BUILTIN_STORAGE: return builtinIcon; +export function sourceToIcon(source: AICustomizationSource): ThemeIcon { + switch (source) { + case AICustomizationSources.local: return workspaceIcon; + case AICustomizationSources.user: return userIcon; + case AICustomizationSources.extension: return extensionIcon; + case AICustomizationSources.plugin: return pluginIcon; + case AICustomizationSources.builtin: return builtinIcon; default: return instructionsIcon; } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 88fd52b7850068..cedb0f31ead0df 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -11,7 +11,7 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; @@ -19,19 +19,16 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { IFileService } from '../../../../../platform/files/common/files.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; -import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { storageToIcon } from './aiCustomizationIcons.js'; -import { type AICustomizationPromptsStorage, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; -import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js'; +import { sourceToIcon } from './aiCustomizationIcons.js'; +import { type AICustomizationSource, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; // #region Interfaces @@ -44,11 +41,11 @@ export interface IAICustomizationListItem { readonly name: string; readonly filename: string; readonly description?: string; - /** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */ - readonly storage?: AICustomizationPromptsStorage; + /** Storage or provider origin. All items, including those from external providers, must provide a source. */ + readonly source: AICustomizationSource; readonly promptType: PromptsType; readonly disabled: boolean; - /** When set, overrides `storage` for display grouping purposes. */ + /** When set, overrides `source` for display grouping purposes. */ readonly groupKey?: string; /** URI of the parent plugin, when this item comes from an installed plugin. */ readonly pluginUri?: URI; @@ -157,7 +154,7 @@ export async function expandHookFileItems( description: truncatedCmd || localize('hookUnset', "(unset)"), enabled: item.enabled, groupKey: item.groupKey, - storage: item.storage, + source: item.source, extensionId: item.extensionId, pluginUri: item.pluginUri, userInvocable: item.userInvocable, @@ -186,10 +183,7 @@ export async function expandHookFileItems( */ export class AICustomizationItemNormalizer { constructor( - private readonly workspaceContextService: IWorkspaceContextService, - private readonly workspaceService: IAICustomizationWorkspaceService, private readonly labelService: ILabelService, - private readonly agentPluginService: IAgentPluginService, private readonly productService: IProductService, ) { } @@ -202,11 +196,11 @@ export class AICustomizationItemNormalizer { } normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { - const { storage, groupKey, isBuiltin, extensionId, pluginUri } = this.inferStorageAndGroup(item); + const { source, groupKey, isBuiltin, extensionId, pluginUri } = this.inferStorageAndGroup(item); const seenCount = uriUseCounts.get(item.uri) ?? 0; uriUseCounts.set(item.uri, seenCount + 1); const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`; - const isWorkspaceItem = storage === PromptsStorage.local; + const isWorkspaceItem = source === AICustomizationSources.local; return { id: `${item.uri.toString()}${duplicateSuffix}`, @@ -216,7 +210,7 @@ export class AICustomizationItemNormalizer { ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }) : basename(item.uri), description: item.description, - storage, + source, promptType, disabled: item.enabled === false, groupKey, @@ -224,7 +218,7 @@ export class AICustomizationItemNormalizer { displayName: item.name, badge: item.badge, badgeTooltip: item.badgeTooltip, - typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined, + typeIcon: promptType === PromptsType.instructions && source ? sourceToIcon(source) : undefined, isBuiltin, extensionId, status: item.status, @@ -232,61 +226,28 @@ export class AICustomizationItemNormalizer { }; } - private inferStorageAndGroup(item: ICustomizationItem): { storage: AICustomizationPromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionId?: string; pluginUri?: URI } { + private inferStorageAndGroup(item: ICustomizationItem): { source: AICustomizationSource; groupKey?: string; isBuiltin?: boolean; extensionId?: string; pluginUri?: URI } { const groupKey = item.groupKey; - const hasBuiltinStorage = item.storage === BUILTIN_STORAGE; + const hasBuiltinStorage = item.source === AICustomizationSources.builtin; const isBuiltin = groupKey === BUILTIN_STORAGE || hasBuiltinStorage; if (hasBuiltinStorage) { - return { storage: BUILTIN_STORAGE, groupKey: groupKey ?? BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId }; - } - if (item.storage === PromptsStorage.plugin) { - return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin }; - } - if (item.extensionId) { - const extensionIdentifier = new ExtensionIdentifier(item.extensionId); - if (isChatExtensionItem(extensionIdentifier, this.productService)) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId }; - } - return { storage: PromptsStorage.extension, extensionId: item.extensionId, groupKey, isBuiltin }; - } - if (item.pluginUri) { - return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin }; - } - if (item.storage) { - return { storage: item.storage, groupKey, isBuiltin }; + return { source: AICustomizationSources.builtin, groupKey: groupKey ?? BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId }; } - - const uri = item.uri; - - const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); - if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { - return { storage: PromptsStorage.local, groupKey, isBuiltin }; - } - - for (const folder of this.workspaceContextService.getWorkspace().folders) { - if (isEqualOrParent(uri, folder.uri)) { - return { storage: PromptsStorage.local, groupKey, isBuiltin }; - } + if (item.source === AICustomizationSources.plugin) { + return { source: AICustomizationSources.plugin, pluginUri: item.pluginUri, groupKey, isBuiltin }; } - - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(uri, plugin.uri)) { - return { storage: PromptsStorage.plugin, pluginUri: plugin.uri, groupKey, isBuiltin }; - } - } - - const extensionId = extractExtensionIdFromPath(uri.path); - if (extensionId) { - const extensionIdentifier = new ExtensionIdentifier(extensionId); - if (isChatExtensionItem(extensionIdentifier, this.productService)) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId }; + if (item.source === AICustomizationSources.extension) { + if (item.extensionId) { + const extensionIdentifier = new ExtensionIdentifier(item.extensionId); + if (isChatExtensionItem(extensionIdentifier, this.productService)) { + return { source: AICustomizationSources.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionId: item.extensionId }; + } } - return { storage: PromptsStorage.extension, extensionId, groupKey, isBuiltin }; + return { source: AICustomizationSources.extension, extensionId: item.extensionId, groupKey, isBuiltin }; } - return { storage: PromptsStorage.user, groupKey, isBuiltin }; + return { source: item.source, groupKey, isBuiltin, pluginUri: item.pluginUri, extensionId: item.extensionId }; } - } // #endregion @@ -336,8 +297,8 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati if (promptType === PromptsType.hook) { const hookItems = allItems.filter(item => item.type === PromptsType.hook); // Plugin hooks are pre-expanded by plugin manifests — skip re-expansion. - const toExpand = hookItems.filter(item => item.storage !== PromptsStorage.plugin); - const preExpanded = hookItems.filter(item => item.storage === PromptsStorage.plugin); + const toExpand = hookItems.filter(item => item.source !== AICustomizationSources.plugin); + const preExpanded = hookItems.filter(item => item.source === AICustomizationSources.plugin); const expanded = await expandHookFileItems( toExpand, this.workspaceService, this.fileService, this.pathService, ); @@ -395,7 +356,7 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati // copy once the user has added an override at either level. const overriddenNames = new Set(); for (const item of deduped) { - if (item.storage === PromptsStorage.local || item.storage === PromptsStorage.user) { + if (item.source === AICustomizationSources.local || item.source === AICustomizationSources.user) { if (item.name) { overriddenNames.add(item.name); } @@ -422,7 +383,7 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati type: PromptsType.skill, name, description: p.description, - storage: BUILTIN_STORAGE as unknown as PromptsStorage, + source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, enabled: !disabledPromptFiles.has(p.uri), badge: uiTooltip ? uiIntegrationBadge : undefined, @@ -449,76 +410,6 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati return items.map(item => item.description ? item : { ...item, description: descriptionsByUri.get(item.uri.toString()) }); } } -/** - * Item source backed directly by promptsService enumeration and optional sync state. - */ -export class PromptServiceItemSource implements IAICustomizationItemSource { - - readonly onDidChange: Event; - constructor( - readonly sessionResource: URI, - readonly syncProvider: ICustomizationSyncProvider | undefined, - private readonly promptsService: IPromptsService, - private readonly itemNormalizer: AICustomizationItemNormalizer, - ) { - this.onDidChange = Event.any( - this.syncProvider?.onDidChange ?? Event.None, - this.promptsService.onDidChangeCustomAgents, - this.promptsService.onDidChangeSlashCommands, - this.promptsService.onDidChangeSkills, - this.promptsService.onDidChangeHooks, - this.promptsService.onDidChangeInstructions, - ); - } - - dispose(): void { - } - - async fetchItems(promptType: PromptsType): Promise { - const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); - if (!files.length) { - return []; - } - - const disabledPromptFiles = this.promptsService.getDisabledPromptFiles(promptType); - - const toProviderItem = (file: typeof files[number]): ICustomizationItem => ({ - uri: file.uri, - type: promptType, - name: file.name ?? getFriendlyName(basename(file.uri)), - description: file.description, - storage: file.storage, - enabled: !disabledPromptFiles.has(file.uri), - extensionId: file.extension?.id, - pluginUri: file.pluginUri, - userInvocable: undefined - }); - - // Local/user files are sync-eligible (the user picks individual items - // to push to the remote agent host). Locally-installed plugin files - // always show up but are not individually syncable — the plugin is - // the unit of sync. - const syncEligibleFiles = files.filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user); - const pluginFiles = files.filter(file => file.storage === PromptsStorage.plugin); - - const syncEligibleItems = this.itemNormalizer.normalizeItems(syncEligibleFiles.map(toProviderItem), promptType) - .map(item => { - if (!this.syncProvider) { - return item; - } - - return { - ...item, - id: `sync-${item.id}`, - syncable: true, - synced: !this.syncProvider.isDisabled(item.uri), - }; - }); - const pluginItems = this.itemNormalizer.normalizeItems(pluginFiles.map(toProviderItem), promptType); - - return [...syncEligibleItems, ...pluginItems]; - } -} // #endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index a0a13ff554d384..785555866a549a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -21,7 +21,7 @@ import { ICustomizationHarnessService, ICustomizationItemProvider, isPluginCusto import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; -import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ItemProviderItemSource, PromptServiceItemSource } from './aiCustomizationItemSource.js'; +import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ItemProviderItemSource } from './aiCustomizationItemSource.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; import { URI } from '../../../../../base/common/uri.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -161,13 +161,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz ) { super(); - this.itemNormalizer = new AICustomizationItemNormalizer( - workspaceContextService, - workspaceService, - labelService, - this.agentPluginService, - productService, - ); + this.itemNormalizer = new AICustomizationItemNormalizer(labelService, productService); this.promptsServiceItemProvider = new PromptsServiceCustomizationItemProvider( () => this.harnessService.getActiveDescriptor(), this.promptsService, @@ -253,22 +247,17 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz return this.sourceCache.value; } const descriptor = this.harnessService.findHarnessById(getChatSessionType(sessionResource)); - const source = descriptor?.itemProvider - ? new ItemProviderItemSource( - sessionResource, - descriptor.itemProvider, - this.promptsService, - this.workspaceService, - this.fileService, - this.pathService, - this.itemNormalizer, - ) - : new PromptServiceItemSource( - sessionResource, - descriptor?.syncProvider, - this.promptsService, - this.itemNormalizer, - ); + const itemProvider = descriptor?.itemProvider ?? this.promptsServiceItemProvider; + + const source = new ItemProviderItemSource( + sessionResource, + itemProvider, + this.promptsService, + this.workspaceService, + this.fileService, + this.pathService, + this.itemNormalizer, + ); this.sourceCache.value = source; return source; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index fa698e9dec90dc..dc287430a33a7d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -35,7 +35,7 @@ import { IMenuService, MenuItemAction } from '../../../../../platform/actions/co import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -325,7 +325,7 @@ class AICustomizationItemRenderer implements IListRenderer g.groupKey === key); if (!group) { // Dynamically create a group for unknown groupKeys from providers @@ -1600,7 +1600,7 @@ export class AICustomizationListWidget extends Disposable { this.currentSection, this.promptsService, this.workspaceService, - { allItems: this.allItems as IAICustomizationListItem[], displayEntries: this.displayEntries }, + { allItems: this.allItems, displayEntries: this.displayEntries }, this.itemsModel.getPromptsServiceItemProvider(), this.harnessService, this.agentPluginService, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 65e918a0628903..9fe6c38cc06bb2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -34,7 +34,7 @@ import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../services/agentHost/comm import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IWorkbenchExtensionManagementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; @@ -53,7 +53,7 @@ import { AICustomizationManagementCommands, AICustomizationManagementItemMenuId, AICustomizationManagementSection, - BUILTIN_STORAGE, + AICustomizationSource, } from './aiCustomizationManagement.js'; import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; @@ -147,7 +147,7 @@ function extractURI(context: AICustomizationContext): URI { /** * Extracts storage type from context. */ -function extractStorage(context: AICustomizationContext): PromptsStorage | undefined { +function extractSource(context: AICustomizationContext): AICustomizationSource | undefined { if (URI.isUri(context) || typeof context === 'string') { return undefined; } @@ -219,14 +219,14 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { const editorService = accessor.get(IEditorService); - const storage = extractStorage(context); + const source = extractSource(context); const editorPane = await editorService.openEditor({ resource: extractURI(context) }); const codeEditor = getCodeEditor(editorPane?.getControl()); - if (codeEditor && (storage === PromptsStorage.extension || storage === PromptsStorage.plugin)) { + if (codeEditor && (source === AICustomizationSources.extension || source === AICustomizationSources.plugin)) { codeEditor.updateOptions({ readOnly: true, readOnlyMessage: new MarkdownString(localize('readonlyPluginFile', "This file is provided by a plugin or extension and cannot be edited.")), @@ -294,7 +294,7 @@ registerAction2(class extends Action2 { const editorService = accessor.get(IEditorService); const uri = extractURI(context); - const storage = extractStorage(context); + const source = extractSource(context); const promptType = extractPromptType(context); const itemId = extractItemId(context); const isSkill = promptType === PromptsType.skill; @@ -303,7 +303,7 @@ registerAction2(class extends Action2 { const fileName = isSkill ? basename(dirname(uri)) : basename(uri); // Plugin-provided files: offer to uninstall the plugin - if (storage === PromptsStorage.plugin) { + if (source === AICustomizationSources.plugin) { const agentPluginService = accessor.get(IAgentPluginService); const plugin = agentPluginService.plugins.get().find(p => isEqualOrParent(uri, p.uri)); if (plugin) { @@ -321,7 +321,7 @@ registerAction2(class extends Action2 { } // Extension and built-in files cannot be deleted - if (storage === PromptsStorage.extension || storage === BUILTIN_STORAGE) { + if (source === AICustomizationSources.extension || source === AICustomizationSources.builtin) { await dialogService.info( localize('cannotDeleteExtension', "Cannot Delete Extension File"), localize('cannotDeleteExtensionDetail', "Files provided by extensions cannot be deleted. You can disable the extension if you no longer want to use this customization.") @@ -348,7 +348,7 @@ registerAction2(class extends Action2 { try { telemetryService.publicLog2('chatCustomizationEditor.deleteItem', { promptType: promptType ?? '', - storage: storage ?? '', + storage: source ?? '', }); } catch { // Telemetry must not block deletion @@ -364,7 +364,7 @@ registerAction2(class extends Action2 { if (edits.length > 0) { const updated = applyEdits(text, edits); await fileService.writeFile(uri, VSBuffer.fromString(updated)); - if (storage === PromptsStorage.local) { + if (source === AICustomizationSources.local) { const projectRoot = workspaceService.getActiveProjectRoot(); if (projectRoot) { await workspaceService.commitFiles(projectRoot, [uri]); @@ -387,7 +387,7 @@ registerAction2(class extends Action2 { await fileService.del(deleteTarget, { useTrash, recursive: isSkill }); // Commit the deletion to git (sessions: main repo + worktree) - if (storage === PromptsStorage.local) { + if (source === AICustomizationSources.local) { const projectRoot = workspaceService.getActiveProjectRoot(); if (projectRoot) { await workspaceService.deleteFiles(projectRoot, [deleteTarget]); @@ -443,9 +443,9 @@ registerAction2(class extends Action2 { * When clause that hides an action for read-only (extension, plugin, built-in) items. */ const WHEN_ITEM_IS_DELETABLE = ContextKeyExpr.and( - ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.extension), - ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.plugin), - ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.extension), + ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.plugin), + ContextKeyExpr.notEquals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), ); /** @@ -457,7 +457,7 @@ const WHEN_ITEM_IS_DELETABLE = ContextKeyExpr.and( * plugin-related actions ("Show Plugin", "Uninstall Plugin") for them. */ const WHEN_ITEM_IS_PLUGIN = ContextKeyExpr.and( - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, PromptsStorage.plugin), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.plugin), ContextKeyExpr.regex(AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, new RegExp(`^${SYNCED_CUSTOMIZATION_SCHEME}:`)).negate(), ); @@ -668,7 +668,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { order: 1, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), ), }); @@ -680,7 +680,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { order: 1, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), ), }); @@ -692,7 +692,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { order: 5, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), ), }); @@ -704,7 +704,7 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { order: 5, when: ContextKeyExpr.and( ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), - ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AICustomizationSources.builtin), ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), ), }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index cea174c6d5835d..2adb6d9c029ea0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -11,7 +11,7 @@ import { MenuId } from '../../../../../platform/actions/common/actions.js'; // Re-export for convenience — consumers import from this file export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; -export type { AICustomizationPromptsStorage } from '../../common/aiCustomizationWorkspaceService.js'; +export type { AICustomizationSource } from '../../common/aiCustomizationWorkspaceService.js'; export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; export function sectionToPromptType(section: AICustomizationManagementSection): PromptsType { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index a04198d2649c7c..dd66847dac9827 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -46,8 +46,7 @@ import { AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, AICustomizationManagementSection, - AICustomizationPromptsStorage, - BUILTIN_STORAGE, + AICustomizationSource, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_HARNESS, @@ -67,7 +66,7 @@ import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js'; @@ -305,7 +304,7 @@ export class AICustomizationManagementEditor extends EditorPane { private readonly builtinEditingSessions = new Map(); private currentEditingUri: URI | undefined; private currentEditingProjectRoot: URI | undefined; - private currentEditingStorage: AICustomizationPromptsStorage | undefined; + private currentEditingSource: AICustomizationSource | undefined; private currentEditingPromptType: PromptsType | undefined; private currentEditingReadOnly = false; private currentModelRef: IReference | undefined; @@ -813,12 +812,12 @@ export class AICustomizationManagementEditor extends EditorPane { this.telemetryService.publicLog2('chatCustomizationEditor.itemSelected', { section: this.selectedSection ?? 'welcome', promptType: item.promptType, - storage: item.storage ?? 'external', + storage: item.source ?? 'external', }); - const storage = item.storage; - const isWorkspaceFile = storage === PromptsStorage.local; - const isReadOnly = !storage || storage === PromptsStorage.extension || storage === PromptsStorage.plugin || storage === BUILTIN_STORAGE; - this.showEmbeddedEditor(item.uri, item.name, item.promptType, storage ?? BUILTIN_STORAGE, isWorkspaceFile, isReadOnly); + const source = item.source; + const isWorkspaceFile = source === AICustomizationSources.local; + const isReadOnly = !source || source === AICustomizationSources.extension || source === AICustomizationSources.plugin || source === AICustomizationSources.builtin; + this.showEmbeddedEditor(item.uri, item.name, item.promptType, source ?? AICustomizationSources.builtin, isWorkspaceFile, isReadOnly); })); // Handle create actions - AI-guided creation @@ -1550,7 +1549,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateEditorDisplayMode(); } - private async showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, storage: AICustomizationPromptsStorage, isWorkspaceFile = false, isReadOnly = false): Promise { + private async showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, source: AICustomizationSource, isWorkspaceFile = false, isReadOnly = false): Promise { this.currentModelRef?.dispose(); this.currentModelRef = undefined; this.editorModelChangeDisposables.clear(); @@ -1558,7 +1557,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorPreviewRenderScheduler.cancel(); this.currentEditingUri = uri; this.currentEditingProjectRoot = isWorkspaceFile ? this.workspaceService.getActiveProjectRoot() : undefined; - this.currentEditingStorage = storage; + this.currentEditingSource = source; this.currentEditingPromptType = promptType; this.currentEditingReadOnly = isReadOnly; this.editorDisplayMode = this.isStructuredPreviewSupported(promptType) ? 'preview' : 'raw'; @@ -1573,7 +1572,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateContentVisibility(); try { - if (storage === BUILTIN_STORAGE && (promptType === PromptsType.prompt || promptType === PromptsType.skill)) { + if (source === AICustomizationSources.builtin && (promptType === PromptsType.prompt || promptType === PromptsType.skill)) { const session = await this.getOrCreateBuiltinEditingSession(uri); if (!isEqual(this.currentEditingUri, uri)) { @@ -1654,11 +1653,11 @@ export class AICustomizationManagementEditor extends EditorPane { if (backgroundSaveRequest) { this.telemetryService.publicLog2('chatCustomizationEditor.saveItem', { promptType: this.currentEditingPromptType ?? '', - storage: String(this.currentEditingStorage ?? ''), + storage: String(this.currentEditingSource ?? ''), saveTarget: 'existing', }); } - if (fileUri && this.currentEditingStorage === BUILTIN_STORAGE) { + if (fileUri && this.currentEditingSource === AICustomizationSources.builtin) { this.disposeBuiltinEditingSession(fileUri); } @@ -1666,7 +1665,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.currentModelRef = undefined; this.currentEditingUri = undefined; this.currentEditingProjectRoot = undefined; - this.currentEditingStorage = undefined; + this.currentEditingSource = undefined; this.currentEditingPromptType = undefined; this.currentEditingReadOnly = false; this.editorDisplayMode = 'preview'; @@ -1728,7 +1727,7 @@ export class AICustomizationManagementEditor extends EditorPane { private createBuiltinPromptSaveRequest(target: ISaveTargetQuickPickItem): IBuiltinPromptSaveRequest | undefined { const sourceUri = this.currentEditingUri; const promptType = this.currentEditingPromptType; - if (!sourceUri || this.currentEditingStorage !== BUILTIN_STORAGE || (promptType !== PromptsType.prompt && promptType !== PromptsType.skill) || !target.folder || target.target === 'cancel') { + if (!sourceUri || this.currentEditingSource !== AICustomizationSources.builtin || (promptType !== PromptsType.prompt && promptType !== PromptsType.skill) || !target.folder || target.target === 'cancel') { return; } @@ -1748,7 +1747,7 @@ export class AICustomizationManagementEditor extends EditorPane { } private createExistingCustomizationSaveRequest(): IExistingCustomizationSaveRequest | undefined { - if (!this._editorContentChanged || this.currentEditingStorage === BUILTIN_STORAGE || !this.currentEditingUri) { + if (!this._editorContentChanged || this.currentEditingSource === AICustomizationSources.builtin || !this.currentEditingUri) { return undefined; } @@ -1843,7 +1842,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (backgroundSaveRequest) { this.telemetryService.publicLog2('chatCustomizationEditor.saveItem', { promptType: this.currentEditingPromptType ?? '', - storage: String(this.currentEditingStorage ?? ''), + storage: String(this.currentEditingSource ?? ''), saveTarget: selection.target, }); } @@ -1887,7 +1886,7 @@ export class AICustomizationManagementEditor extends EditorPane { private shouldShowBuiltinSaveAction(): boolean { return this._editorContentChanged - && this.currentEditingStorage === BUILTIN_STORAGE + && this.currentEditingSource === AICustomizationSources.builtin && (this.currentEditingPromptType === PromptsType.prompt || this.currentEditingPromptType === PromptsType.skill); } @@ -1917,7 +1916,7 @@ export class AICustomizationManagementEditor extends EditorPane { await this.saveBuiltinPromptCopy(saveRequest); this.telemetryService.publicLog2('chatCustomizationEditor.saveItem', { promptType: this.currentEditingPromptType ?? '', - storage: String(this.currentEditingStorage ?? ''), + storage: String(this.currentEditingSource ?? ''), saveTarget: target.target, }); @@ -1973,7 +1972,7 @@ export class AICustomizationManagementEditor extends EditorPane { return undefined; } - if (this.currentEditingStorage === BUILTIN_STORAGE) { + if (this.currentEditingSource === AICustomizationSources.builtin) { return this.builtinEditingSessions.get(this.currentEditingUri.toString())?.model; } @@ -2058,7 +2057,7 @@ export class AICustomizationManagementEditor extends EditorPane { return false; } - return (this.currentEditingStorage === BUILTIN_STORAGE && (promptType === PromptsType.prompt || promptType === PromptsType.skill)) + return (this.currentEditingSource === AICustomizationSources.builtin && (promptType === PromptsType.prompt || promptType === PromptsType.skill)) || !this.currentEditingReadOnly; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index d61da34576d513..91218d8a9de506 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -10,8 +10,8 @@ import { createVSCodeHarnessDescriptor, } from '../../common/customizationHarnessService.js'; -import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; import { SessionType } from '../../common/chatSessionsService.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -25,7 +25,7 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { constructor( @IPromptsService promptsService: IPromptsService, ) { - const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; + const localExtras = [AICustomizationSources.extension, AICustomizationSources.builtin]; super( [createVSCodeHarnessDescriptor(localExtras)], SessionType.Local, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 77d5a3841b5ff7..01c2d6e34fcfa7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -12,7 +12,7 @@ import { basename, dirname, isEqualOrParent } from '../../../../../base/common/r import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IAICustomizationWorkspaceService, AICustomizationPromptsStorage, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, applySourceFilter, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; @@ -55,7 +55,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt return itemSets.flat(); } - private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { + private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { const items: ICustomizationItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); @@ -74,7 +74,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: agent.name, description: agent.description, - storage: agent.source.storage, + source: agent.source.storage, enabled: agent.enabled, extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, @@ -104,7 +104,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: skillName, description: skill.description, - storage: skill.storage, + source: skill.storage, enabled: true, badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, @@ -124,7 +124,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: disabledName, description: file.description, - storage: file.storage, + source: file.storage, enabled: false, badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, badgeTooltip: uiTooltip, @@ -146,7 +146,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: command.name, description: command.description, - storage: command.storage, + source: command.storage, enabled: !disabledUris.has(command.uri), extensionId: command.extension?.identifier.value, pluginUri: command.pluginUri, @@ -177,7 +177,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt uri: f.uri, type: promptType, name: f.name || getFriendlyName(basename(f.uri)), - storage: f.storage, + source: f.storage, enabled: !disabledUris.has(f.uri), extensionId: f.extension?.identifier.value, pluginUri: f.pluginUri, @@ -206,7 +206,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: hookMeta?.label ?? hookType, description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, - storage: agent.source.storage, + source: agent.source.storage, groupKey: 'agents', enabled: !disabledUris.has(agent.uri), extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, @@ -235,7 +235,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt uri: file.uri, type: promptType, name: filename, - storage, + source: storage, groupKey: 'agent-instructions', enabled: !disabledUris.has(file.uri), extensionId: undefined, @@ -265,7 +265,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt badge, badgeTooltip, description, - storage, + source: storage, groupKey: 'context-instructions', enabled: !disabledUris.has(uri), extensionId: extension?.identifier.value, @@ -278,7 +278,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt type: promptType, name: friendlyName, description, - storage, + source: storage, groupKey: 'on-demand-instructions', enabled: !disabledUris.has(uri), extensionId: extension?.identifier.value, @@ -291,7 +291,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt private applyBuiltinGroupKeys(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): ICustomizationItem[] { return items.map(item => { - if (item.storage !== PromptsStorage.extension) { + if (item.source !== AICustomizationSources.extension) { return item; } const extInfo = extensionInfoByUri.get(item.uri); @@ -311,11 +311,9 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } - private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): ICustomizationItem[] { + private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): readonly ICustomizationItem[] { const filter = this.workspaceService.getStorageSourceFilter(promptType); - const withStorage = groupedItems.filter((item): item is ICustomizationItem & { readonly storage: AICustomizationPromptsStorage } => item.storage !== undefined); - const withoutStorage = groupedItems.filter(item => item.storage === undefined); - let items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; + let items = applySourceFilter(groupedItems, filter); const descriptor = this.getActiveDescriptor(); const subpaths = descriptor.workspaceSubpaths; @@ -324,7 +322,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt if (subpaths) { const projectRoot = this.workspaceService.getActiveProjectRoot(); items = items.filter(item => { - if (item.storage !== PromptsStorage.local || !projectRoot || !isEqualOrParent(item.uri, projectRoot)) { + if (item.source !== AICustomizationSources.local || !projectRoot || !isEqualOrParent(item.uri, projectRoot)) { return true; } if (matchesWorkspaceSubpath(item.uri.path, subpaths)) { diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 10debd9df0cb68..39fb781ce40cdc 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1621,7 +1621,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.OfflineByok]: { type: 'boolean', description: nls.localize('chat.offlineByok', "Experimental: enable BYOK chat features without GitHub sign-in."), - default: false, + default: product.quality !== 'stable', scope: ConfigurationScope.WINDOW, included: false, }, diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index c30c476dd90580..4903a2bad05920 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -41,6 +41,7 @@ import { IContextKey, IContextKeyService } from '../../../../../platform/context import { CONTEXT_MODELS_SEARCH_FOCUS } from '../../common/constants.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import Severity from '../../../../../base/common/severity.js'; +import { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; const $ = DOM.$; @@ -744,6 +745,65 @@ interface IActionsColumnTemplateData extends IModelTableColumnTemplateData { readonly actionBar: ToolBar; } +function createProviderGroupActions( + viewModel: ChatModelsViewModel, + vendor: ILanguageModelProviderDescriptor, + groupName: string, + languageModelsService: ILanguageModelsService, + dialogService: IDialogService, +): IAction[] { + const configuration = vendor.configuration as IJSONSchema | undefined; + if (!configuration) { + return []; + } + + const actions: IAction[] = []; + const configurationProperties = configuration.properties; + actions.push(toAction({ + id: 'goToSettingsAction', + label: localize('models.goToSettings', "Open in Language Models (JSON)"), + run: () => languageModelsService.openLanguageModelsProviderGroupSettings(vendor.vendor, groupName) + })); + actions.push(new Separator()); + actions.push(toAction({ + id: 'renameGroupAction', + label: localize('models.renameGroup', 'Rename Group'), + run: () => languageModelsService.renameLanguageModelsProviderGroup(vendor.vendor, groupName) + })); + if (configurationProperties?.apiKey) { + actions.push(toAction({ + id: 'updateApiKeyAction', + label: localize('models.updateApiKey', "Update API Key"), + run: () => languageModelsService.updateLanguageModelsProviderGroupApiKey(vendor.vendor, groupName) + })); + } + if (configurationProperties?.models?.defaultSnippets?.[0]) { + actions.push(toAction({ + id: 'addModelAction', + label: localize('models.addModel', "Add Model"), + run: () => languageModelsService.addLanguageModelsProviderGroupModel(vendor.vendor, groupName) + })); + } + actions.push(new Separator()); + actions.push(toAction({ + id: 'deleteAction', + label: localize('models.deleteAction', 'Delete'), + class: ThemeIcon.asClassName(Codicon.trash), + run: async () => { + const result = await dialogService.confirm({ + type: 'info', + message: localize('models.deleteConfirmation', "Would you like to delete {0}?", groupName) + }); + if (!result.confirmed) { + return; + } + await languageModelsService.removeLanguageModelsProviderGroup(vendor.vendor, groupName); + viewModel.refresh(); + } + })); + return actions; +} + class ActionsColumnRenderer extends ModelsTableColumnRenderer { static readonly TEMPLATE_ID = 'actions'; @@ -793,27 +853,7 @@ class ActionsColumnRenderer extends ModelsTableColumnRenderer this.languageModelsService.configureLanguageModelsProviderGroup(vendorEntry.vendor.vendor, vendorEntry.group.name) - })); - secondaryActions.push(toAction({ - id: 'deleteAction', - label: localize('models.deleteAction', 'Delete'), - class: ThemeIcon.asClassName(Codicon.trash), - run: async () => { - const result = await this.dialogService.confirm({ - type: 'info', - message: localize('models.deleteConfirmation', "Would you like to delete {0}?", vendorEntry.group.name) - }); - if (!result.confirmed) { - return; - } - await this.languageModelsService.removeLanguageModelsProviderGroup(vendorEntry.vendor.vendor, vendorEntry.group.name); - this.viewModel.refresh(); - } - })); + secondaryActions.push(...createProviderGroupActions(this.viewModel, vendorEntry.vendor, vendorEntry.group.name, this.languageModelsService, this.dialogService)); } else if (vendorEntry.vendor.managementCommand) { primaryActions.push(toAction({ id: 'manageVendor', @@ -954,6 +994,7 @@ export class ChatModelsWidget extends Disposable { @IEditorProgressService private readonly editorProgressService: IEditorProgressService, @ICommandService private readonly commandService: ICommandService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IDialogService private readonly dialogService: IDialogService, ) { super(); @@ -1341,26 +1382,21 @@ export class ChatModelsWidget extends Disposable { } if (configureGroup && configureVendor) { - if (configureVendor.managementCommand || configureVendor.configuration) { + const groupActions = configureVendor.managementCommand + ? [toAction({ + id: 'manageVendor', + label: localize('models.manageProvider', 'Manage {0}...', configureGroup), + run: async () => { + await this.commandService.executeCommand(configureVendor.managementCommand!, configureVendor.vendor); + await this.viewModel.refresh(); + } + })] + : createProviderGroupActions(this.viewModel, configureVendor, configureGroup, this.languageModelsService, this.dialogService); + if (groupActions.length) { if (actions.length) { actions.push(new Separator()); } - if (configureVendor.managementCommand) { - actions.push(toAction({ - id: 'configureVendor', - label: localize('models.configureContextMenu', 'Configure'), - run: async () => { - await this.commandService.executeCommand(configureVendor.managementCommand!, configureVendor.vendor); - await this.viewModel.refresh(); - } - })); - } else { - actions.push(toAction({ - id: 'configureVendor', - label: localize('models.configureContextMenu', 'Configure'), - run: () => this.languageModelsService.configureLanguageModelsProviderGroup(configureVendor.vendor, configureGroup!) - })); - } + actions.push(...groupActions); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index e209805841136e..99928940166335 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -363,7 +363,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: 'workbench.action.chat.triggerSetupFromAccounts', - title: localize2('triggerChatSetupFromAccounts', "Sign in to use AI features..."), + title: localize2('triggerChatSetupFromAccounts', "Sign in to use GitHub Copilot..."), menu: { id: MenuId.AccountsContext, group: '2_copilot', @@ -371,7 +371,6 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabledInWorkspace.negate(), ChatContextKeys.Setup.completed.negate(), - ChatEntitlementContextKeys.hasByokModels.negate(), ChatContextKeys.Entitlement.signedOut ) } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 1493a525227bb9..55f6f89c49b8b6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -518,7 +518,9 @@ export class ChatStatusDashboard extends DomWidget { const newUser = isNewUser(this.chatEntitlementService) && !hasByokModels; const anonymousUser = this.chatEntitlementService.anonymous; const disabled = this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted; - const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !hasByokModels; + // Keep the Sign-in entry visible even when BYOK models are present so air-gapped + // users can still authenticate to unlock the full Copilot experience. + const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown; if (!(newUser || signedOut || disabled)) { return; } @@ -537,7 +539,7 @@ export class ChatStatusDashboard extends DomWidget { } else if (disabled) { descriptionText = localize('enableDescription', "Enable Copilot to use AI features."); } else { - descriptionText = localize('signInDescription', "Sign in to use Copilot AI features."); + descriptionText = localize('signInDescription', "Sign in to use GitHub Copilot AI features."); } let buttonLabel: string; @@ -548,7 +550,7 @@ export class ChatStatusDashboard extends DomWidget { } else if (disabled) { buttonLabel = localize('enableCopilotButton', "Enable AI Features"); } else { - buttonLabel = localize('signInToUseAIFeatures', "Sign in to use AI Features"); + buttonLabel = localize('signInToUseAIFeatures', "Sign in to use GitHub Copilot"); } let commandId: string; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index e7239ff59de9c7..1a21aec2e9a802 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -177,8 +177,9 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu } } - // Signed out - else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.hasByokModels) { + // Signed out — keep showing Sign-in affordance even when BYOK models are present + // so air-gapped users can still authenticate to unlock the full Copilot experience. + else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { return this.getSetupEntryProps(); } diff --git a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts index c6bfca0856ad41..e5d67d31ed9061 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts @@ -163,24 +163,36 @@ export class LanguageModelsConfigurationService extends Disposable implements IL return; } - if (!options.group.range) { - return; - } - if (options.snippet) { // Insert snippet at the end of the last property line (before the closing brace line), with comma prepended const model = codeEditor.getModel(); if (!model) { return; } - const lastPropertyLine = options.group.range.endLineNumber - 1; - const lastPropertyLineLength = model.getLineLength(lastPropertyLine); - const insertPosition = { lineNumber: lastPropertyLine, column: lastPropertyLineLength + 1 }; + const targetRange = options.snippetTarget === 'models' ? options.group.modelsRange : options.group.range; + if (!targetRange) { + return; + } + const models = options.group.models; + const isModelsArray = options.snippetTarget === 'models' && Array.isArray(models); + const emptyModelsArray = isModelsArray && models.length === 0; + const insertBeforeModelsArrayEnd = emptyModelsArray || (isModelsArray && targetRange.startLineNumber === targetRange.endLineNumber); + const lastPropertyLine = targetRange.endLineNumber - 1; + const insertPosition = insertBeforeModelsArrayEnd ? { + lineNumber: targetRange.endLineNumber, + column: targetRange.endColumn - 1 + } : { + lineNumber: lastPropertyLine, + column: model.getLineLength(lastPropertyLine) + 1 + }; codeEditor.setPosition(insertPosition); codeEditor.revealPositionNearTop(insertPosition); codeEditor.focus(); - SnippetController2.get(codeEditor)?.insert(',\n' + options.snippet); + SnippetController2.get(codeEditor)?.insert(emptyModelsArray ? options.snippet : ',\n' + options.snippet); } else { + if (!options.group.range) { + return; + } const position = { lineNumber: options.group.range.startLineNumber, column: options.group.range.startColumn }; codeEditor.setPosition(position); codeEditor.revealPositionNearTop(position); @@ -203,6 +215,7 @@ export class LanguageModelsConfigurationService extends Disposable implements IL const updatedLanguageModelsProviderGroups = await update(languageModelsProviderGroups); for (const group of updatedLanguageModelsProviderGroups) { delete group.range; + delete group.modelsRange; } model.setValue(JSON.stringify(updatedLanguageModelsProviderGroups, undefined, '\t')); await this.textFileService.save(this.modelsConfigurationFile); @@ -273,20 +286,38 @@ export function parseLanguageModelsProviderGroups(model: ITextModel): LanguageMo currentProperty = null; return; } - const array: unknown[] = []; + const array: unknown[] & { _parentModelsRange?: Mutable } = []; + const parent = currentParent as Record & { range?: IRange; modelsRange?: Mutable }; + if (currentProperty === 'models' && parent.range) { + const start = model.getPositionAt(offset); + const end = model.getPositionAt(offset + length); + parent.modelsRange = { + startLineNumber: start.lineNumber, + startColumn: start.column, + endLineNumber: end.lineNumber, + endColumn: end.column + }; + array._parentModelsRange = parent.modelsRange; + } onValue(array, offset, length); previousParents.push(currentParent); currentParent = array; currentProperty = null; }, onArrayEnd: (offset: number, length: number) => { - const parent = currentParent as { _parentConfigurationRange?: Mutable }; + const parent = currentParent as { _parentConfigurationRange?: Mutable; _parentModelsRange?: Mutable }; if (parent._parentConfigurationRange) { const end = model.getPositionAt(offset + length); parent._parentConfigurationRange.endLineNumber = end.lineNumber; parent._parentConfigurationRange.endColumn = end.column; delete parent._parentConfigurationRange; } + if (parent._parentModelsRange) { + const end = model.getPositionAt(offset + length); + parent._parentModelsRange.endLineNumber = end.lineNumber; + parent._parentModelsRange.endColumn = end.column; + delete parent._parentModelsRange; + } currentParent = previousParents.pop(); }, onLiteralValue: (value: unknown, offset: number, length: number) => { diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index e4d21d1676d6cf..2bd74cc35388f5 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -413,10 +413,7 @@ export class PluginInstallService implements IPluginInstallService { } getPluginInstallUri(plugin: IMarketplacePlugin): URI { - if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { - return this._pluginRepositoryService.getPluginInstallUri(plugin); - } - return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor); + return this._pluginRepositoryService.getPluginInstallUri(plugin); } // --- Trust gate ------------------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts b/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts index ac7e4efbfa916d..d1c607ae4d487d 100644 --- a/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts @@ -7,7 +7,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { localize } from '../../../../nls.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { ChatConfiguration } from '../common/constants.js'; -import { ILanguageModelsService } from '../common/languageModels.js'; +import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../common/languageModels.js'; import { createDefaultModelArrays, DefaultModelContribution } from './defaultModelContribution.js'; // The empty value for these settings means "use the built-in utility-family @@ -40,6 +40,7 @@ export class UtilityModelContribution extends DefaultModelContribution { configKey: ChatConfiguration.UtilityModel, configSectionId: 'chatSidebar', logPrefix: '[UtilityModel]', + filter: metadata => metadata.vendor !== COPILOT_VENDOR_ID, storageFormat: 'vendorAndId', defaultEntryLabel, defaultEntryDescription, @@ -68,6 +69,7 @@ export class UtilitySmallModelContribution extends DefaultModelContribution { configKey: ChatConfiguration.UtilitySmallModel, configSectionId: 'chatSidebar', logPrefix: '[UtilitySmallModel]', + filter: metadata => metadata.vendor !== COPILOT_VENDOR_ID, storageFormat: 'vendorAndId', defaultEntryLabel, defaultEntryDescription, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index f9c392def35a79..1608bbda8f195c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -191,7 +191,7 @@ function pickWorkingLabel(elementId: string, configurationService: IConfiguratio return existing.label; } - const fun = maybePickFunWorkingMessage(); + const fun = maybePickFunWorkingMessage(configurationService); const label = fun ?? (() => { const pool = buildPhrasePool(defaultThinkingMessages, configurationService); return pool[Math.floor(Math.random() * pool.length)]; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 0346b081ef4362..c0b406a713671a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -192,10 +192,31 @@ const funWorkingMessages = [ const FUN_WORKING_MESSAGE_RATE = 100; +type ThinkingPhrasesConfiguration = { mode?: 'replace' | 'append'; phrases?: string[] }; + +function getCustomThinkingPhrases(configurationService: IConfigurationService): { customPhrases: string[]; replaceDefaults: boolean } { + const config = configurationService.getValue(ChatConfiguration.ThinkingPhrases); + const customPhrases = Array.isArray(config?.phrases) + ? config.phrases + .filter((phrase): phrase is string => typeof phrase === 'string') + .map(phrase => phrase.trim()) + .filter(phrase => phrase.length > 0) + : []; + + return { + customPhrases, + replaceDefaults: config?.mode === 'replace' && customPhrases.length > 0, + }; +} + /** Returns an easter-egg message ~1 in {@link FUN_WORKING_MESSAGE_RATE}, else `undefined`. */ -export function maybePickFunWorkingMessage(): string | undefined { - if (Math.floor(Math.random() * FUN_WORKING_MESSAGE_RATE) === 0) { - return funWorkingMessages[Math.floor(Math.random() * funWorkingMessages.length)]; +export function maybePickFunWorkingMessage(configurationService: IConfigurationService, random = Math.random): string | undefined { + if (getCustomThinkingPhrases(configurationService).replaceDefaults) { + return undefined; + } + + if (Math.floor(random() * FUN_WORKING_MESSAGE_RATE) === 0) { + return funWorkingMessages[Math.floor(random() * funWorkingMessages.length)]; } return undefined; } @@ -206,16 +227,10 @@ export function maybePickFunWorkingMessage(): string | undefined { * custom phrases are added to the defaults. */ export function buildPhrasePool(defaults: string[], configurationService: IConfigurationService): string[] { - const config = configurationService.getValue<{ mode?: 'replace' | 'append'; phrases?: string[] }>(ChatConfiguration.ThinkingPhrases); - const customPhrases = Array.isArray(config?.phrases) - ? config.phrases - .filter((phrase): phrase is string => typeof phrase === 'string') - .map(phrase => phrase.trim()) - .filter(phrase => phrase.length > 0) - : []; + const { customPhrases, replaceDefaults } = getCustomThinkingPhrases(configurationService); if (customPhrases.length > 0) { - return config?.mode === 'replace' ? [...customPhrases] : [...defaults, ...customPhrases]; + return replaceDefaults ? [...customPhrases] : [...defaults, ...customPhrases]; } return [...defaults]; } @@ -285,7 +300,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen get aggregatedDiff(): IEditSessionDiffStats { return this._aggregatedDiff; } private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { - const fun = maybePickFunWorkingMessage(); + const fun = maybePickFunWorkingMessage(this.configurationService); if (fun) { return fun; } diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 715d629ab08d11..c22ec79119fa87 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -17,12 +17,21 @@ export const IAICustomizationWorkspaceService = createDecorator(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { + const sourceSet = new Set(filter.sources); + return items.filter(item => { + if (!sourceSet.has(item.source)) { + return false; + } + if (item.source === AICustomizationSources.user && filter.includedUserFileRoots) { + return filter.includedUserFileRoots.some(root => isEqualOrParent(item.uri, root)); + } + return true; + }); +} + +/** + * Applies a storage filter to an array of items that have uri and storage. * Removes items whose storage is not in the filter's source list, * and for user-storage items, removes those not under an allowed root. */ -export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { +export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { const sourceSet = new Set(filter.sources); return items.filter(item => { if (!sourceSet.has(item.storage)) { diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 2909add655d724..66cadfa9765be3 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -12,7 +12,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { AICustomizationManagementSection, AICustomizationPromptsStorage, BUILTIN_STORAGE, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSource, AICustomizationSources, BUILTIN_STORAGE, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; import { IAgentSource, IChatPromptSlashCommand, ICustomAgent, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; @@ -164,8 +164,8 @@ export interface ICustomizationItem { readonly type: string; readonly name: string; readonly description?: string; - /** Storage origin (local, user, extension, plugin, builtin). Set by providers that know the source. */ - readonly storage?: AICustomizationPromptsStorage; + /** Customization source (local, user, extension, plugin, builtin). Set by providers that know the source. */ + readonly source: AICustomizationSource; /** The extension identifier that contributed this customization, if any. */ readonly extensionId: string | undefined; /** The URI of the plugin that contributed this customization, if any. */ @@ -400,16 +400,16 @@ export function getCliUserRoots(userHome: URI): readonly URI[] { * Core passes `[PromptsStorage.extension]`; sessions passes its * BUILTIN_STORAGE constant. */ -function buildAllSources(extras: readonly string[]): readonly string[] { - return [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, ...extras]; +function buildAllSources(extras: readonly AICustomizationSource[]): readonly AICustomizationSource[] { + return [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.plugin, ...extras]; } /** * Creates a "VS Code" harness descriptor that shows all storage sources * with no user-root restrictions. */ -export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarnessDescriptor { - const filter: IStorageSourceFilter = { sources: buildAllSources(extras) }; +export function createVSCodeHarnessDescriptor(sources: readonly AICustomizationSource[]): IHarnessDescriptor { + const filter: IStorageSourceFilter = { sources: buildAllSources(sources) }; return { id: SessionType.Local, label: localize('harness.local', "Local"), @@ -443,7 +443,7 @@ function createRestrictedHarnessDescriptor( label: string, icon: ThemeIcon, restrictedUserRoots: readonly URI[], - extras: readonly string[], + extras: readonly AICustomizationSource[], options?: IRestrictedHarnessOptions, ): IHarnessDescriptor { const allSources = buildAllSources(extras); @@ -474,7 +474,7 @@ function createRestrictedHarnessDescriptor( /** * Creates a "Copilot CLI" harness descriptor. */ -export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor { +export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: readonly AICustomizationSource[]): IHarnessDescriptor { return createRestrictedHarnessDescriptor( SessionType.CopilotCLI, localize('harness.cli', "Copilot CLI"), @@ -663,13 +663,13 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer if (!items) { return []; } - const result = []; + const result: IChatPromptSlashCommand[] = []; for (const item of items) { if ((item.enabled !== false) && (item.type === PromptsType.prompt || item.type === PromptsType.skill)) { // `IChatPromptSlashCommand.storage` is `PromptsStorage`, so coerce // the wider provider-supplied storage (which may be `BUILTIN_STORAGE`) // down to the closest narrow value. - const storage = item.storage; + const storage = item.source; const narrowStorage: PromptsStorage = storage !== undefined && storage !== BUILTIN_STORAGE ? storage as PromptsStorage : PromptsStorage.local; @@ -701,11 +701,11 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } const getSource = (item: ICustomizationItem): IAgentSource => { - if (item.storage === PromptsStorage.extension && item.extensionId) { + if (item.source === PromptsStorage.extension && item.extensionId) { return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(item.extensionId) }; - } else if (item.storage === PromptsStorage.plugin && item.pluginUri) { + } else if (item.source === PromptsStorage.plugin && item.pluginUri) { return { storage: PromptsStorage.plugin, pluginUri: item.pluginUri }; - } else if (item.storage === PromptsStorage.user) { + } else if (item.source === PromptsStorage.user) { return { storage: PromptsStorage.user }; } return { storage: PromptsStorage.local }; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index d97b3a234e2727..a4ffaa45c04c07 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -423,6 +423,14 @@ export interface ILanguageModelsService { configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; + renameLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise; + + updateLanguageModelsProviderGroupApiKey(vendorId: string, providerGroupName: string): Promise; + + addLanguageModelsProviderGroupModel(vendorId: string, providerGroupName: string): Promise; + + openLanguageModelsProviderGroupSettings(vendorId: string, providerGroupName: string): Promise; + /** * Opens the language models configuration file and navigates to * or creates the per-model configuration for the given model. @@ -1179,7 +1187,7 @@ export class LanguageModelsService implements ILanguageModelsService { ...group, settings: Object.keys(updatedSettings).length > 0 ? updatedSettings : undefined }; - if (!updatedGroup.settings && Object.keys(updatedGroup).filter(k => k !== 'name' && k !== 'vendor' && k !== 'range' && k !== 'settings').length === 0) { + if (!updatedGroup.settings && Object.keys(updatedGroup).filter(k => k !== 'name' && k !== 'vendor' && k !== 'range' && k !== 'modelsRange' && k !== 'settings').length === 0) { // Remove the group entirely if it only had model config await this._languageModelsConfigurationService.removeLanguageModelsProviderGroup(group); } else { @@ -1298,6 +1306,96 @@ export class LanguageModelsService implements ILanguageModelsService { } } + async renameLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { + const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); + if (!vendor) { + throw new Error(`Vendor ${vendorId} not found.`); + } + + const languageModelProviderGroups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + const existing = languageModelProviderGroups.find(group => group.vendor === vendorId && group.name === providerGroupName); + if (!existing) { + throw new Error(`Language model provider group ${providerGroupName} for vendor ${vendorId} not found.`); + } + + const name = await this.promptForName(languageModelProviderGroups, vendor, existing); + if (!name || name === existing.name) { + return; + } + + await this._languageModelsConfigurationService.updateLanguageModelsProviderGroup(existing, { ...existing, name }); + } + + async updateLanguageModelsProviderGroupApiKey(vendorId: string, providerGroupName: string): Promise { + const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); + const schema = vendor?.configuration as IJSONSchema | undefined; + const apiKeySchema = schema?.properties?.apiKey; + if (!vendor || !schema || !apiKeySchema) { + return; + } + + const existing = this._languageModelsConfigurationService.getLanguageModelsProviderGroups().find(group => group.vendor === vendorId && group.name === providerGroupName); + if (!existing) { + throw new Error(`Language model provider group ${providerGroupName} for vendor ${vendorId} not found.`); + } + + try { + const existingConfiguration = await this._resolveConfiguration(existing, schema); + const apiKey = await this.promptForValue(existing.name, 'apiKey', apiKeySchema, !!schema.required?.includes('apiKey'), existingConfiguration); + if (apiKey === undefined || apiKey === existingConfiguration.apiKey) { + return; + } + + const configuration = { ...existingConfiguration, apiKey }; + const updated = { + ...await this._resolveLanguageModelProviderGroup(existing.name, vendorId, configuration, schema), + settings: existing.settings + }; + await this._languageModelsConfigurationService.updateLanguageModelsProviderGroup(existing, updated); + await this._deleteSecretsInConfiguration(existing, schema); + } catch (error) { + if (isCancellationError(error)) { + return; + } + throw error; + } + } + + async addLanguageModelsProviderGroupModel(vendorId: string, providerGroupName: string): Promise { + const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); + const schema = vendor?.configuration as IJSONSchema | undefined; + const modelsSchema = schema?.properties?.models; + if (!vendor || !modelsSchema) { + return; + } + + const group = this._languageModelsConfigurationService.getLanguageModelsProviderGroups().find(group => group.vendor === vendorId && group.name === providerGroupName); + if (!group) { + throw new Error(`Language model provider group ${providerGroupName} for vendor ${vendorId} not found.`); + } + + const hasModels = Array.isArray(group.models); + const snippet = hasModels ? this.getSnippetForArrayItem(modelsSchema) : this.getSnippetForProperty('models', modelsSchema); + if (!snippet) { + return; + } + + await this._languageModelsConfigurationService.configureLanguageModels({ + group, + snippet, + snippetTarget: hasModels ? 'models' : 'group' + }); + } + + async openLanguageModelsProviderGroupSettings(vendorId: string, providerGroupName: string): Promise { + const group = this._languageModelsConfigurationService.getLanguageModelsProviderGroups().find(group => group.vendor === vendorId && group.name === providerGroupName); + if (!group) { + throw new Error(`Language model provider group ${providerGroupName} for vendor ${vendorId} not found.`); + } + + await this._languageModelsConfigurationService.configureLanguageModels({ group }); + } + async configureModel(modelId: string): Promise { const metadata = this._modelCache.get(modelId); if (!metadata || !metadata.configurationSchema) { @@ -1404,18 +1502,40 @@ export class LanguageModelsService implements ILanguageModelsService { for (const property of Object.keys(schema.properties)) { if (configuration[property] === undefined) { const propertySchema = schema.properties[property]; - if (propertySchema && typeof propertySchema !== 'boolean' && propertySchema.defaultSnippets?.[0]) { - const snippet = propertySchema.defaultSnippets[0]; - let bodyText = snippet.bodyText ?? JSON.stringify(snippet.body, null, '\t'); - // Handle ^ prefix for raw values (numbers/booleans) - remove quotes around ^-prefixed values - bodyText = bodyText.replace(/"(\^[^"]*)"/g, (_, value) => value.substring(1)); - return `"${property}": ${bodyText}`; + const snippet = this.getSnippetForProperty(property, propertySchema); + if (snippet) { + return snippet; } } } return undefined; } + private getSnippetForProperty(property: string, propertySchema: IJSONSchema): string | undefined { + const bodyText = this.getDefaultSnippetBodyText(propertySchema); + return bodyText ? `"${property}": ${bodyText}` : undefined; + } + + private getSnippetForArrayItem(propertySchema: IJSONSchema): string | undefined { + return this.getDefaultSnippetBodyText(propertySchema, true); + } + + private getDefaultSnippetBodyText(propertySchema: IJSONSchema, arrayItem = false): string | undefined { + const snippet = propertySchema.defaultSnippets?.[0]; + if (!snippet) { + return undefined; + } + + const bodyText = arrayItem + ? Array.isArray(snippet.body) && snippet.body.length > 0 ? JSON.stringify(snippet.body[0], null, '\t') : undefined + : snippet.bodyText ?? JSON.stringify(snippet.body, null, '\t'); + if (!bodyText) { + return undefined; + } + + return bodyText.replace(/"(\^[^"]*)"/g, (_, value) => value.substring(1)); + } + private async promptForName(languageModelProviderGroups: readonly ILanguageModelsProviderGroup[], vendor: IUserFriendlyLanguageModel, existing: ILanguageModelsProviderGroup | undefined): Promise { let providerGroupName = existing?.name; if (!providerGroupName) { @@ -1443,7 +1563,7 @@ export class LanguageModelsService implements ILanguageModelsService { inputBox.severity = Severity.Error; return; } - if (!existing && languageModelProviderGroups.some(g => g.name === value)) { + if (languageModelProviderGroups.some(group => group !== existing && group.vendor === vendor.vendor && group.name === value)) { inputBox.validationMessage = localize('nameExists', "A language models group with this name already exists"); inputBox.severity = Severity.Error; return; @@ -1729,7 +1849,7 @@ export class LanguageModelsService implements ILanguageModelsService { const result: IStringDictionary = {}; for (const key in group) { - if (key === 'vendor' || key === 'name' || key === 'range' || key === 'settings') { + if (key === 'vendor' || key === 'name' || key === 'range' || key === 'modelsRange' || key === 'settings') { continue; } let value = group[key]; @@ -1767,7 +1887,7 @@ export class LanguageModelsService implements ILanguageModelsService { return; } - const { vendor, name, range, ...configuration } = group; + const { vendor, name, range, modelsRange, ...configuration } = group; for (const key in configuration) { const value = group[key]; if (schema.properties?.[key]?.secret) { diff --git a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts index 86c0d307537a69..ef645487a79b6e 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts @@ -14,6 +14,7 @@ export const ILanguageModelsConfigurationService = createDecorator readonly name: string; readonly vendor: string; readonly range?: IRange; + readonly modelsRange?: IRange; readonly settings?: IStringDictionary>; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 5571ef364d5e95..e40bca3d08af34 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -3100,7 +3100,7 @@ export namespace ChatResponseResource { } function _logChangesToStateModel(newState: Partial | undefined, oldState: Partial | undefined, logger: ILogService) { - if (canLog(logger.getLevel(), LogLevel.Debug) || newState?.selectedModel?.identifier === oldState?.selectedModel?.identifier) { + if (!canLog(logger.getLevel(), LogLevel.Debug) || newState?.selectedModel?.identifier === oldState?.selectedModel?.identifier) { return; } const stack = new Error().stack; @@ -3109,7 +3109,7 @@ function _logChangesToStateModel(newState: Partial | undef } export function logChangesToStateModel(model: IInputModel | undefined, message: string, newState: Partial | undefined, oldState: Partial | undefined, logger: ILogService) { - if (canLog(logger.getLevel(), LogLevel.Debug)) { + if (!canLog(logger.getLevel(), LogLevel.Debug)) { return; } message = [message, diff --git a/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts b/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts index 4a3c94e7fa75ff..4d51b9d789fcae 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/fileBackedInstalledPluginsStore.ts @@ -25,11 +25,14 @@ const LEGACY_MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1 /** * Minimal entry stored in `installed.json`. URIs are serialised as strings * so that external tools can read and write the file without depending on - * VS Code internal URI representations. + * VS Code internal URI representations. The optional `name` identifies the + * marketplace plugin and lets VS Code re-read the full descriptor from the + * marketplace when needed. */ interface IInstalledJsonEntry { readonly pluginUri: string; readonly marketplace: string; + readonly name?: string; } /** @@ -46,6 +49,7 @@ interface IInstalledJson { export interface IStoredInstalledPlugin { readonly pluginUri: URI; readonly marketplace: string; + readonly name?: string; } /** @@ -54,9 +58,9 @@ export interface IStoredInstalledPlugin { * the installed-plugin manifest discoverable by external tools (CLIs, * other editors, etc.) without depending on VS Code internals. * - * The on-disk format stores only the plugin URI (as a string) and the - * marketplace identifier. Plugin metadata (name, description, etc.) is - * read from the plugin manifest on disk by the discovery layer - + * The on-disk format stores only the plugin URI (as a string), marketplace + * identifier, and plugin name. Full plugin metadata (description, source + * descriptor, etc.) is read from marketplace data by the discovery layer - * keeping a single source of truth. * * On construction the store: @@ -134,10 +138,14 @@ export class FileBackedInstalledPluginsStore extends Disposable { return undefined; } - // Each entry is { pluginUri: string, enabled: boolean }. + // Each entry is { pluginUri, marketplace, name? }. return json.installed .filter((entry): entry is IInstalledJsonEntry => typeof entry.pluginUri === 'string' && typeof entry.marketplace === 'string') - .map(entry => ({ pluginUri: URI.parse(entry.pluginUri), marketplace: entry.marketplace })); + .map(entry => ({ + pluginUri: URI.parse(entry.pluginUri), + marketplace: entry.marketplace, + name: typeof entry.name === 'string' ? entry.name : undefined, + })); } catch { return undefined; } @@ -153,6 +161,7 @@ export class FileBackedInstalledPluginsStore extends Disposable { const entries: IInstalledJsonEntry[] = this.get().map(e => ({ pluginUri: e.pluginUri.toString(), marketplace: e.marketplace, + ...(e.name ? { name: e.name } : {}), })); const data: IInstalledJson = { @@ -230,12 +239,13 @@ export class FileBackedInstalledPluginsStore extends Disposable { return; } - const migrated: IStoredInstalledPlugin[] = (revive(parsed) as { pluginUri: UriComponents; plugin?: { marketplaceReference?: { rawValue?: string } } }[]).map(entry => { + const migrated: IStoredInstalledPlugin[] = (revive(parsed) as { pluginUri: UriComponents; plugin?: { name?: string; marketplaceReference?: { rawValue?: string } } }[]).map(entry => { const uri = URI.revive(entry.pluginUri); const rebased = this._rebasePluginUri(uri); return { pluginUri: rebased ?? uri, marketplace: entry.plugin?.marketplaceReference?.rawValue ?? '', + name: entry.plugin?.name, }; }).filter(e => !!e.marketplace); diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index b29af7f261b2c2..5e1464b580b3eb 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -383,8 +383,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke // Hydrate plugin metadata for installed entries that are not yet in // the in-memory cache (e.g. after restart when installed.json is read - // but the metadata map is empty). Walks up from each plugin URI to - // find the marketplace.json in the enclosing repository directory. + // but the metadata map is empty). Modern entries match by plugin name; + // older entries without names fall back to matching by install URI. this._register(autorun(reader => { const entries = this._installedPluginsStore.value.read(reader); const unhydrated = entries.filter(e => !this._pluginMetadata.has(e.pluginUri.toString())); @@ -583,13 +583,18 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { this._pluginMetadata.set(pluginUri.toString(), plugin); + const entry: IStoredInstalledPlugin = { + pluginUri, + marketplace: plugin.marketplaceReference.rawValue, + name: plugin.name, + }; const current = this._installedPluginsStore.get(); const existing = current.find(e => isEqual(e.pluginUri, pluginUri)); if (existing) { // Still update to trigger watchers to re-check, something might have happened that we want to know about - this._installedPluginsStore.set(current.map(c => c === existing ? { pluginUri, marketplace: plugin.marketplaceReference.rawValue } : c), undefined); + this._installedPluginsStore.set(current.map(c => c === existing ? entry : c), undefined); } else { - this._installedPluginsStore.set([...current, { pluginUri, marketplace: plugin.marketplaceReference.rawValue }], undefined); + this._installedPluginsStore.set([...current, entry], undefined); } } @@ -606,10 +611,10 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke // --- Plugin metadata hydration ----------------------------------------------- /** - * For each plugin URI that has no cached metadata, walk up the directory - * tree from the plugin towards the agent-plugins root looking for a - * marketplace definition file. When found, read the marketplace plugins - * and match by source path to populate {@link _pluginMetadata}. + * Hydrates installed entries from marketplace metadata. Entries written + * by current builds include the marketplace plugin name, which is enough + * to re-read the full plugin descriptor from the marketplace source. Old + * entries without a name fall back to matching by install URI. * * After hydration completes the installed-plugins store is "touched" so * that the derived {@link installedPlugins} observable re-evaluates with @@ -631,23 +636,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } try { - const repoDir = this._pluginRepositoryService.getRepositoryUri(reference); - let plugins = await this._readPluginsFromDirectory(repoDir, reference); - if (plugins.length === 0) { - // The entry may have come from a single-plugin repo - // installed via `installPluginFromSource` (no - // marketplace.json). Try the plugin manifest at the - // repo root — its synthesised install URI matches what - // `addInstalledPlugin` recorded. - const single = await this.readSinglePluginManifest(repoDir, reference); - if (single) { - plugins = [single]; - } - } - const match = plugins.find(p => { - const installUri = this._pluginRepositoryService.getPluginInstallUri(p); - return isEqual(installUri, entry.pluginUri); - }); + const plugins = await this._readPluginsForInstalledEntry(reference, CancellationToken.None); + const match = plugins.find(p => entry.name ? p.name === entry.name : isEqual(this._pluginRepositoryService.getPluginInstallUri(p), entry.pluginUri)); if (match) { this._pluginMetadata.set(key, match); hydrated++; @@ -665,6 +655,25 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } } + private async _readPluginsForInstalledEntry(reference: IMarketplaceReference, token: CancellationToken): Promise { + if (reference.kind === MarketplaceReferenceKind.GitHubShorthand && reference.githubRepo) { + return this._fetchFromGitHubRepo(reference, reference.githubRepo, token); + } + + const repoDir = this._pluginRepositoryService.getRepositoryUri(reference); + let plugins = await this._readPluginsFromDirectory(repoDir, reference, token); + if (plugins.length === 0) { + // The entry may have come from a single-plugin repo installed + // via `installPluginFromSource` (no marketplace.json). Try the + // plugin manifest at the repo root. + const single = await this.readSinglePluginManifest(repoDir, reference); + if (single) { + plugins = [single]; + } + } + return plugins; + } + /** * Shared logic to parse a marketplace.json into {@link IMarketplacePlugin} * objects. Used by both fetch and hydration paths. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 08ac9e24e00918..242a6488bee723 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -202,6 +202,18 @@ export type IAgentSource = { readonly pluginUri: URI; }; +export namespace IAgentSource { + export function fromPromptPath(promptPath: IPromptPath): IAgentSource { + if (promptPath.storage === PromptsStorage.extension) { + return { storage: PromptsStorage.extension, extensionId: promptPath.extension.identifier }; + } else if (promptPath.storage === PromptsStorage.plugin) { + return { storage: PromptsStorage.plugin, pluginUri: promptPath.pluginUri! }; + } else { + return { storage: promptPath.storage }; + } + } +} + /** * The visibility/availability of an agent. * - 'all': available as custom agent in picker AND can be used as subagent diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 7a41da2d49ea12..7416be30bb0a98 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -32,7 +32,7 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU import { PROMPT_LANGUAGE_ID, PromptFileSource, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IWorkspaceInstructionFile, PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { evaluateApplyToPattern, PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, IPromptFileContext, IPromptFileResource, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand, matchesSessionType } from './promptsService.js'; +import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, IPromptFileContext, IPromptFileResource, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand, matchesSessionType } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { ChatRequestHooks, parseSubagentHooksFromYaml } from '../hookSchema.js'; @@ -1469,23 +1469,4 @@ export namespace CustomAgent { } } -namespace IAgentSource { - export function fromPromptPath(promptPath: IPromptPath): IAgentSource { - if (promptPath.storage === PromptsStorage.extension) { - return { - storage: PromptsStorage.extension, - extensionId: promptPath.extension.identifier - }; - } else if (promptPath.storage === PromptsStorage.plugin) { - return { - storage: PromptsStorage.plugin, - pluginUri: promptPath.pluginUri - }; - } else { - return { - storage: promptPath.storage - }; - } - } -} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 93badd9e297a8f..706862fcc56f0b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -4425,6 +4425,132 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(ac.activeClient.customizations?.length, 1); assert.strictEqual(ac.activeClient.customizations?.[0].uri, 'file:///plugin-b'); }); + + test('does not dispatch activeClientChanged when an existing session is restored', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + const sessionResource = AgentSession.uri('copilot', 'existing-session'); + const summary: SessionSummary = { + resource: sessionResource.toString(), + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + agentHostService.sessionStates.set(sessionResource.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + activeClient: { + clientId: 'other-client', + tools: [], + }, + }); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => chatSession.dispose())); + + assert.strictEqual( + agentHostService.dispatchedActions.filter(d => d.action.type === 'session/activeClientChanged').length, + 0, + ); + }); + + test('dispatches activeClientChanged before sending a message when another client is active', async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const sessionResource = AgentSession.uri('copilot', 'existing-session'); + const summary: SessionSummary = { + resource: sessionResource.toString(), + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + agentHostService.sessionStates.set(sessionResource.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + activeClient: { + clientId: 'other-client', + tools: [], + }, + }); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + const activeClientActions = agentHostService.dispatchedActions.filter(d => d.action.type === 'session/activeClientChanged'); + assert.strictEqual(activeClientActions.length, 1); + assert.strictEqual(activeClientActions[0].channel, sessionResource.toString()); + }); + + test('dispatches activeClientChanged before sending a message when current client customizations are stale', async () => { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const customizations = observableValue('customizations', [ + { uri: 'file:///plugin-new', displayName: 'Plugin New' }, + ]); + const sessionResource = AgentSession.uri('copilot', 'existing-session'); + const summary: SessionSummary = { + resource: sessionResource.toString(), + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + agentHostService.sessionStates.set(sessionResource.toString(), { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + activeClient: { + clientId: agentHostService.clientId, + tools: [], + customizations: [{ uri: 'file:///plugin-old', displayName: 'Plugin Old' }], + }, + }); + + const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Agent Host - Copilot', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + customizations, + })); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + const activeClientActions = agentHostService.dispatchedActions.filter(d => d.action.type === 'session/activeClientChanged'); + assert.strictEqual(activeClientActions.length, 1); + const activeClientAction = activeClientActions[0].action; + assert.strictEqual(activeClientAction.type, 'session/activeClientChanged'); + assert.deepStrictEqual(activeClientAction.activeClient?.customizations, [ + { uri: 'file:///plugin-new', displayName: 'Plugin New' }, + ]); + }); }); // ---- Subagent grouping ---------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts index 6720d2bb784ee5..3db0a83ab31833 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/enumerateLocalCustomizationsForHarness.test.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { enumerateLocalCustomizationsForHarness } from '../../../browser/agentSessions/agentHost/agentHostLocalCustomizations.js'; -import { BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSources, BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js'; import { type ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { type IPromptPath, type IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; @@ -52,7 +52,7 @@ suite('enumerateLocalCustomizationsForHarness', () => { assert.deepStrictEqual(result, [{ uri: builtin, type: PromptsType.skill, - storage: BUILTIN_STORAGE, + source: AICustomizationSources.builtin, disabled: false, }]); }); @@ -67,9 +67,9 @@ suite('enumerateLocalCustomizationsForHarness', () => { const result = await enumerateLocalCustomizationsForHarness(promptsService, new FakeSyncProvider(), SessionType.CopilotCLI, CancellationToken.None); - assert.deepStrictEqual(result.map((e: { uri: URI; type: PromptsType; storage: unknown; disabled: boolean }) => ({ uri: e.uri.toString(), type: e.type, storage: e.storage, disabled: e.disabled })), [ - { uri: userAgent.toString(), type: PromptsType.agent, storage: PromptsStorage.extension, disabled: false }, - { uri: builtinSkill.toString(), type: PromptsType.skill, storage: BUILTIN_STORAGE, disabled: false }, + assert.deepStrictEqual(result.map((e: { uri: URI; type: PromptsType; source: unknown; disabled: boolean }) => ({ uri: e.uri.toString(), type: e.type, source: e.source, disabled: e.disabled })), [ + { uri: userAgent.toString(), type: PromptsType.agent, source: AICustomizationSources.extension, disabled: false }, + { uri: builtinSkill.toString(), type: PromptsType.skill, source: AICustomizationSources.builtin, disabled: false }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index 88758cdbb0fb5e..b625537cd5cdf7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -16,13 +16,14 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { AICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; -import { AICustomizationManagementSection, BUILTIN_STORAGE, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources, BUILTIN_STORAGE, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, ICustomizationSyncProvider, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { IAgentPluginService, type IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; -import { PromptFileSource, PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js'; +import { IAgentSource, ICustomAgent, IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; +import { basename } from '../../../../../../base/common/resources.js'; suite('AICustomizationItemsModel', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -83,6 +84,19 @@ suite('AICustomizationItemsModel', () => { instaService = workbenchInstantiationService({}, disposables); + function customAgentFromPromptPath(promptFile: IPromptPath): ICustomAgent { + return { + uri: promptFile.uri, + name: promptFile.name ?? basename(promptFile.uri), + description: promptFile.description, + target: Target.VSCode, + visibility: { agentInvocable: true, userInvocable: true }, + enabled: !disabledPromptFilesResult.has(promptFile.uri), + source: IAgentSource.fromPromptPath(promptFile), + agentInstructions: { content: '', toolReferences: [] }, + }; + } + instaService.stub(IPromptsService, { onDidChangeCustomAgents: Event.None, onDidChangeSlashCommands: Event.None, @@ -90,10 +104,12 @@ suite('AICustomizationItemsModel', () => { onDidChangeHooks: Event.None, onDidChangeInstructions: Event.None, listPromptFiles: async (type: PromptsType) => listPromptFilesResult.filter(f => f.type === type), - getCustomAgents: async () => [], + getCustomAgents: async () => listPromptFilesResult.filter(f => f.type === PromptsType.agent).map(customAgentFromPromptPath), findAgentSkills: async () => [], getHooks: async () => undefined, getInstructionFiles: async () => [], + getPromptSlashCommands: async () => [], + listAgentInstructions: async () => [], getDisabledPromptFiles: () => disabledPromptFilesResult, }); @@ -103,7 +119,7 @@ suite('AICustomizationItemsModel', () => { managementSections: [AICustomizationManagementSection.Agents], isSessionsWindow: false, welcomePageFeatures: { showGettingStartedBanner: false }, - getStorageSourceFilter: () => ({ sources: [] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.plugin] }), getSkillUIIntegrations: () => new Map(), hasOverrideProjectRoot: observableValue('test', false), commitFiles: async () => { }, @@ -212,7 +228,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/plugins/my-plugin/skills/my-skill/SKILL.md'), type: PromptsType.skill, name: 'My Skill', - storage: PromptsStorage.plugin, + source: PromptsStorage.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: true, @@ -224,10 +240,10 @@ suite('AICustomizationItemsModel', () => { assert.deepStrictEqual(items.get().map(item => ({ name: item.name, - storage: item.storage, + source: item.source, })), [{ name: 'My Skill', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, }]); }); @@ -236,7 +252,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/builtin/skills/github/SKILL.md'), type: PromptsType.skill, name: 'Built-in Skill', - storage: BUILTIN_STORAGE as unknown as PromptsStorage, + source: AICustomizationSources.builtin, extensionId: undefined, pluginUri: undefined, userInvocable: true, @@ -248,12 +264,12 @@ suite('AICustomizationItemsModel', () => { assert.deepStrictEqual(items.get().map(item => ({ name: item.name, - storage: item.storage, + source: item.source, groupKey: item.groupKey, isBuiltin: item.isBuiltin, })), [{ name: 'Built-in Skill', - storage: BUILTIN_STORAGE, + source: AICustomizationSources.builtin, groupKey: BUILTIN_STORAGE, isBuiltin: true, }]); @@ -274,6 +290,7 @@ suite('AICustomizationItemsModel', () => { enabled: true, extensionId: undefined, pluginUri: undefined, + source: AICustomizationSources.builtin, // Ignored, should be overridden by groupKey }]; const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); @@ -293,6 +310,7 @@ suite('AICustomizationItemsModel', () => { test('prompt service items preserve storage grouping, metadata, and disabled state without sync provider', async () => { availableHarnesses.set([createDescriptor('A', undefined), descriptorB], undefined); + activeSessionResource.set(URI.parse('A:///session2'), undefined); listPromptFilesResult = [{ uri: URI.parse('file:///workspace/agents/team-agent.agent.md'), storage: PromptsStorage.local, @@ -311,7 +329,7 @@ suite('AICustomizationItemsModel', () => { uri: item.uri.toString(), name: item.name, description: item.description, - storage: item.storage, + source: item.source, disabled: item.disabled, groupKey: item.groupKey, syncable: item.syncable, @@ -321,7 +339,7 @@ suite('AICustomizationItemsModel', () => { uri: 'file:///workspace/agents/team-agent.agent.md', name: 'Team Agent', description: 'Workspace agent description', - storage: PromptsStorage.local, + source: AICustomizationSources.local, disabled: true, groupKey: undefined, syncable: undefined, @@ -329,78 +347,13 @@ suite('AICustomizationItemsModel', () => { }]); }); - test('prompt service items only get sync decoration when a sync provider exists', async () => { - const syncProvider: ICustomizationSyncProvider = { - onDidChange: Event.None, - isDisabled: uri => uri.toString() === 'file:///user/agents/user-agent.agent.md', - setDisabled: () => { }, - }; - availableHarnesses.set([createDescriptor('A', undefined, syncProvider), descriptorB], undefined); - listPromptFilesResult = [{ - uri: URI.parse('file:///workspace/agents/workspace-agent.agent.md'), - storage: PromptsStorage.local, - type: PromptsType.agent, - name: 'Workspace Agent', - }, { - uri: URI.parse('file:///user/agents/user-agent.agent.md'), - storage: PromptsStorage.user, - type: PromptsType.agent, - name: 'User Agent', - }, { - uri: URI.parse('file:///plugins/helper/agents/plugin-agent.agent.md'), - storage: PromptsStorage.plugin, - type: PromptsType.agent, - name: 'Plugin Agent', - pluginUri: URI.parse('file:///plugins/helper'), - source: PromptFileSource.Plugin, - }]; - - const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); - const items = model.getItems(AICustomizationManagementSection.Agents); - await model.whenSectionLoaded(AICustomizationManagementSection.Agents); - - assert.deepStrictEqual(items.get().map(item => ({ - id: item.id, - name: item.name, - storage: item.storage, - syncable: item.syncable, - synced: item.synced, - groupKey: item.groupKey, - disabled: item.disabled, - })), [{ - id: 'sync-file:///user/agents/user-agent.agent.md', - name: 'User Agent', - storage: PromptsStorage.user, - syncable: true, - synced: false, - groupKey: undefined, - disabled: false, - }, { - id: 'sync-file:///workspace/agents/workspace-agent.agent.md', - name: 'Workspace Agent', - storage: PromptsStorage.local, - syncable: true, - synced: true, - groupKey: undefined, - disabled: false, - }, { - id: 'file:///plugins/helper/agents/plugin-agent.agent.md', - name: 'Plugin Agent', - storage: PromptsStorage.plugin, - syncable: undefined, - synced: undefined, - groupKey: undefined, - disabled: false, - }]); - }); - test('plugin count includes provider-supplied plugin items', async () => { providerA_items = [ { uri: URI.parse('agent-host://test-authority/plugins/remote-one'), type: 'plugin', name: 'Remote One', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: undefined, @@ -409,7 +362,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/plugins/remote-two'), type: AICustomizationManagementSection.Plugins, name: 'Remote Two', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: undefined, @@ -418,7 +371,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/plugins/remote-two/skills/my-skill/SKILL.md'), type: PromptsType.skill, name: 'My Skill', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: true, @@ -427,7 +380,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/plugins/local-synced'), type: 'plugin', name: 'Local Synced', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, groupKey: 'remote-client', extensionId: undefined, pluginUri: undefined, @@ -447,7 +400,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/plugins/remote-one'), type: 'plugin', name: 'Remote One', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: undefined, @@ -475,7 +428,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/plugins/model-council'), type: 'plugin', name: 'model-council', - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: undefined, @@ -515,7 +468,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse('agent-host://test-authority/agents/coder.agent.md'), type: PromptsType.agent, name: 'Coder', - storage: PromptsStorage.user, + source: AICustomizationSources.user, extensionId: undefined, pluginUri: undefined, }]; @@ -667,7 +620,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse(`agent-host://t/plugins/${name}`), type: 'plugin', name, - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: undefined, @@ -680,7 +633,7 @@ suite('AICustomizationItemsModel', () => { uri: URI.parse(uri), type: PromptsType.skill, name, - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: true, @@ -695,7 +648,7 @@ suite('AICustomizationItemsModel', () => { // Hooks pre-expanded items are kept under `plugin` storage; using // plugin storage uniformly avoids the file-system expansion path // in tests for non-hook types as well. - storage: PromptsStorage.plugin, + source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 0bc738e948b3c8..7cdb56ebe8baf9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -16,7 +16,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { AICustomizationListWidget } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; @@ -195,7 +195,7 @@ suite('aiCustomizationListWidget', () => { managementSections: [AICustomizationManagementSection.Agents], isSessionsWindow: false, welcomePageFeatures: { showGettingStartedBanner: false }, - getStorageSourceFilter: () => ({ sources: [] }), + getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), getSkillUIIntegrations: () => new Map(), hasOverrideProjectRoot: observableValue('test', false), commitFiles: async () => { }, @@ -213,7 +213,7 @@ suite('aiCustomizationListWidget', () => { activeHarness, availableHarnesses: observableValue('test', [descriptor]), setActiveSession: () => { }, - getStorageSourceFilter: () => ({ sources: [] }), + getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), getActiveDescriptor: () => descriptor, findHarnessById: (id) => id === descriptor.id ? descriptor : undefined, registerExternalHarness: () => ({ dispose() { } }), 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 11ef981abb1eba..22b7496bea6947 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 @@ -10,17 +10,17 @@ import type { IManagedHover } from '../../../../../../base/browser/ui/hover/hove import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AICustomizationManagementEditor } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; -import { BUILTIN_STORAGE } from '../../../browser/aiCustomization/aiCustomizationManagement.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IHeaderAttribute } from '../../../common/promptSyntax/promptFileParser.js'; import { PromptsType, Target } from '../../../common/promptSyntax/promptTypes.js'; +import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; suite('aiCustomizationManagementEditor', () => { ensureNoDisposablesAreLeakedInTestSuite(); type TestableEditor = { currentEditingPromptType: PromptsType | undefined; - currentEditingStorage: string | undefined; + currentEditingSource: string | undefined; currentEditingReadOnly: boolean; editorDisplayMode: 'preview' | 'raw'; editorPreviewFrontMatterContainer: HTMLElement | undefined; @@ -51,7 +51,7 @@ suite('aiCustomizationManagementEditor', () => { function createTestEditor(hoverService?: IHoverService, configurationService?: IConfigurationService): TestableEditor { const editor = Object.create(AICustomizationManagementEditor.prototype) as unknown as TestableEditor; editor.currentEditingPromptType = undefined; - editor.currentEditingStorage = undefined; + editor.currentEditingSource = undefined; editor.currentEditingReadOnly = false; editor.editorDisplayMode = 'preview'; editor.editorPreviewFrontMatterContainer = document.createElement('div'); @@ -96,7 +96,7 @@ suite('aiCustomizationManagementEditor', () => { test('uses edit copy for built-in skills that support raw overrides', () => { const editor = createTestEditor(); editor.currentEditingPromptType = PromptsType.skill; - editor.currentEditingStorage = BUILTIN_STORAGE; + editor.currentEditingSource = AICustomizationSources.builtin; editor.currentEditingReadOnly = true; editor.editorDisplayMode = 'preview'; @@ -109,7 +109,7 @@ suite('aiCustomizationManagementEditor', () => { test('uses view-raw copy for true read-only extension content', () => { const editor = createTestEditor(); editor.currentEditingPromptType = PromptsType.agent; - editor.currentEditingStorage = 'extension'; + editor.currentEditingSource = AICustomizationSources.extension; editor.currentEditingReadOnly = true; editor.editorDisplayMode = 'preview'; @@ -155,7 +155,7 @@ suite('aiCustomizationManagementEditor', () => { [ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled]: false, })); editor.currentEditingPromptType = PromptsType.agent; - editor.currentEditingStorage = BUILTIN_STORAGE; + editor.currentEditingSource = AICustomizationSources.builtin; editor.currentEditingReadOnly = false; editor.editorDisplayMode = 'preview'; diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts index 55f7cb956f7783..8e5752e4692257 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts @@ -6,11 +6,10 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { applyStorageSourceFilter, BUILTIN_STORAGE, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationSource, AICustomizationSources, applySourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; -function item(path: string, storage: PromptsStorage | string): { uri: URI; storage: string } { - return { uri: URI.file(path), storage }; +function item(path: string, source: AICustomizationSource): { uri: URI; source: AICustomizationSource } { + return { uri: URI.file(path), source }; } suite('applyStorageSourceFilter', () => { @@ -19,73 +18,73 @@ suite('applyStorageSourceFilter', () => { suite('source filtering', () => { test('keeps items matching sources', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/u/b.md', PromptsStorage.user), - item('/e/c.md', PromptsStorage.extension), + item('/w/a.md', AICustomizationSources.local), + item('/u/b.md', AICustomizationSources.user), + item('/e/c.md', AICustomizationSources.extension), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], + sources: [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.extension], }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 3); + assert.strictEqual(applySourceFilter(items, filter).length, 3); }); test('removes items not in sources', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/u/b.md', PromptsStorage.user), - item('/e/c.md', PromptsStorage.extension), - item('/p/d.md', PromptsStorage.plugin), + item('/w/a.md', AICustomizationSources.local), + item('/u/b.md', AICustomizationSources.user), + item('/e/c.md', AICustomizationSources.extension), + item('/p/d.md', AICustomizationSources.plugin), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local], + sources: [AICustomizationSources.local], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].uri.toString(), URI.file('/w/a.md').toString()); }); test('empty sources removes everything', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/u/b.md', PromptsStorage.user), + item('/w/a.md', AICustomizationSources.local), + item('/u/b.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { sources: [] }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 0); + assert.strictEqual(applySourceFilter(items, filter).length, 0); }); test('empty items returns empty', () => { const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user], + sources: [AICustomizationSources.local, AICustomizationSources.user], }; - assert.strictEqual(applyStorageSourceFilter([], filter).length, 0); + assert.strictEqual(applySourceFilter([], filter).length, 0); }); }); suite('includedUserFileRoots filtering', () => { test('undefined includedUserFileRoots keeps all user files', () => { const items = [ - item('/home/.copilot/a.md', PromptsStorage.user), - item('/home/.vscode/b.md', PromptsStorage.user), - item('/home/.claude/c.md', PromptsStorage.user), + item('/home/.copilot/a.md', AICustomizationSources.user), + item('/home/.vscode/b.md', AICustomizationSources.user), + item('/home/.claude/c.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.user], + sources: [AICustomizationSources.user], // includedUserFileRoots not set = allow all }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 3); + assert.strictEqual(applySourceFilter(items, filter).length, 3); }); test('includedUserFileRoots filters user files by root', () => { const items = [ - item('/home/.copilot/instructions/a.md', PromptsStorage.user), - item('/home/.vscode/instructions/b.md', PromptsStorage.user), - item('/home/.claude/rules/c.md', PromptsStorage.user), + item('/home/.copilot/instructions/a.md', AICustomizationSources.user), + item('/home/.vscode/instructions/b.md', AICustomizationSources.user), + item('/home/.claude/rules/c.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.user], + sources: [AICustomizationSources.user], includedUserFileRoots: [URI.file('/home/.copilot'), URI.file('/home/.claude')], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 2); assert.strictEqual(result[0].uri.toString(), URI.file('/home/.copilot/instructions/a.md').toString()); assert.strictEqual(result[1].uri.toString(), URI.file('/home/.claude/rules/c.md').toString()); @@ -93,81 +92,81 @@ suite('applyStorageSourceFilter', () => { test('includedUserFileRoots does not affect non-user items', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/e/b.md', PromptsStorage.extension), - item('/home/.copilot/c.md', PromptsStorage.user), + item('/w/a.md', AICustomizationSources.local), + item('/e/b.md', AICustomizationSources.extension), + item('/home/.copilot/c.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.extension, PromptsStorage.user], + sources: [AICustomizationSources.local, AICustomizationSources.extension, AICustomizationSources.user], includedUserFileRoots: [URI.file('/home/.copilot')], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); // local + extension kept (not affected by user root filter), user kept (matches root) assert.strictEqual(result.length, 3); }); test('empty includedUserFileRoots removes all user files', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/home/.copilot/b.md', PromptsStorage.user), + item('/w/a.md', AICustomizationSources.local), + item('/home/.copilot/b.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user], + sources: [AICustomizationSources.local, AICustomizationSources.user], includedUserFileRoots: [], // explicit empty = no user files allowed }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].storage, PromptsStorage.local); + assert.strictEqual(result[0].source, AICustomizationSources.local); }); test('user file at exact root is included', () => { const items = [ - item('/home/.copilot', PromptsStorage.user), + item('/home/.copilot', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.user], + sources: [AICustomizationSources.user], includedUserFileRoots: [URI.file('/home/.copilot')], }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 1); + assert.strictEqual(applySourceFilter(items, filter).length, 1); }); test('user file outside all roots is excluded', () => { const items = [ - item('/other/path/a.md', PromptsStorage.user), + item('/other/path/a.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.user], + sources: [AICustomizationSources.user], includedUserFileRoots: [URI.file('/home/.copilot'), URI.file('/home/.claude')], }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 0); + assert.strictEqual(applySourceFilter(items, filter).length, 0); }); test('deeply nested user file under root is included', () => { const items = [ - item('/home/.copilot/instructions/sub/deep/a.md', PromptsStorage.user), + item('/home/.copilot/instructions/sub/deep/a.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.user], + sources: [AICustomizationSources.user], includedUserFileRoots: [URI.file('/home/.copilot')], }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 1); + assert.strictEqual(applySourceFilter(items, filter).length, 1); }); }); suite('combined filtering', () => { test('source filter + user root filter applied together', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/home/.copilot/b.md', PromptsStorage.user), - item('/home/.vscode/c.md', PromptsStorage.user), - item('/e/d.md', PromptsStorage.extension), - item('/p/e.md', PromptsStorage.plugin), + item('/w/a.md', AICustomizationSources.local), + item('/home/.copilot/b.md', AICustomizationSources.user), + item('/home/.vscode/c.md', AICustomizationSources.user), + item('/e/d.md', AICustomizationSources.extension), + item('/p/e.md', AICustomizationSources.plugin), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user], + sources: [AICustomizationSources.local, AICustomizationSources.user], includedUserFileRoots: [URI.file('/home/.copilot')], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); // local (kept), .copilot user (kept), .vscode user (excluded by root), // extension (excluded by source), plugin (excluded by source) assert.strictEqual(result.length, 2); @@ -175,93 +174,93 @@ suite('applyStorageSourceFilter', () => { test('sessions-like filter: hooks show only local', () => { const items = [ - item('/w/.github/hooks/pre.json', PromptsStorage.local), - item('/home/.claude/settings.json', PromptsStorage.user), + item('/w/.github/hooks/pre.json', AICustomizationSources.local), + item('/home/.claude/settings.json', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local], + sources: [AICustomizationSources.local], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].storage, PromptsStorage.local); + assert.strictEqual(result[0].source, AICustomizationSources.local); }); test('sessions-like filter: instructions show only CLI roots', () => { const items = [ - item('/w/.github/instructions/a.md', PromptsStorage.local), - item('/home/.copilot/instructions/b.md', PromptsStorage.user), - item('/home/.claude/rules/c.md', PromptsStorage.user), - item('/home/.vscode-profile/instructions/d.md', PromptsStorage.user), + item('/w/.github/instructions/a.md', AICustomizationSources.local), + item('/home/.copilot/instructions/b.md', AICustomizationSources.user), + item('/home/.claude/rules/c.md', AICustomizationSources.user), + item('/home/.vscode-profile/instructions/d.md', AICustomizationSources.user), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user], + sources: [AICustomizationSources.local, AICustomizationSources.user], includedUserFileRoots: [ URI.file('/home/.copilot'), URI.file('/home/.claude'), URI.file('/home/.agents'), ], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); // local + .copilot + .claude pass; .vscode-profile excluded assert.strictEqual(result.length, 3); }); test('core-like filter: show everything', () => { const items = [ - item('/w/a.md', PromptsStorage.local), - item('/u/b.md', PromptsStorage.user), - item('/e/c.md', PromptsStorage.extension), - item('/p/d.md', PromptsStorage.plugin), + item('/w/a.md', AICustomizationSources.local), + item('/u/b.md', AICustomizationSources.user), + item('/e/c.md', AICustomizationSources.extension), + item('/p/d.md', AICustomizationSources.plugin), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin], + sources: [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.extension, AICustomizationSources.plugin], }; - assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4); + assert.strictEqual(applySourceFilter(items, filter).length, 4); }); test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => { // Items from the chat extension have storage=extension but groupKey=builtin. // The filter operates on storage, so extension items pass through regardless of groupKey. const items = [ - item('/w/a.md', PromptsStorage.local), - item('/e/builtin-agent.md', PromptsStorage.extension), - item('/e/third-party.md', PromptsStorage.extension), - item('/b/sessions-builtin.md', BUILTIN_STORAGE), + item('/w/a.md', AICustomizationSources.local), + item('/e/builtin-agent.md', AICustomizationSources.extension), + item('/e/third-party.md', AICustomizationSources.extension), + item('/b/sessions-builtin.md', AICustomizationSources.builtin), ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.extension, BUILTIN_STORAGE], + sources: [AICustomizationSources.local, AICustomizationSources.extension, AICustomizationSources.builtin], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 4); }); test('builtin source is respected independently', () => { const items = [ - item('/e/from-extension.md', PromptsStorage.extension), - item('/b/from-sessions.md', BUILTIN_STORAGE), + item('/e/from-extension.md', AICustomizationSources.extension), + item('/b/from-sessions.md', AICustomizationSources.builtin), ]; // Only builtin in sources — extension items excluded const filter: IStorageSourceFilter = { - sources: [BUILTIN_STORAGE], + sources: [AICustomizationSources.builtin], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].storage, BUILTIN_STORAGE); + assert.strictEqual(result[0].source, AICustomizationSources.builtin); }); }); suite('type safety', () => { test('works with objects that have extra properties', () => { const items = [ - { uri: URI.file('/w/a.md'), storage: PromptsStorage.local, name: 'A', extra: true }, - { uri: URI.file('/u/b.md'), storage: PromptsStorage.user, name: 'B', extra: false }, + { uri: URI.file('/w/a.md'), source: AICustomizationSources.local, name: 'A', extra: true }, + { uri: URI.file('/u/b.md'), source: AICustomizationSources.user, name: 'B', extra: false }, ]; const filter: IStorageSourceFilter = { - sources: [PromptsStorage.local], + sources: [AICustomizationSources.local], }; - const result = applyStorageSourceFilter(items, filter); + const result = applySourceFilter(items, filter); assert.strictEqual(result.length, 1); - assert.strictEqual((result[0] as typeof items[0]).name, 'A'); + assert.strictEqual(result[0].name, 'A'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 4e2c1d452a7215..f59c61ee318fc6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -131,6 +131,18 @@ class MockLanguageModelsService implements ILanguageModelsService { async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { } + async renameLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { + } + + async updateLanguageModelsProviderGroupApiKey(vendorId: string, providerGroupName: string): Promise { + } + + async addLanguageModelsProviderGroupModel(vendorId: string, providerGroupName: string): Promise { + } + + async openLanguageModelsProviderGroupSettings(vendorId: string, providerGroupName: string): Promise { + } + async configureModel(_modelId: string): Promise { } diff --git a/src/vs/workbench/contrib/chat/test/browser/defaultModelContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/defaultModelContribution.test.ts index 3873b783a782ea..e5d3b0c27cdb3e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/defaultModelContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/defaultModelContribution.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { createDefaultModelArrays, DefaultModelArrays, DefaultModelContribution } from '../../browser/defaultModelContribution.js'; +import { UtilityModelContribution, UtilitySmallModelContribution } from '../../browser/utilityModelContribution.js'; import { ILanguageModelChatMetadata, ILanguageModelProviderDescriptor, ILanguageModelsService } from '../../common/languageModels.js'; class TestLanguageModelsService implements Partial { @@ -187,4 +188,24 @@ suite('DefaultModelContribution', () => { }, ); }); + + test('utility model settings exclude Copilot vendor models', () => { + const service = new TestLanguageModelsService(); + store.add({ dispose: () => service.dispose() }); + service.addVendor({ vendor: 'copilot', displayName: 'Copilot', isDefault: true, configuration: undefined, managementCommand: undefined, when: undefined }); + service.addVendor({ vendor: 'anthropic', displayName: 'Anthropic', isDefault: false, configuration: undefined, managementCommand: undefined, when: undefined }); + service.addModel(makeMetadata({ id: 'gpt-4o-mini', name: 'GPT 4o mini', vendor: 'copilot' })); + service.addModel(makeMetadata({ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', vendor: 'anthropic' })); + + store.add(new UtilityModelContribution(service as unknown as ILanguageModelsService, new NullLogService())); + store.add(new UtilitySmallModelContribution(service as unknown as ILanguageModelsService, new NullLogService())); + + assert.deepStrictEqual({ + utility: { ids: UtilityModelContribution.modelIds, labels: UtilityModelContribution.modelLabels }, + utilitySmall: { ids: UtilitySmallModelContribution.modelIds, labels: UtilitySmallModelContribution.modelLabels }, + }, { + utility: { ids: ['', 'anthropic/claude-haiku-4.5'], labels: ['Default', 'Claude Haiku 4.5 (Anthropic)'] }, + utilitySmall: { ids: ['', 'anthropic/claude-haiku-4.5'], labels: ['Default', 'Claude Haiku 4.5 (Anthropic)'] }, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts index 0d1a184a8e4da6..3a62f0ec1ef624 100644 --- a/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/languageModelsConfiguration.test.ts @@ -148,4 +148,44 @@ suite('LanguageModelsConfiguration', () => { assert.strictEqual(g2.range.startLineNumber, 7); assert.strictEqual(g2.range.endLineNumber, 11); }); + + test('parseLanguageModelsConfiguration - models range', () => { + const content = `[ + { + "vendor": "vendor", + "name": "group", + "models": [ + { "id": "one" }, + { "id": "two" } + ] + } +]`; + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + assert.deepStrictEqual({ + startLineNumber: result[0].modelsRange?.startLineNumber, + endLineNumber: result[0].modelsRange?.endLineNumber + }, { + startLineNumber: 5, + endLineNumber: 8 + }); + }); + + test('parseLanguageModelsConfiguration - empty models range', () => { + const content = JSON.stringify([{ + vendor: 'vendor', + name: 'group', + models: [] + }], null, '\t'); + const model = testDisposables.add(createTextModel(content)); + const result = parseLanguageModelsProviderGroups(model); + + assert.deepStrictEqual(result[0].modelsRange, { + startLineNumber: 5, + startColumn: 13, + endLineNumber: 5, + endColumn: 15 + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index 3364c101849e0a..b74f87d4e1e62f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -239,6 +239,9 @@ suite('PluginInstallService', () => { instantiationService.stub(IAgentPluginRepositoryService, { getPluginInstallUri: (plugin: IMarketplacePlugin) => { + if (plugin.sourceDescriptor.kind !== PluginSourceKind.RelativePath) { + return state.pluginSourceInstallUris.get(plugin.sourceDescriptor.kind) ?? URI.file(`/cache/agentPlugins/${plugin.sourceDescriptor.kind}/default`); + } return URI.joinPath(state.ensureRepositoryResult, plugin.source); }, getRepositoryUri: () => state.ensureRepositoryResult, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 6dd8a6d73e9b40..d0649e4c4f1ca9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -13,7 +13,7 @@ import { mainWindow } from '../../../../../../../base/browser/window.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ChatThinkingContentPart } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; +import { ChatThinkingContentPart, maybePickFunWorkingMessage } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; import { IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; @@ -22,7 +22,7 @@ import { IChatMarkdownAnchorService } from '../../../../browser/widget/chatConte import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IRenderedMarkdown, MarkdownRenderOptions } from '../../../../../../../base/browser/markdownRenderer.js'; import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; -import { ThinkingDisplayMode } from '../../../../common/constants.js'; +import { ChatConfiguration, ThinkingDisplayMode } from '../../../../common/constants.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; @@ -132,6 +132,15 @@ suite('ChatThinkingContentPart', () => { disposables.dispose(); }); + test('replace thinking phrases suppresses fun default phrases', () => { + mockConfigurationService.setUserConfiguration(ChatConfiguration.ThinkingPhrases, { + mode: 'replace', + phrases: ['Custom phrase'], + }); + + assert.strictEqual(maybePickFunWorkingMessage(mockConfigurationService, () => 0), undefined); + }); + suite('ThinkingDisplayMode.Collapsed', () => { setup(() => { mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index 2eb455c9f16635..52eff5555b0b2b 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -8,12 +8,13 @@ import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath, ICustomizationItem } from '../../common/customizationHarnessService.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { SessionType } from '../../common/chatSessionsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; +import { AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; suite('CustomizationHarnessService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -46,7 +47,7 @@ suite('CustomizationHarnessService', () => { id: harnessId, label: 'Test Harness', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -72,7 +73,7 @@ suite('CustomizationHarnessService', () => { id: harnessId, label: 'Test Harness', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -99,7 +100,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -121,7 +122,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -143,7 +144,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -167,7 +168,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -212,7 +213,7 @@ suite('CustomizationHarnessService', () => { const emitter = new Emitter(); store.add(emitter); const testItems = [ - { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', source: 'local', extensionId: undefined, pluginUri: undefined, userInvocable: undefined } satisfies ICustomizationItem, ]; const itemProvider: ICustomizationItemProvider = { @@ -224,7 +225,7 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider, }; const activeSessionResource = URI.parse('test-ext://session'); @@ -250,7 +251,7 @@ suite('CustomizationHarnessService', () => { icon: ThemeIcon.fromId('extensions'), hiddenSections: ['agents', 'prompts'], workspaceSubpaths: ['.test-ext'], - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -271,10 +272,10 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( - createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createVSCodeHarnessDescriptor([AICustomizationSources.extension]), staticDescriptor, ); assert.strictEqual(service.availableHarnesses.get().length, 2); @@ -285,7 +286,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -306,10 +307,10 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( - createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createVSCodeHarnessDescriptor([AICustomizationSources.extension]), staticDescriptor, ); @@ -319,7 +320,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -340,10 +341,10 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( - createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createVSCodeHarnessDescriptor([AICustomizationSources.extension]), staticDescriptor, ); @@ -353,7 +354,7 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -385,14 +386,14 @@ suite('CustomizationHarnessService', () => { id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ - { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, - { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, - { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, - { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, source: 'local', name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, source: 'local', name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, source: 'local', name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, source: 'local', name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, ], }, }); @@ -476,12 +477,12 @@ suite('CustomizationHarnessService', () => { id: testSessionType1, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ - { uri: URI.parse('file:///workspace/.test/agents/enabled.agent.md'), type: PromptsType.agent, name: 'enabled', enabled: true, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, - { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/enabled.agent.md'), type: PromptsType.agent, source: 'local', name: 'enabled', enabled: true, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, source: 'local', name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, ], }, }], testSessionType1, promptsService); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 338b3f2bcec91f..df2002e350666b 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -17,8 +17,8 @@ import { TestStorageService } from '../../../../test/common/workbenchTestService import { Emitter, Event } from '../../../../../base/common/event.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; -import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ConfigureLanguageModelsOptions, ILanguageModelsConfigurationService, ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; +import { IInputBox, IQuickInputHideEvent, IQuickInputService, QuickInputHideReason } from '../../../../../platform/quickinput/common/quickInput.js'; import { TestSecretStorageService } from '../../../../../platform/secrets/test/common/testSecretStorageService.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IRequestService } from '../../../../../platform/request/common/request.js'; @@ -1075,6 +1075,224 @@ suite('LanguageModels - Per-Model Configuration', function () { }); }); +suite('LanguageModels - Provider Group Management', function () { + + class TestInputBox extends mock() { + private readonly onDidChangeValueEmitter = new Emitter(); + private readonly onDidAcceptEmitter = new Emitter(); + private readonly onDidHideEmitter = new Emitter(); + + override readonly onDidChangeValue = this.onDidChangeValueEmitter.event; + override readonly onDidAccept = this.onDidAcceptEmitter.event; + override readonly onDidHide = this.onDidHideEmitter.event; + + override value = ''; + + constructor(private readonly valueToAccept: string) { + super(); + } + + override show(): void { + this.value = this.valueToAccept; + this.onDidChangeValueEmitter.fire(this.value); + this.onDidAcceptEmitter.fire(); + } + + override hide(): void { + this.onDidHideEmitter.fire({ reason: QuickInputHideReason.Other }); + } + + override dispose(): void { + this.onDidChangeValueEmitter.dispose(); + this.onDidAcceptEmitter.dispose(); + this.onDidHideEmitter.dispose(); + } + } + + let languageModelsService: LanguageModelsService; + let providerGroups: ILanguageModelsProviderGroup[]; + let updateCalls: { from: ILanguageModelsProviderGroup; to: ILanguageModelsProviderGroup }[]; + let configureCalls: (ConfigureLanguageModelsOptions | undefined)[]; + let acceptedInputValues: string[]; + let secretStorageService: TestSecretStorageService; + + setup(function () { + providerGroups = [{ + vendor: 'custom-vendor', + name: 'Custom Group', + apiKey: '${input:existing-secret}', + settings: { model: { temperature: 0.7 } } + }]; + updateCalls = []; + configureCalls = []; + acceptedInputValues = []; + secretStorageService = new TestSecretStorageService(); + + languageModelsService = new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + new TestStorageService(), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return providerGroups; + } + override async updateLanguageModelsProviderGroup(from: ILanguageModelsProviderGroup, to: ILanguageModelsProviderGroup): Promise { + updateCalls.push({ from, to }); + providerGroups = providerGroups.map(group => group === from ? to : group); + return to; + } + override async configureLanguageModels(options?: ConfigureLanguageModelsOptions): Promise { + configureCalls.push(options); + } + }, + new class extends mock() { + override createInputBox(): IInputBox { + const value = acceptedInputValues.shift(); + if (value === undefined) { + throw new Error('Missing scripted quick input value.'); + } + return new TestInputBox(value); + } + }, + secretStorageService, + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + ); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + { + vendor: 'custom-vendor', + displayName: 'Custom Vendor', + // Cast needed: TypeFromJsonSchema resolves the `anyOf`+`$ref` configuration + // field to `undefined`, but this provider-management test needs the + // runtime schema so the vendor is treated as configurable. + configuration: { + type: 'object', + required: ['apiKey'], + properties: { + apiKey: { type: 'string', secret: true }, + models: { + type: 'array', + defaultSnippets: [{ body: [{ id: '$1' }] }] + } + } + } as unknown as undefined, + managementCommand: undefined, + when: undefined + } + ], []); + }); + + teardown(function () { + languageModelsService.dispose(); + secretStorageService.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('renameLanguageModelsProviderGroup updates only the selected group name', async function () { + acceptedInputValues.push('Renamed Group'); + + await languageModelsService.renameLanguageModelsProviderGroup('custom-vendor', 'Custom Group'); + + assert.deepStrictEqual(updateCalls, [{ + from: { + vendor: 'custom-vendor', + name: 'Custom Group', + apiKey: '${input:existing-secret}', + settings: { model: { temperature: 0.7 } } + }, + to: { + vendor: 'custom-vendor', + name: 'Renamed Group', + apiKey: '${input:existing-secret}', + settings: { model: { temperature: 0.7 } } + } + }]); + }); + + test('updateLanguageModelsProviderGroupApiKey stores the new secret and preserves model settings', async function () { + acceptedInputValues.push('new-api-key'); + await secretStorageService.set('existing-secret', 'old-api-key'); + + await languageModelsService.updateLanguageModelsProviderGroupApiKey('custom-vendor', 'Custom Group'); + + const updatedGroup = updateCalls[0]?.to; + const encodedApiKey = typeof updatedGroup?.apiKey === 'string' ? updatedGroup.apiKey : ''; + const secretKey = encodedApiKey.substring('${input:'.length, encodedApiKey.length - 1); + assert.deepStrictEqual({ + encodedApiKeyUsesSecretStorage: encodedApiKey.startsWith('${input:chat.lm.secret.'), + newSecretValue: await secretStorageService.get(secretKey), + oldSecretValue: await secretStorageService.get('existing-secret'), + settings: updatedGroup?.settings, + identity: { name: updatedGroup?.name, vendor: updatedGroup?.vendor } + }, { + encodedApiKeyUsesSecretStorage: true, + newSecretValue: 'new-api-key', + oldSecretValue: undefined, + settings: { model: { temperature: 0.7 } }, + identity: { name: 'Custom Group', vendor: 'custom-vendor' } + }); + }); + + test('updateLanguageModelsProviderGroupApiKey leaves the existing secret unchanged when the value is unchanged', async function () { + acceptedInputValues.push('old-api-key'); + await secretStorageService.set('existing-secret', 'old-api-key'); + + await languageModelsService.updateLanguageModelsProviderGroupApiKey('custom-vendor', 'Custom Group'); + + assert.deepStrictEqual({ + updateCalls, + secretKeys: await secretStorageService.keys(), + secretValue: await secretStorageService.get('existing-secret') + }, { + updateCalls: [], + secretKeys: ['existing-secret'], + secretValue: 'old-api-key' + }); + }); + + test('addLanguageModelsProviderGroupModel inserts a models property when the group does not have one', async function () { + await languageModelsService.addLanguageModelsProviderGroupModel('custom-vendor', 'Custom Group'); + + assert.deepStrictEqual(configureCalls, [{ + group: providerGroups[0], + snippet: `"models": [ + { + "id": "$1" + } +]`, + snippetTarget: 'group' + }]); + }); + + test('addLanguageModelsProviderGroupModel inserts a model item when the group already has models', async function () { + providerGroups = [{ ...providerGroups[0], models: [{ id: 'existing' }] }]; + + await languageModelsService.addLanguageModelsProviderGroupModel('custom-vendor', 'Custom Group'); + + assert.deepStrictEqual(configureCalls, [{ + group: providerGroups[0], + snippet: `{ + "id": "$1" +}`, + snippetTarget: 'models' + }]); + }); + + test('openLanguageModelsProviderGroupSettings opens the selected provider group', async function () { + await languageModelsService.openLanguageModelsProviderGroupSettings('custom-vendor', 'Custom Group'); + + assert.deepStrictEqual(configureCalls, [{ group: providerGroups[0] }]); + }); +}); + suite('LanguageModels - Provider Group Detail Fallback', function () { const disposables = new DisposableStore(); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index e8e8025f9c8e83..dac0c9c6c6b448 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -92,6 +92,18 @@ export class NullLanguageModelsService implements ILanguageModelsService { } + async renameLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { + } + + async updateLanguageModelsProviderGroupApiKey(vendorId: string, providerGroupName: string): Promise { + } + + async addLanguageModelsProviderGroupModel(vendorId: string, providerGroupName: string): Promise { + } + + async openLanguageModelsProviderGroupSettings(vendorId: string, providerGroupName: string): Promise { + } + async configureModel(_modelId: string): Promise { } diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts index 30e966f9f69ef8..44282db9dc8c38 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/fileBackedInstalledPluginsStore.test.ts @@ -133,7 +133,8 @@ suite('FileBackedInstalledPluginsStore', () => { assert.strictEqual(parsed.version, 1); assert.strictEqual(parsed.installed.length, 1); assert.ok(parsed.installed[0].pluginUri.includes('/home/user/.vscode/agent-plugins/github.com/microsoft/plugins/plugins/my-plugin')); - // plugin metadata is NOT stored in the file + assert.strictEqual(parsed.installed[0].name, 'my-plugin'); + // Full plugin metadata is NOT stored in the file. assert.strictEqual(parsed.installed[0].plugin, undefined); assert.strictEqual(pluginsStore.get().length, 1); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index dfaa86a63b88dd..fe3f9540822b9f 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -4,22 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { Event } from '../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IFileService, IFileSystemWatcher } from '../../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IRequestService } from '../../../../../../platform/request/common/request.js'; -import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; import { IWorkspacePluginSettingsService } from '../../../common/plugins/workspacePluginSettingsService.js'; suite('PluginMarketplaceService', () => { @@ -357,6 +359,230 @@ suite('PluginMarketplaceService - installed plugins lifecycle', () => { }); }); +suite('PluginMarketplaceService - hydration after restart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const CACHE_ROOT = URI.file('/agent-plugins'); + + class TestFileService { + readonly files = new Map(); + readonly folders = new Set(); + + async exists(resource: URI): Promise { + const key = resource.toString(); + return this.files.has(key) || this.folders.has(key); + } + + async readFile(resource: URI): Promise<{ value: VSBuffer }> { + const key = resource.toString(); + const value = this.files.get(key); + if (value === undefined) { + throw new Error(`Missing file: ${key}`); + } + return { value: VSBuffer.fromString(value) }; + } + + async writeFile(resource: URI, content: VSBuffer): Promise { + this.files.set(resource.toString(), content.toString()); + return {}; + } + + async createFolder(resource: URI): Promise { + this.folders.add(resource.toString()); + return {}; + } + + createWatcher(): IFileSystemWatcher { + return { onDidChange: Event.None, dispose: () => { } }; + } + + setFile(resource: URI, content: string): void { + this.files.set(resource.toString(), content); + } + } + + function createPluginRepositoryStub(): IAgentPluginRepositoryService { + const getRepositoryUri = (marketplace: IMarketplaceReference) => URI.joinPath(CACHE_ROOT, ...marketplace.cacheSegments); + const getPluginSourceInstallUri = (descriptor: IPluginSourceDescriptor) => { + if (descriptor.kind === PluginSourceKind.GitHub) { + const [owner, repo] = descriptor.repo.split('/'); + const base = URI.joinPath(CACHE_ROOT, 'github.com', owner, repo); + return descriptor.path ? URI.joinPath(base, descriptor.path) : base; + } + if (descriptor.kind === PluginSourceKind.RelativePath) { + // Tests using this stub only exercise non-relative descriptors via this entry point. + throw new Error('RelativePath should not reach getPluginSourceInstallUri in hydration tests'); + } + throw new Error(`Unhandled source kind in test stub: ${descriptor.kind}`); + }; + return { + agentPluginsHome: CACHE_ROOT, + getRepositoryUri, + getPluginInstallUri: (plugin: IMarketplacePlugin) => { + if (plugin.sourceDescriptor.kind !== PluginSourceKind.RelativePath) { + return getPluginSourceInstallUri(plugin.sourceDescriptor); + } + const repoDir = getRepositoryUri(plugin.marketplaceReference); + return plugin.source ? URI.joinPath(repoDir, plugin.source) : repoDir; + }, + getPluginSourceInstallUri, + } as unknown as IAgentPluginRepositoryService; + } + + function makeAzurePlugin(marketplaceReference: IMarketplaceReference): IMarketplacePlugin { + return { + name: 'azure', + description: 'Microsoft Azure MCP Server and skills', + version: '1.0.0', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'microsoft/azure-skills', path: '.github/plugins/azure-skills' }, + marketplace: marketplaceReference.displayLabel, + marketplaceReference, + marketplaceType: MarketplaceType.Copilot, + }; + } + + function storeMarketplaceCache(storageService: InMemoryStorageService, marketplaceReference: IMarketplaceReference, plugin: IMarketplacePlugin): void { + storageService.store('chat.plugins.marketplaces.githubCache.v1', JSON.stringify({ + [marketplaceReference.canonicalId]: { + plugins: [plugin], + expiresAt: Date.now() + 60_000, + referenceRawValue: marketplaceReference.rawValue, + }, + }), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + test('hydrates a github-sourced plugin from installed.json name and marketplace cache after restart', async () => { + // Simulates: user installs the "azure" plugin from the + // "github/awesome-copilot" marketplace (fetched via HTTP, never + // cloned). After restart, installed.json contains only the durable + // identity for that plugin; the full descriptor is recovered from + // marketplace data cached from the prior fetch. + + const storageService = store.add(new InMemoryStorageService()); + const fileService = new TestFileService(); + + const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot')!; + const azurePlugin = makeAzurePlugin(awesomeCopilot); + storeMarketplaceCache(storageService, awesomeCopilot, azurePlugin); + const azurePluginUri = URI.joinPath(CACHE_ROOT, 'github.com', 'microsoft', 'azure-skills', '.github', 'plugins', 'azure-skills'); + + const installedJson = URI.joinPath(CACHE_ROOT, 'installed.json'); + fileService.setFile(installedJson, JSON.stringify({ + version: 1, + installed: [{ + pluginUri: azurePluginUri.toString(), + marketplace: awesomeCopilot.rawValue, + name: 'azure', + }], + })); + + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: ['github/awesome-copilot'], + [ChatConfiguration.PluginsEnabled]: true, + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); + instantiationService.stub(IFileService, fileService as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, createPluginRepositoryStub()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, {} as unknown as IRequestService); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + } as Partial as IWorkspacePluginSettingsService); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + } as Partial as IWorkspaceTrustManagementService); + + const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); + + // FileBackedInstalledPluginsStore initialises asynchronously. + for (let i = 0; i < 50; i++) { + if (service.installedPlugins.get().length === 1) { + break; + } + await timeout(10); + } + + const installed = service.installedPlugins.get(); + assert.strictEqual(installed.length, 1, 'azure plugin should be hydrated from marketplace data'); + assert.strictEqual(installed[0].plugin.name, 'azure'); + assert.strictEqual(installed[0].plugin.sourceDescriptor.kind, PluginSourceKind.GitHub); + assert.strictEqual(installed[0].plugin.marketplaceReference.canonicalId, awesomeCopilot.canonicalId); + }); + + test('persists plugin name when a plugin is added so it survives a restart', async () => { + // First service writes installed.json, second service (sharing the + // same file system + storage) reads it back and must reconstruct + // the plugin from its stored name plus marketplace data. + const storageService = store.add(new InMemoryStorageService()); + const fileService = new TestFileService(); + + const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot')!; + const azurePluginUri = URI.joinPath(CACHE_ROOT, 'github.com', 'microsoft', 'azure-skills', '.github', 'plugins', 'azure-skills'); + const azurePlugin = makeAzurePlugin(awesomeCopilot); + storeMarketplaceCache(storageService, awesomeCopilot, azurePlugin); + + function makeService(): PluginMarketplaceService { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: ['github/awesome-copilot'], + [ChatConfiguration.PluginsEnabled]: true, + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); + instantiationService.stub(IFileService, fileService as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, createPluginRepositoryStub()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, {} as unknown as IRequestService); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + } as Partial as IWorkspacePluginSettingsService); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + } as Partial as IWorkspaceTrustManagementService); + return store.add(instantiationService.createInstance(PluginMarketplaceService)); + } + + // First session: install the plugin. + const first = makeService(); + // Wait for FileBackedInstalledPluginsStore to finish initialisation + // so that subsequent writes are flushed to the file service. + await timeout(20); + first.addInstalledPlugin(azurePluginUri, azurePlugin); + // Wait for the throttled write to land. + await timeout(200); + + const installedJson = URI.joinPath(CACHE_ROOT, 'installed.json'); + const persisted = JSON.parse(fileService.files.get(installedJson.toString())!); + assert.strictEqual(persisted.installed.length, 1); + assert.deepStrictEqual(persisted.installed[0], { + pluginUri: azurePluginUri.toString(), + marketplace: awesomeCopilot.rawValue, + name: 'azure', + }); + + // Second session: restart with shared storage + file system. The + // plugin must be reconstructed from installed.json + marketplace data. + const second = makeService(); + for (let i = 0; i < 50; i++) { + if (second.installedPlugins.get().length === 1) { + break; + } + await timeout(10); + } + const installed = second.installedPlugins.get(); + assert.strictEqual(installed.length, 1); + assert.strictEqual(installed[0].plugin.name, 'azure'); + assert.strictEqual(installed[0].plugin.sourceDescriptor.kind, PluginSourceKind.GitHub); + }); +}); + suite('parsePluginSource', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpSamplingLog.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpSamplingLog.test.ts index 4886dbbcc08ce6..d69ad9a346fd57 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpSamplingLog.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpSamplingLog.test.ts @@ -30,7 +30,14 @@ suite('MCP - Sampling Log', () => { setup(() => { storage = ds.add(new TestStorageService()); log = ds.add(new McpSamplingLog(storage)); - clock = sinon.useFakeTimers(); + // `shouldClearNativeTimers` silently absorbs `clearTimeout` calls for + // native timer IDs scheduled outside the fake clock (e.g. by cross-test + // background schedulers) instead of emitting a `console.warn` that would + // fail the renderer's no-console-output assertion. The option exists in + // @sinonjs/fake-timers but is missing from the @types/sinon typings, so + // we widen the config type locally. + const fakeTimerOpts: Partial & { shouldClearNativeTimers: boolean } = { shouldClearNativeTimers: true }; + clock = sinon.useFakeTimers(fakeTimerOpts); clock.setSystemTime(new Date('2023-10-01T00:00:00Z').getTime()); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 58059720693b5e..275a3fdce3c2ac 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -415,7 +415,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // In async mode, signal the agent so it can drive send_to_terminal. if (this._asyncMode) { if (shouldFireInputNeeded) { - if (detectsSensitiveInputPrompt(outputLastLine)) { + if (this._isSensitivePrompt(outputLastLine)) { this._logService.trace('OutputMonitor: Async mode - sensitive input prompt detected, signaling sensitive UI'); this._onDidDetectSensitiveInputNeeded.fire(); } else { @@ -433,7 +433,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // event so the tool can show a confirmation dialog that focuses the terminal — // the secret must never be routed through the model. if (shouldFireInputNeeded) { - if (detectsSensitiveInputPrompt(outputLastLine)) { + if (this._isSensitivePrompt(outputLastLine)) { this._logService.trace('OutputMonitor: Sensitive input prompt detected, signaling sensitive UI'); this._onDidDetectSensitiveInputNeeded.fire(); } else { @@ -596,10 +596,18 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private _isSensitivePrompt(prompt: string): boolean { + if (isCanonicalSudoSPrompt(this._command, prompt)) { + return false; + } + return detectsSensitiveInputPrompt(prompt); } } +function isCanonicalSudoSPrompt(command: string, prompt: string): boolean { + return /(?:^|\s)sudo\s+-S(?:\s|$)/.test(command) && /^\[sudo\]\s+password for .+:\s*$/i.test(prompt); +} + /** * Returns true when the terminal's last visible line looks like a prompt for * a sensitive secret (password, passphrase, token, API key, OTP, etc.). Used diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 156d22169c42ff..2ea212708ba4d3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -400,6 +400,40 @@ suite('OutputMonitor', () => { }); }); + test('sudo -S password prompt fires onDidDetectInputNeeded and not onDidDetectSensitiveInputNeeded', async () => { + return runWithFakedTimers({}, async () => { + execution.getOutput = () => '[sudo] password for jdoe: '; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'ssh host && echo hunter2 | sudo -S systemctl restart myservice')); + + let inputNeededFired = false; + let sensitiveFired = false; + store.add(monitor.onDidDetectInputNeeded(() => { inputNeededFired = true; })); + store.add(monitor.onDidDetectSensitiveInputNeeded(() => { sensitiveFired = true; })); + + await Event.toPromise(monitor.onDidFinishCommand); + + assert.strictEqual(inputNeededFired, true, 'sudo -S prompts should be treated as normal input-needed so the non-interactive flow can continue'); + assert.strictEqual(sensitiveFired, false, 'sudo -S prompts should not be treated as sensitive interactive prompts'); + }); + }); + + test('plain sudo password prompt still fires onDidDetectSensitiveInputNeeded', async () => { + return runWithFakedTimers({}, async () => { + execution.getOutput = () => '[sudo] password for jdoe: '; + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), cts.token, 'sudo systemctl restart myservice')); + + let inputNeededFired = false; + let sensitiveFired = false; + store.add(monitor.onDidDetectInputNeeded(() => { inputNeededFired = true; })); + store.add(monitor.onDidDetectSensitiveInputNeeded(() => { sensitiveFired = true; })); + + await Event.toPromise(monitor.onDidFinishCommand); + + assert.strictEqual(sensitiveFired, true, 'interactive sudo prompts should still be treated as sensitive'); + assert.strictEqual(inputNeededFired, false, 'interactive sudo prompts must not be routed to the agent'); + }); + }); + test('detectsSensitiveInputPrompt matches common secret prompts', () => { assert.strictEqual(detectsSensitiveInputPrompt('Password: '), true); assert.strictEqual(detectsSensitiveInputPrompt('[sudo] password for jdoe: '), true); diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.ts index 5024c179cf5cfe..729f91f8610019 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.ts @@ -43,6 +43,8 @@ export const terminalStickyScrollConfiguration: IStringDictionary | undefined; private _userSignedIn = false; private selectedAiMode: AiCollaborationMode = AiCollaborationMode.Balanced; + private enterpriseSignInUiState: EnterpriseSignInUiState = 'options'; + private enterpriseInstanceValue = ''; + private enterpriseSignInWatch: StopWatch | undefined; constructor( @ILayoutService private readonly layoutService: ILayoutService, @@ -132,7 +142,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IConfigurationService private readonly configurationService: IConfigurationService, @INotificationService private readonly notificationService: INotificationService, - @IQuickInputService private readonly quickInputService: IQuickInputService, @IFileService private readonly fileService: IFileService, @IPathService private readonly pathService: IPathService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -219,6 +228,13 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._dismiss('skip'); })); this.disposables.add(addDisposableListener(this.backButton, EventType.CLICK, () => { + if (this.currentStepIndex === 0 && this.enterpriseSignInUiState === 'instance') { + this._logAction('cancelEnterpriseInstancePrompt'); + this.enterpriseSignInWatch = undefined; + this._setEnterpriseSignInUiState('options'); + return; + } + this._logAction('back'); this._prevStep(); })); @@ -298,6 +314,11 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi private _nextStep(): void { if (this.currentStepIndex < this.steps.length - 1) { const leavingStep = this.steps[this.currentStepIndex]; + if (leavingStep === OnboardingStepId.SignIn) { + this.enterpriseSignInUiState = 'options'; + this.enterpriseInstanceValue = ''; + this.enterpriseSignInWatch = undefined; + } if (leavingStep === OnboardingStepId.Personalize) { this._applyKeymap(this.selectedKeymapId); } @@ -399,13 +420,19 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi private _updateButtonStates(): void { if (this.backButton) { - this.backButton.style.display = this.currentStepIndex === 0 ? 'none' : ''; + const showEnterpriseBack = this.currentStepIndex === 0 && this.enterpriseSignInUiState === 'instance'; + this.backButton.style.display = (this.currentStepIndex === 0 && !showEnterpriseBack) ? 'none' : ''; } if (this.nextButton) { if (this.currentStepIndex === 0) { - // Sign-in step: secondary "Continue without Signing In" - this.nextButton.className = 'onboarding-a-btn onboarding-a-btn-secondary'; - this.nextButton.textContent = localize('onboarding.continueWithoutSignIn', "Continue without Signing In"); + if (this._userSignedIn) { + this.nextButton.className = 'onboarding-a-btn onboarding-a-btn-primary'; + this.nextButton.textContent = localize('onboarding.continue', "Continue"); + } else { + // Sign-in step: secondary "Continue without Signing In" + this.nextButton.className = 'onboarding-a-btn onboarding-a-btn-secondary'; + this.nextButton.textContent = localize('onboarding.continueWithoutSignIn', "Continue without Signing In"); + } } else if (this._isLastStep()) { this.nextButton.className = 'onboarding-a-btn onboarding-a-btn-primary'; this.nextButton.textContent = localize('onboarding.getStarted', "Get Started"); @@ -459,6 +486,47 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi const actions = append(contentMain, $('.onboarding-a-signin-actions')); + if (this._userSignedIn) { + const signedIn = append(actions, $('.onboarding-a-signin-confirmation')); + const icon = append(signedIn, $('span')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + icon.setAttribute('aria-hidden', 'true'); + const text = append(signedIn, $('span')); + text.textContent = localize('onboarding.signIn.signedIn', "You're signed in. You can continue to the next step."); + } else { + switch (this.enterpriseSignInUiState) { + case 'instance': + this._renderEnterpriseInstanceForm(actions); + break; + case 'progress': + this._renderEnterpriseSignInProgress(actions); + break; + default: + this._renderDefaultSignInActions(actions); + break; + } + } + + const footer = append(wrapper, $('.onboarding-a-signin-footer')); + + const disclaimerCol = append(footer, $('.onboarding-a-signin-disclaimer-col')); + + // GitHub Copilot disclaimer + const copilotDisclaimer = append(disclaimerCol, $('.onboarding-a-signin-disclaimer')); + copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.prefix', "By signing in, you agree to {0}'s ", defaultChat.provider.default.name)); + this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.terms', "Terms"), defaultChat.termsStatementUrl); + copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.middle', " and ")); + this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.privacy', "Privacy Statement"), defaultChat.privacyStatementUrl); + copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.copilotPrefix', ". {0} Copilot may show ", defaultChat.provider.default.name)); + this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.publicCode', "public code"), defaultChat.publicCodeMatchesUrl); + copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.improveSuffix', " suggestions and use your data to improve the product.")); + copilotDisclaimer.append(' '); + copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.settingsPrefix', "You can change these ")); + this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.settings', "settings"), this.defaultAccountService.resolveGitHubUrl(GitHubPaths.copilotSettings)); + copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.suffix', " anytime.")); + } + + private _renderDefaultSignInActions(actions: HTMLElement): void { const githubBtn = this._registerStepFocusable(this._createSignInButton(actions, 'github', localize('onboarding.signIn.github', "Continue with GitHub"), { emphasized: true, label: localize('onboarding.signIn.github.aria', "Continue with GitHub") @@ -492,26 +560,120 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi })); this.stepDisposables.add(addDisposableListener(gheBtn, EventType.CLICK, () => { this._logAction('signIn', undefined, 'github-enterprise'); - this._handleEnterpriseSignIn(); + void this._handleEnterpriseSignIn(); })); + } - const footer = append(wrapper, $('.onboarding-a-signin-footer')); + private static readonly GHE_INPUT_ACTION_PADDING = 28; - const disclaimerCol = append(footer, $('.onboarding-a-signin-disclaimer-col')); + private _renderEnterpriseInstanceForm(actions: HTMLElement): void { + const enterprisePromptLabel = this._getEnterpriseInstancePromptLabel(); - // GitHub Copilot disclaimer - const copilotDisclaimer = append(disclaimerCol, $('.onboarding-a-signin-disclaimer')); - copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.prefix', "By signing in, you agree to {0}'s ", defaultChat.provider.default.name)); - this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.terms', "Terms"), defaultChat.termsStatementUrl); - copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.middle', " and ")); - this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.privacy', "Privacy Statement"), defaultChat.privacyStatementUrl); - copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.copilotPrefix', ". {0} Copilot may show ", defaultChat.provider.default.name)); - this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.publicCode', "public code"), defaultChat.publicCodeMatchesUrl); - copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.improveSuffix', " suggestions and use your data to improve the product.")); - copilotDisclaimer.append(' '); - copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.settingsPrefix', "You can change these ")); - this._createInlineLink(copilotDisclaimer, localize('onboarding.signIn.disclaimer.settings', "settings"), this.defaultAccountService.resolveGitHubUrl(GitHubPaths.copilotSettings)); - copilotDisclaimer.append(localize('onboarding.signIn.disclaimer.suffix', " anytime.")); + const container = append(actions, $('.onboarding-a-signin-ghe-input')); + + const submitAction = this.stepDisposables.add(new Action( + 'onboarding.signIn.enterprise.submit', + localize('onboarding.signIn.enterprise.continue', "Continue"), + ThemeIcon.asClassName(Codicon.arrowRight), + false, + )); + + const inputBox = this.stepDisposables.add(new InputBox(container, undefined, { + placeholder: localize('onboarding.signIn.enterprise.placeholder', 'i.e. "octocat" or "https://octocat.ghe.com"...'), + ariaLabel: enterprisePromptLabel, + actions: [submitAction], + inputBoxStyles: defaultInputBoxStyles, + })); + inputBox.value = this.enterpriseInstanceValue; + inputBox.paddingRight = OnboardingVariationA.GHE_INPUT_ACTION_PADDING; + const input = this._registerStepFocusable(inputBox.inputElement); + + const submit = async () => { + const result = parseGheInstanceInput(inputBox.value); + if (result.kind === GheParseResultKind.Empty || result.kind === GheParseResultKind.Invalid) { + validate(); + return; + } + await this._submitEnterpriseInstance(result.resolvedUri); + }; + submitAction.run = submit; + + const message = append(container, $('.onboarding-a-signin-ghe-message')); + + const validate = (): boolean => { + this.enterpriseInstanceValue = inputBox.value; + inputBox.element.classList.remove('error'); + message.classList.remove('error', 'info'); + + const result = parseGheInstanceInput(inputBox.value); + switch (result.kind) { + case GheParseResultKind.Empty: + message.textContent = enterprisePromptLabel; + submitAction.enabled = false; + return false; + case GheParseResultKind.SingleWord: + message.classList.add('info'); + message.textContent = localize('onboarding.signIn.enterprise.resolve', "Will resolve to {0}", result.resolvedUri); + submitAction.enabled = true; + return true; + case GheParseResultKind.FullUri: + submitAction.enabled = true; + message.textContent = ''; + return true; + case GheParseResultKind.Invalid: + inputBox.element.classList.add('error'); + message.classList.add('error'); + message.textContent = localize('onboarding.signIn.enterprise.invalid', 'You must enter a valid {0} instance (i.e. "octocat" or "https://octocat.ghe.com")', defaultChat.provider.enterprise.name); + submitAction.enabled = false; + return false; + } + }; + + this.stepDisposables.add(inputBox.onDidChange(() => { + validate(); + })); + + this.stepDisposables.add(addDisposableListener(input, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter) { + e.preventDefault(); + void submitAction.run(); + return; + } + + if (event.keyCode === KeyCode.Escape) { + e.preventDefault(); + e.stopPropagation(); + this._logAction('cancelEnterpriseInstancePrompt'); + this.enterpriseSignInWatch = undefined; + this._setEnterpriseSignInUiState('options'); + } + })); + + validate(); + } + + private _renderEnterpriseSignInProgress(actions: HTMLElement): void { + const container = append(actions, $('.onboarding-a-signin-ghe-progress')); + container.setAttribute('aria-live', 'polite'); + const spinner = append(container, $('span')); + spinner.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + spinner.setAttribute('aria-hidden', 'true'); + const message = append(container, $('.onboarding-a-signin-ghe-progress-message')); + message.textContent = localize('onboarding.signIn.enterprise.progress', "Waiting for {0} sign-in to complete...", defaultChat.provider.enterprise.name); + } + + private _getEnterpriseInstancePromptLabel(): string { + return localize('onboarding.signIn.enterprise.prompt', "What is your {0} instance?", defaultChat.provider.enterprise.name); + } + + private _setEnterpriseSignInUiState(state: EnterpriseSignInUiState): void { + this.enterpriseSignInUiState = state; + if (this.steps[this.currentStepIndex] === OnboardingStepId.SignIn && this.contentEl) { + this._renderStep(); + this._updateButtonStates(); + this._focusCurrentStepElement(); + } } private _createSignInButton(parent: HTMLElement, providerClass: 'github' | 'github-enterprise' | 'google' | 'apple', label: string, options?: { emphasized?: boolean; iconOnly?: boolean; textOnly?: boolean; label?: string }): HTMLButtonElement { @@ -574,93 +736,68 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } private async _handleEnterpriseSignIn(): Promise { - const watch = StopWatch.create(); + const existingUri = this.configurationService.getValue(defaultChat.providerUriSetting); + if (typeof existingUri !== 'string' || !GHE_FULL_URI_REGEX.test(existingUri)) { + this.enterpriseInstanceValue = existingUri ?? ''; + this.enterpriseSignInWatch = StopWatch.create(); + this._setEnterpriseSignInUiState('instance'); + return; + } + + this.enterpriseInstanceValue = existingUri; + await this._runEnterpriseSignInSetup(); + } + + private async _submitEnterpriseInstance(resolvedUri: string): Promise { try { - const configured = await this._ensureEnterpriseInstance(); - if (!configured) { - return; - } + await this.configurationService.updateValue(defaultChat.providerUriSetting, resolvedUri, ConfigurationTarget.USER); + this.enterpriseInstanceValue = resolvedUri; + await this._runEnterpriseSignInSetup(); + } catch { + this.enterpriseSignInWatch = undefined; + this._setEnterpriseSignInUiState('instance'); + this._notifyEnterpriseSignInError(); + } + } - const provider = defaultChat.provider.enterprise.id; - const account = await this.defaultAccountService.signIn({ - extraAuthorizeParameters: { get_started_with: 'copilot-vscode' }, + private async _runEnterpriseSignInSetup(): Promise { + const watch = this.enterpriseSignInWatch ?? StopWatch.create(); + const provider = defaultChat.provider.enterprise.id; + this._setEnterpriseSignInUiState('progress'); + + try { + const success = await this.commandService.executeCommand('workbench.action.chat.triggerSetup', undefined, { + disableChatViewReveal: true, + setupStrategy: ChatSetupStrategy.SetupWithEnterpriseProvider, }); - if (account) { + + if (success) { this._userSignedIn = true; this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); - this.commandService.executeCommand('workbench.action.chat.triggerSetup', undefined, { - disableChatViewReveal: true, - setupStrategy: ChatSetupStrategy.DefaultSetup, - }); this._nextStep(); + } else { + this._setEnterpriseSignInUiState('options'); } } catch (error) { if (isCancellationError(error)) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'cancelled', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider: defaultChat.provider.enterprise.id }); + this._setEnterpriseSignInUiState('options'); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'cancelled', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); return; } - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider: defaultChat.provider.enterprise.id }); - this.notificationService.notify({ - severity: Severity.Error, - message: localize('onboarding.signIn.enterprise.error', "GitHub Enterprise sign-in failed. Check your instance URL and try again."), - }); + this._setEnterpriseSignInUiState('instance'); + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNotSignedIn', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); + this._notifyEnterpriseSignInError(); + } finally { + this.enterpriseSignInWatch = undefined; } } - private async _ensureEnterpriseInstance(): Promise { - const domainRegEx = /^[a-zA-Z\-_]+$/; - const fullUriRegEx = /^(https:\/\/)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.ghe\.com\/?$/; - - const uri = this.configurationService.getValue(defaultChat.providerUriSetting); - if (typeof uri === 'string' && fullUriRegEx.test(uri)) { - return true; - } - - let isSingleWord = false; - const result = await this.quickInputService.input({ - prompt: localize('onboarding.signIn.enterprise.prompt', "What is your {0} instance?", defaultChat.provider.enterprise.name), - placeHolder: localize('onboarding.signIn.enterprise.placeholder', 'i.e. "octocat" or "https://octocat.ghe.com"...'), - ignoreFocusLost: true, - value: uri, - validateInput: async value => { - isSingleWord = false; - if (!value) { - return undefined; - } - - if (domainRegEx.test(value)) { - isSingleWord = true; - return { - content: localize('onboarding.signIn.enterprise.resolve', "Will resolve to {0}", `https://${value}.ghe.com`), - severity: Severity.Info - }; - } - - if (!fullUriRegEx.test(value)) { - return { - content: localize('onboarding.signIn.enterprise.invalid', 'You must enter a valid {0} instance (i.e. "octocat" or "https://octocat.ghe.com")', defaultChat.provider.enterprise.name), - severity: Severity.Error - }; - } - - return undefined; - } + private _notifyEnterpriseSignInError(): void { + this.notificationService.notify({ + severity: Severity.Error, + message: localize('onboarding.signIn.enterprise.error', "GitHub Enterprise sign-in failed. Check your instance URL and try again."), }); - - if (!result) { - return false; - } - - let resolvedUri = result; - if (isSingleWord) { - resolvedUri = `https://${resolvedUri}.ghe.com`; - } else if (!result.toLowerCase().startsWith('https://')) { - resolvedUri = `https://${result}`; - } - - await this.configurationService.updateValue(defaultChat.providerUriSetting, resolvedUri, ConfigurationTarget.USER); - return true; } // ===================================================================== @@ -1210,6 +1347,9 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._footerSignInBtn = undefined; this.footerFocusableElements.length = 0; this.stepFocusableElements.length = 0; + this.enterpriseSignInUiState = 'options'; + this.enterpriseInstanceValue = ''; + this.enterpriseSignInWatch = undefined; this._isShowing = false; this.disposables.clear(); this.stepDisposables.clear(); diff --git a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts index 0dd5734ae55f28..b2228ca51171a8 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts @@ -111,3 +111,48 @@ export const ONBOARDING_AI_PREFERENCE_OPTIONS: readonly IAiPreferenceOption[] = * Storage key for persisting onboarding completion state. */ export const ONBOARDING_STORAGE_KEY = 'welcomeOnboarding.state'; + +/** + * Regex matching a single-word GHE instance slug (e.g. "octocat"). + * Only allows characters valid in DNS hostnames (letters, digits, hyphens). + */ +export const GHE_DOMAIN_REGEX = /^[a-zA-Z0-9-]+$/; + +/** + * Regex matching a full GHE instance URI (e.g. "https://octocat.ghe.com"). + */ +export const GHE_FULL_URI_REGEX = /^(https:\/\/)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.ghe\.com\/?$/; + +export const enum GheParseResultKind { + Empty = 'empty', + SingleWord = 'singleWord', + FullUri = 'fullUri', + Invalid = 'invalid', +} + +export type GheParseResult = + | { readonly kind: GheParseResultKind.Empty } + | { readonly kind: GheParseResultKind.SingleWord; readonly resolvedUri: string } + | { readonly kind: GheParseResultKind.FullUri; readonly resolvedUri: string } + | { readonly kind: GheParseResultKind.Invalid }; + +/** + * Parses a GHE instance input value and returns the result kind and resolved URI. + */ +export function parseGheInstanceInput(value: string): GheParseResult { + const trimmed = value.trim(); + if (!trimmed) { + return { kind: GheParseResultKind.Empty }; + } + + if (GHE_DOMAIN_REGEX.test(trimmed)) { + return { kind: GheParseResultKind.SingleWord, resolvedUri: `https://${trimmed}.ghe.com` }; + } + + if (GHE_FULL_URI_REGEX.test(trimmed)) { + const resolvedUri = trimmed.toLowerCase().startsWith('https://') ? trimmed : `https://${trimmed}`; + return { kind: GheParseResultKind.FullUri, resolvedUri }; + } + + return { kind: GheParseResultKind.Invalid }; +} diff --git a/src/vs/workbench/contrib/welcomeOnboarding/test/common/onboardingTypes.test.ts b/src/vs/workbench/contrib/welcomeOnboarding/test/common/onboardingTypes.test.ts new file mode 100644 index 00000000000000..94a8fdfa800fc6 --- /dev/null +++ b/src/vs/workbench/contrib/welcomeOnboarding/test/common/onboardingTypes.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { GheParseResultKind, parseGheInstanceInput } from '../../common/onboardingTypes.js'; + +suite('parseGheInstanceInput', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('empty input returns Empty', () => { + assert.deepStrictEqual(parseGheInstanceInput(''), { kind: GheParseResultKind.Empty }); + assert.deepStrictEqual(parseGheInstanceInput(' '), { kind: GheParseResultKind.Empty }); + }); + + test('single word returns SingleWord with resolved URI', () => { + assert.deepStrictEqual(parseGheInstanceInput('octocat'), { kind: GheParseResultKind.SingleWord, resolvedUri: 'https://octocat.ghe.com' }); + assert.deepStrictEqual(parseGheInstanceInput('my-org'), { kind: GheParseResultKind.SingleWord, resolvedUri: 'https://my-org.ghe.com' }); + }); + + test('single word with surrounding whitespace is trimmed', () => { + assert.deepStrictEqual(parseGheInstanceInput(' octocat '), { kind: GheParseResultKind.SingleWord, resolvedUri: 'https://octocat.ghe.com' }); + }); + + test('single word with numbers returns SingleWord', () => { + assert.deepStrictEqual(parseGheInstanceInput('org123'), { kind: GheParseResultKind.SingleWord, resolvedUri: 'https://org123.ghe.com' }); + }); + + test('single word with underscores is invalid (not valid DNS)', () => { + assert.deepStrictEqual(parseGheInstanceInput('my_org'), { kind: GheParseResultKind.Invalid }); + }); + + test('full URI with https prefix returns FullUri', () => { + assert.deepStrictEqual(parseGheInstanceInput('https://octocat.ghe.com'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://octocat.ghe.com' }); + assert.deepStrictEqual(parseGheInstanceInput('https://octocat.ghe.com/'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://octocat.ghe.com/' }); + }); + + test('full URI without https prefix gets it prepended', () => { + assert.deepStrictEqual(parseGheInstanceInput('octocat.ghe.com'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://octocat.ghe.com' }); + assert.deepStrictEqual(parseGheInstanceInput('sub.octocat.ghe.com'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://sub.octocat.ghe.com' }); + }); + + test('full URI with subdomain returns FullUri', () => { + assert.deepStrictEqual(parseGheInstanceInput('https://sub.domain.ghe.com'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://sub.domain.ghe.com' }); + }); + + test('invalid input returns Invalid', () => { + assert.deepStrictEqual(parseGheInstanceInput('not a valid url'), { kind: GheParseResultKind.Invalid }); + assert.deepStrictEqual(parseGheInstanceInput('https://github.com'), { kind: GheParseResultKind.Invalid }); + assert.deepStrictEqual(parseGheInstanceInput('https://octocat.example.com'), { kind: GheParseResultKind.Invalid }); + assert.deepStrictEqual(parseGheInstanceInput('http://octocat.ghe.com'), { kind: GheParseResultKind.Invalid }); + assert.deepStrictEqual(parseGheInstanceInput('ftp://octocat.ghe.com'), { kind: GheParseResultKind.Invalid }); + }); + + test('input with numbers in domain is valid', () => { + assert.deepStrictEqual(parseGheInstanceInput('https://org123.ghe.com'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://org123.ghe.com' }); + }); + + test('single word with only hyphens returns SingleWord', () => { + assert.deepStrictEqual(parseGheInstanceInput('my-long-org-name'), { kind: GheParseResultKind.SingleWord, resolvedUri: 'https://my-long-org-name.ghe.com' }); + }); + + test('URI with trailing slash is valid', () => { + assert.deepStrictEqual(parseGheInstanceInput('https://octocat.ghe.com/'), { kind: GheParseResultKind.FullUri, resolvedUri: 'https://octocat.ghe.com/' }); + }); + + test('URI with path segments is invalid', () => { + assert.deepStrictEqual(parseGheInstanceInput('https://octocat.ghe.com/api/v3'), { kind: GheParseResultKind.Invalid }); + }); +}); 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 bd80ca56c51f0c..1aeeaf78cb8727 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -34,7 +34,7 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { IPathService } from '../../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../../contrib/webview/browser/webview.js'; -import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection, AICustomizationSources } from '../../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots } from '../../../../contrib/chat/common/customizationHarnessService.js'; import { IChatSessionsService, SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; @@ -829,7 +829,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); override getActiveProjectRoot() { return URI.file('/workspace'); } override getStorageSourceFilter() { - return { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; + return { sources: AICustomizationSources.all }; } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { @@ -1047,7 +1047,7 @@ function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): voi override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); override getActiveProjectRoot() { return URI.file('/workspace'); } override getStorageSourceFilter() { - return { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; + return { sources: AICustomizationSources.all }; } }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 30b8777d22a6b8..a712f76cab2591 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -11,7 +11,7 @@ declare module 'vscode' { /** * Indicates where a chat resource was loaded from. */ - export type ChatResourceSource = 'local' | 'user' | 'extension' | 'plugin'; + export type ChatResourceSource = 'local' | 'user' | 'extension' | 'plugin' | 'builtin'; /** * Represents a chat-related resource, such as a custom agent, instructions, prompt file, skill, or slash command. diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index d452e598978296..8feb605f2b9b23 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -65,6 +65,8 @@ declare module 'vscode' { readonly supportedTypes?: readonly ChatSessionCustomizationType[]; } + export type ChatSessionCustomizationSource = 'local' | 'user' | 'extension' | 'plugin' | 'builtin'; + /** * Represents a single customization item reported by a provider. */ @@ -90,12 +92,17 @@ declare module 'vscode' { readonly description?: string; /** - * The extension identifier that contributed this customization, if any. + * The source/origin of this customization, which drives UI grouping and filtering + */ + readonly source: ChatSessionCustomizationSource; + + /** + * The extension identifier that contributed this customization. Should be set if the source is 'extension'. */ readonly extensionId?: string; /** - * The URI of the plugin that contributed this customization, if any. + * The URI of the plugin that contributed this customization, if any. Should be set if the source is 'plugin'. */ readonly pluginUri?: Uri; diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts new file mode 100644 index 00000000000000..9f3f5990c68a25 --- /dev/null +++ b/test/automation/src/agentsWindow.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Code } from './code'; +import { QuickAccess } from './quickaccess'; + +const AGENTS_WORKBENCH = '.agent-sessions-workbench'; +const NEW_SESSION_VIEW = '.sessions-chat-widget .new-chat-widget-container'; +const SESSION_TYPE_PICKER = '.sessions-chat-session-type-picker .action-label'; +const SESSION_TYPE_PICKER_VISIBLE = `${SESSION_TYPE_PICKER}:not(.hidden)`; +const NEW_CHAT_EDITOR = `${NEW_SESSION_VIEW} .sessions-chat-editor .monaco-editor[role="code"]`; +const SEND_BUTTON_ENABLED = `${NEW_SESSION_VIEW} .sessions-chat-send-button .monaco-button:not(.disabled)`; +const RESPONSE = `${AGENTS_WORKBENCH} .interactive-item-container.interactive-response`; +const RESPONSE_COMPLETE = `${RESPONSE}:not(.chat-response-loading)`; + +export class AgentsWindow { + + constructor(private code: Code, private quickaccess: QuickAccess) { } + + private get newChatEditorInputSelector(): string { + return `${NEW_CHAT_EDITOR} ${this.code.editContextEnabled ? '.native-edit-context' : 'textarea'}`; + } + + /** + * Run the "Open in Agents" command from the normal workbench window. + * VS Code opens a new Agents Window with the current workspace folder + * pre-selected in the workspace picker. + * + * After calling this, use {@link switchToAgentsWindow} to move the + * driver focus to the newly opened Agents Window. + */ + async openCurrentFolderInAgentsWindow(): Promise { + await this.quickaccess.runCommand('workbench.action.openWorkspaceInAgentsWindow'); + } + + /** + * Start a new session from inside the Agents Window via the + * `workbench.action.sessions.newChat` keybinding (Ctrl+L). The action + * is not exposed in the command palette, so we drive it through its + * key chord which works cross-platform (mac uses WinCtrl+L as the + * secondary binding, which maps to plain Ctrl+L). + */ + async startNewSession(): Promise { + await this.code.dispatchKeybinding('ctrl+l', async () => this.waitForNewSessionView()); + } + + /** + * Wait for a new Electron window to appear beyond the current count, + * then switch the driver to it. Returns once the Agents Window's + * workbench DOM is present so the caller can immediately drive UI. + */ + async switchToAgentsWindow(previousWindowCount: number, timeoutMs: number = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const windows = this.code.driver.getAllWindows(); + if (windows.length > previousWindowCount) { + const newIndex = windows.length - 1; + this.code.driver.switchToWindow(newIndex); + // Wait for the Agents workbench DOM to be installed instead of + // sleeping a fixed amount — Copilot review feedback #317545. + await this.code.waitForElement(AGENTS_WORKBENCH); + return; + } + await new Promise(r => setTimeout(r, 300)); + } + throw new Error(`Timed out waiting for Agents Window to open (${previousWindowCount} → more windows)`); + } + + /** + * Wait until the new-session homepage is visible and the session type + * picker is populated (i.e. no longer has the `.hidden` class). The + * picker is hidden until the provider has loaded session types; clicking + * it before that point silently does nothing. + */ + async waitForNewSessionView(retryCount: number = 600): Promise { + await this.code.waitForElement(NEW_SESSION_VIEW, undefined, retryCount); + await this.code.waitForElement(SESSION_TYPE_PICKER_VISIBLE, undefined, retryCount); + } + + /** + * Select the given session type from the new-session picker. + * + * The picker trigger is the `.action-label` inside + * `.sessions-chat-session-type-picker`. Clicking it opens the action + * widget popup; we then locate the matching `.monaco-list-row` by its + * text content and click it. The dropdown is async-populated, so we + * wait for at least the requested label to appear before committing. + */ + async selectSessionType(label: string): Promise { + await this.code.waitForElement(SESSION_TYPE_PICKER_VISIBLE); + + const itemSel = `.action-widget .monaco-list-row`; + const maxAttempts = 3; + + // The picker click can silently do nothing if the active session + // isn't fully initialized yet. Retry the click until the dropdown + // rows appear. + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + await this.code.waitAndClick(SESSION_TYPE_PICKER_VISIBLE); + try { + await this.code.waitForElement(itemSel, el => !!el && (el.textContent ?? '').trim().length > 0, 30 /* ~3 seconds */); + break; + } catch { + if (attempt === maxAttempts) { + throw new Error(`Session type picker did not populate after ${maxAttempts} attempts`); + } + await new Promise(r => setTimeout(r, 2000)); + } + } + + const items = await this.code.waitForElements(itemSel, /* recursive */ true); + const matchIndex = items.findIndex(el => (el.textContent ?? '').trim().toLowerCase().includes(label.toLowerCase())); + if (matchIndex < 0) { + throw new Error(`Session type "${label}" not found in picker. Available: ${items.map(i => (i.textContent ?? '').trim()).join(', ')}`); + } + await this.code.waitAndClick(`.action-widget .monaco-list-row[data-index="${matchIndex}"]`); + } + + /** + * Submit a prompt from the new-session homepage. Waits for the send + * button to be enabled (indicates the session provider/extension host + * is ready) before clicking it. + * + * After clicking, verifies the new-session homepage disappears to + * confirm the send took effect. Retries the click if the view is + * still visible (the first click can silently fail if the button + * moved or an overlay intercepted the event). + */ + async submitNewSessionPrompt(prompt: string, sendButtonRetryCount: number = 600): Promise { + await this.code.waitForElement(NEW_CHAT_EDITOR); + await this.code.waitAndClick(NEW_CHAT_EDITOR); + await this.code.waitForTypeInEditor(this.newChatEditorInputSelector, prompt); + await this.code.waitForElement(SEND_BUTTON_ENABLED, undefined, sendButtonRetryCount); + + const maxClickAttempts = 3; + for (let attempt = 1; attempt <= maxClickAttempts; attempt++) { + await this.code.waitAndClick(SEND_BUTTON_ENABLED); + // Verify the new-session view disappeared (confirms send took effect). + try { + await this.code.waitForElement(NEW_SESSION_VIEW, result => !result, 30 /* ~3 seconds */); + return; // View gone — send succeeded + } catch { + // View still present — click may not have fired; retry + if (attempt < maxClickAttempts) { + await new Promise(r => setTimeout(r, 1000)); + } + } + } + // Proceed even if the view didn't disappear — the send may have + // worked but the view transition is slow. + } + + /** + * Wait until at least one assistant response bubble contains text + * matching the predicate. Returns the matched element's full text + * content. + */ + async waitForAssistantText(predicate: RegExp | string, timeoutMs: number = 60_000): Promise { + const retryCount = Math.ceil(timeoutMs / 100); + await this.code.waitForElement(RESPONSE, undefined, retryCount); + await this.code.waitForElement(RESPONSE_COMPLETE, undefined, retryCount); + + const responseSelector = `${RESPONSE_COMPLETE} .rendered-markdown`; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const elements = await this.code.getElements(responseSelector, /* recursive */ true); + for (const el of (elements ?? [])) { + const text = el.textContent || ''; + if (typeof predicate === 'string' ? text.includes(predicate) : predicate.test(text)) { + return text; + } + } + await new Promise(r => setTimeout(r, 500)); + } + throw new Error(`Timed out waiting for assistant text matching ${predicate}`); + } +} diff --git a/test/automation/src/code.ts b/test/automation/src/code.ts index 20e8f0cede24c9..35728aed9338b2 100644 --- a/test/automation/src/code.ts +++ b/test/automation/src/code.ts @@ -37,6 +37,14 @@ export interface LaunchOptions { readonly quality: Quality; version: { major: number; minor: number; patch: number }; readonly extensionDevelopmentPath?: string; + + /** + * Extra environment variables merged on top of the inherited `process.env` + * when launching the Electron child process. Set a value to `undefined` + * to unset the variable. Used by tests that need to inject env-based + * mocks (e.g. `VSCODE_COPILOT_CHAT_TOKEN`). + */ + readonly extraEnv?: Readonly>; } interface ICodeInstance { diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index f6f1d78551e51e..c5d47408c0c353 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -19,7 +19,7 @@ export interface IElectronConfiguration { } export async function resolveElectronConfiguration(options: LaunchOptions): Promise { - const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, logsPath, crashesPath, extraArgs } = options; + const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, logsPath, crashesPath, extraArgs, extraEnv } = options; const env = { ...process.env }; const args: string[] = [ @@ -90,6 +90,19 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom args.push(...extraArgs); } + // Apply extraEnv last so caller-provided env truly has final precedence + // over anything resolveElectronConfiguration sets (e.g. TESTRESOLVER_*). + // Copilot review feedback #317545. + if (extraEnv) { + for (const [key, value] of Object.entries(extraEnv)) { + if (value === undefined) { + delete env[key]; + } else { + env[key] = value; + } + } + } + const electronPath = codePath ? getBuildElectronPath(codePath) : getDevElectronPath(); return { diff --git a/test/automation/src/index.ts b/test/automation/src/index.ts index 80318af7962979..f88e24a56421aa 100644 --- a/test/automation/src/index.ts +++ b/test/automation/src/index.ts @@ -27,4 +27,5 @@ export * from './localization'; export * from './workbench'; export * from './task'; export * from './chat'; +export * from './agentsWindow'; export { getDevElectronPath, getBuildElectronPath, getBuildVersion } from './electron'; diff --git a/test/automation/src/workbench.ts b/test/automation/src/workbench.ts index a01a7d362b6bb7..4a93b475b11bc6 100644 --- a/test/automation/src/workbench.ts +++ b/test/automation/src/workbench.ts @@ -23,6 +23,7 @@ import { Notebook } from './notebook'; import { Localization } from './localization'; import { Task } from './task'; import { Chat } from './chat'; +import { AgentsWindow } from './agentsWindow'; export interface Commands { runCommand(command: string, options?: { exactLabelMatch?: boolean }): Promise; @@ -49,6 +50,7 @@ export class Workbench { readonly localization: Localization; readonly task: Task; readonly chat: Chat; + readonly agentsWindow: AgentsWindow; constructor(code: Code) { this.editors = new Editors(code); @@ -70,5 +72,6 @@ export class Workbench { this.localization = new Localization(code); this.task = new Task(code, this.editor, this.editors, this.quickaccess, this.quickinput, this.terminal); this.chat = new Chat(code); + this.agentsWindow = new AgentsWindow(code, this.quickaccess); } } diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts new file mode 100644 index 00000000000000..544333219cc761 --- /dev/null +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as cp from 'child_process'; +import * as path from 'path'; +import { Application, Logger } from '../../../../automation'; +import { installAllHandlers } from '../../utils'; + +/** + * Per-test scenarios. Each test uses a unique scenario id so that the mock + * reply is distinct — this catches stale-content bugs where the previous + * test's response is mistakenly accepted as the current test's response. + */ +const COPILOT_SCENARIO_ID = 'smoke-hello-copilot'; +const COPILOT_REPLY = 'MOCKED_COPILOT_RESPONSE'; + +const LOCAL_SCENARIO_ID = 'smoke-hello-local'; +const LOCAL_REPLY = 'MOCKED_LOCAL_RESPONSE'; + +const CLAUDE_SCENARIO_ID = 'smoke-hello-claude'; +const CLAUDE_REPLY = 'MOCKED_CLAUDE_RESPONSE'; + +/** + * Build the `VSCODE_COPILOT_CHAT_TOKEN` env var value the Copilot Chat + * extension reads at startup to skip GitHub OAuth and use a fake token + * whose `endpoints.api/proxy` point at our mock server. This is the same + * mechanism used by `scripts/chat-simulation/common/utils.js#buildEnv` + * for perf-regression and memory-leak runs. + */ +function buildCopilotChatToken(mockUrl: string): string { + return Buffer.from(JSON.stringify({ + token: 'smoketest-fake-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_in: 1800, + sku: 'free_limited_copilot', + individual: true, + isNoAuthUser: true, + copilot_plan: 'free', + organization_login_list: [], + endpoints: { api: mockUrl, proxy: mockUrl }, + })).toString('base64'); +} + +export function setup(logger: Logger) { + + describe('Agents Window — mock LLM server', () => { + + let mockServer: any; + + // Start the mock server BEFORE installAllHandlers' `before` runs so + // the mock URL is available when we configure the app's env vars via + // `optionsTransform`. + before(async function () { + // Load mock-llm-server.js from the repo scripts directory. + // It is CommonJS so `require` works fine from the compiled TS. + const mockLlmServerPath = path.join( + __dirname, '..', '..', '..', '..', '..', 'scripts', 'chat-simulation', 'common', 'mock-llm-server.js' + ); + + const { startServer, ScenarioBuilder, registerScenario } = require(mockLlmServerPath); + + // Fallback for ancillary requests (title/branch) that don't carry a [scenario:...] tag. + registerScenario('text-only', new ScenarioBuilder().emit('OK').build()); + + // One scenario per session type, each emitting a distinct reply + // so the assertion is unambiguous. + registerScenario(COPILOT_SCENARIO_ID, new ScenarioBuilder().emit(COPILOT_REPLY).build()); + registerScenario(LOCAL_SCENARIO_ID, new ScenarioBuilder().emit(LOCAL_REPLY).build()); + registerScenario(CLAUDE_SCENARIO_ID, new ScenarioBuilder().emit(CLAUDE_REPLY).build()); + + mockServer = await startServer(0); + logger.log(`Mock LLM server started at ${mockServer.url}`); + }); + + installAllHandlers(logger, opts => ({ + ...opts, + extraEnv: { + ...(opts.extraEnv ?? {}), + // Mirror the env-var bypass used by `scripts/chat-simulation/common/utils.js#buildEnv` + // for perf-regression / memory-leak runs: + // - GITHUB_PAT → switches copilotTokenManager into FixedCopilotTokenManager, + // skipping the real GitHub OAuth flow. + // - IS_SCENARIO_AUTOMATION → tells the copilot extension this is an automation run + // so it suppresses sign-in prompts and uses NoAuth paths. + // - VSCODE_COPILOT_CHAT_TOKEN → fake token whose endpoints.api/proxy point at the + // mock LLM server (used by the Local session type which + // goes through the regular VS Code chat infrastructure). + GITHUB_PAT: 'smoketest-fake-pat', + IS_SCENARIO_AUTOMATION: '1', + VSCODE_COPILOT_CHAT_TOKEN: mockServer ? buildCopilotChatToken(mockServer.url) : undefined, + }, + })); + + before(async function () { + // One-time setup: write VS Code settings and open the Agents Window + // with the smoke-test workspace folder pre-selected. Subsequent tests + // reuse this window and just start fresh sessions. + const app = this.app as Application; + + // Reset any uncommitted changes left by earlier smoke test suites + // (e.g. the Tasks test modifies .vscode/tasks.json). A dirty + // workspace prevents worktree creation and triggers the + // "uncommitted changes" confirmation flow which aborts the session. + cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); + + // overrideProxyUrl redirects all Copilot SDK traffic to our mock server + // and enables HMAC auth — no real GitHub token required. + // allowAnonymousAccess skips the token-validation gate in the + // extension-host copilotTokenManager when there is no real GitHub session. + // githubMcpServer is disabled to prevent a real-network MCP connection + // to the GitHub MCP server during the test. + // sessions.chat.localAgent.enabled exposes the "Local" session type. + await app.workbench.settingsEditor.addUserSettings([ + ['github.copilot.advanced.debug.overrideProxyUrl', JSON.stringify(mockServer.url)], + ['chat.allowAnonymousAccess', 'true'], + ['github.copilot.chat.githubMcpServer.enabled', 'false'], + ['sessions.chat.localAgent.enabled', 'true'], + ]); + + // `--enable-smoke-test-driver` (set by the runner) skips the auth dialog. + const windowsBefore = app.code.driver.getAllWindows().length; + await app.workbench.agentsWindow.openCurrentFolderInAgentsWindow(); + await app.workbench.agentsWindow.switchToAgentsWindow(windowsBefore); + }); + + after(async function () { + if (mockServer) { + await mockServer.close(); + } + }); + + it('sends hello world via Copilot session type and receives a mocked response', async function () { + const app = this.app as Application; + + await app.workbench.agentsWindow.waitForNewSessionView(); + await app.workbench.agentsWindow.selectSessionType('Copilot CLI'); + + const requestsBefore = mockServer.requestCount(); + await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${COPILOT_SCENARIO_ID}]`); + + const text = await app.workbench.agentsWindow.waitForAssistantText(COPILOT_REPLY); + logger.log(`Agents Window (Copilot) response: ${text}`); + + assert.ok( + mockServer.requestCount() > requestsBefore, + 'expected the mock LLM server to have received a new request from the Copilot session' + ); + }); + + // TODO: consistently times out on macOS CI — the Claude session + // controller never starts. Needs investigation into what blocks + // createNewChatSessionItem for the claude-code session type. + it.skip('sends hello world via Claude session type and receives a mocked response', async function () { + const app = this.app as Application; + + await app.workbench.agentsWindow.startNewSession(); + await app.workbench.agentsWindow.waitForNewSessionView(); + await app.workbench.agentsWindow.selectSessionType('Claude'); + + const requestsBefore = mockServer.requestCount(); + await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${CLAUDE_SCENARIO_ID}]`); + + const text = await app.workbench.agentsWindow.waitForAssistantText(CLAUDE_REPLY); + logger.log(`Agents Window (Claude) response: ${text}`); + + assert.ok( + mockServer.requestCount() > requestsBefore, + 'expected the mock LLM server to have received a new request from the Claude session' + ); + }); + + it('sends hello world via Local session type and receives a mocked response', async function () { + const app = this.app as Application; + + await app.workbench.agentsWindow.startNewSession(); + await app.workbench.agentsWindow.waitForNewSessionView(); + await app.workbench.agentsWindow.selectSessionType('Local'); + + const requestsBefore = mockServer.requestCount(); + await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${LOCAL_SCENARIO_ID}]`); + + const text = await app.workbench.agentsWindow.waitForAssistantText(LOCAL_REPLY); + logger.log(`Agents Window (Local) response: ${text}`); + + assert.ok( + mockServer.requestCount() > requestsBefore, + 'expected the mock LLM server to have received a new request from the Local session' + ); + }); + }); +} diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index 17034886d88c57..13d844c6d28ce5 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -29,6 +29,7 @@ import { setup as setupTerminalTests } from './areas/terminal/terminal.test'; import { setup as setupTaskTests } from './areas/task/task.test'; import { setup as setupChatTests } from './areas/chat/chatDisabled.test'; import { setup as setupAccessibilityTests } from './areas/accessibility/accessibility.test'; +import { setup as setupAgentsWindowTests } from './areas/agentsWindow/agentsWindow.test'; const rootPath = path.join(__dirname, '..', '..', '..'); @@ -418,5 +419,6 @@ describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => { if (!opts.web && !opts.remote && quality !== Quality.Dev && quality !== Quality.OSS) { setupLocalizationTests(logger); } if (!opts.web && !opts.remote) { setupLaunchTests(logger); } if (!opts.web) { setupChatTests(logger); } + if (!opts.web && !opts.remote && quality !== Quality.Dev && quality !== Quality.OSS) { setupAgentsWindowTests(logger); } setupAccessibilityTests(logger, opts, quality); });