From fda58b5fc2cbf7d810c29d93686614a6ba7b782e Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 1 Sep 2026 11:21:48 -0700 Subject: [PATCH 01/23] Support Copilot CLI MCP configuration discovery Discover MCP servers from ~/.copilot/mcp-config.json when the corresponding external discovery source is enabled. Avoid forwarding those servers back to Copilot Agent Host, which discovers them natively. Refs #333613 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostMcpServerSupport.ts | 8 ++ .../agentHostMcpServerSupport.test.ts | 32 +++++- .../discovery/nativeMcpDiscoveryAbstract.ts | 3 +- .../discovery/nativeMcpDiscoveryAdapters.ts | 13 +++ .../contrib/mcp/common/mcpConfiguration.ts | 4 + .../workbench/contrib/mcp/common/mcpTypes.ts | 2 +- .../common/nativeMcpDiscoveryAdapters.test.ts | 99 ++++++++++++++++++- 7 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts index 13b83cb136f11f..02b49939d683e1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts @@ -57,6 +57,7 @@ export const enum AgentHostMcpServerSourceKind { WorkspaceConfiguration = 'workspaceConfiguration', WorkspaceDotMcp = 'workspaceDotMcp', ClaudeDesktop = 'claudeDesktop', + CopilotUser = 'copilotUser', Windsurf = 'windsurf', CursorUser = 'cursorUser', CursorWorkspace = 'cursorWorkspace', @@ -253,6 +254,10 @@ async function resolveMcpServerForAgentHostDelivery( ); } + if (source.kind === AgentHostMcpServerSourceKind.CopilotUser && isCopilotCliSessionType(sessionType)) { + return createResolution(server, definition, source, applicability, AgentHostMcpServerDelivery.RuntimeDiscovered, supported()); + } + if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection) && !McpCollectionDefinition.isVscodeMcpJson(collection)) { return createResolution( server, @@ -396,6 +401,8 @@ function getExternalConfigurationSourceKind(discoverySource: ExternalDiscoverySo switch (discoverySource) { case ExternalDiscoverySource.ClaudeDesktop: return AgentHostMcpServerSourceKind.ClaudeDesktop; + case ExternalDiscoverySource.Copilot: + return AgentHostMcpServerSourceKind.CopilotUser; case ExternalDiscoverySource.Windsurf: return AgentHostMcpServerSourceKind.Windsurf; case ExternalDiscoverySource.CursorGlobal: @@ -412,6 +419,7 @@ function getMcpServerSourceGroup(kind: AgentHostMcpServerSourceKind): AICustomiz case AgentHostMcpServerSourceKind.UserProfile: case AgentHostMcpServerSourceKind.RemoteUser: case AgentHostMcpServerSourceKind.ClaudeDesktop: + case AgentHostMcpServerSourceKind.CopilotUser: case AgentHostMcpServerSourceKind.Windsurf: case AgentHostMcpServerSourceKind.CursorUser: return AICustomizationSources.user; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts index 18492e31935025..58db9b6a1f4220 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts @@ -14,7 +14,7 @@ import { ExtensionIdentifier } from '../../../../../../platform/extensions/commo import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../../../platform/storage/common/storage.js'; -import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostMcpServerEnablementState, AgentHostMcpServerSourceKind, AgentHostMcpSupportReason, assessMcpServersForCopilotAgentHost, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID, mergeInstalledMcpServersIntoAgentHostSupportAssessment } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupport.js'; +import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostMcpServerEnablementState, AgentHostMcpServerSourceKind, AgentHostMcpSupportReason, assessMcpServersForCopilotAgentHost, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID, mergeInstalledMcpServersIntoAgentHostSupportAssessment, resolveMcpServersForAgentHostDelivery } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupport.js'; import { AgentHostMcpServerSupportScope } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupportScope.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { ExternalDiscoverySource } from '../../../../mcp/common/mcpConfiguration.js'; @@ -41,6 +41,13 @@ suite('agentHostMcpServerSupport', () => { configTarget: ConfigurationTarget.WORKSPACE_FOLDER, collectionOrigin: URI.joinPath(root, '.mcp.json'), }), + makeMcpServer({ + id: 'copilot.null.user', + collectionId: 'copilot.null', + provenance: McpCollectionProvenance.ExternalConfiguration, + discoverySource: ExternalDiscoverySource.Copilot, + collectionOrigin: URI.file('/home/test/.copilot/mcp-config.json'), + }), makeMcpServer({ id: 'plugin.test/server', collectionId: 'plugin.file:///plugin', @@ -71,6 +78,7 @@ suite('agentHostMcpServerSupport', () => { })), [ { name: 'mcp.config.usrlocal.user', source: AgentHostMcpServerSourceKind.UserProfile, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, { name: 'workspace-dot-mcp.0.root', source: AgentHostMcpServerSourceKind.WorkspaceDotMcp, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, + { name: 'copilot.null.user', source: AgentHostMcpServerSourceKind.CopilotUser, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, { name: 'plugin.test/server', source: AgentHostMcpServerSourceKind.AgentPlugin, delivery: AgentHostMcpServerDelivery.AgentPlugin, compatibility: 'supported' }, { name: 'extension.server', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, { name: 'github', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ProviderBuiltIn, compatibility: 'supported' }, @@ -168,6 +176,28 @@ suite('agentHostMcpServerSupport', () => { ]); }); + test('forwards Copilot user configuration to agent hosts that do not discover it', async () => { + const [result] = await resolveMcpServersForAgentHostDelivery([ + makeMcpServer({ + id: 'copilot.null.user', + collectionId: 'copilot.null', + provenance: McpCollectionProvenance.ExternalConfiguration, + discoverySource: ExternalDiscoverySource.Copilot, + collectionOrigin: URI.file('/home/test/.copilot/mcp-config.json'), + }), + ], makeConfigurationResolverService(), 'agent-host-claude', []); + + assert.deepStrictEqual({ + source: result.source.kind, + delivery: result.delivery, + compatibility: result.compatibility, + }, { + source: AgentHostMcpServerSourceKind.CopilotUser, + delivery: AgentHostMcpServerDelivery.ClientForwarded, + compatibility: { kind: 'supported' }, + }); + }); + test('keeps scope applicability separate from configuration compatibility', async () => { const server = makeMcpServer({ id: 'mcp.config.ws0.server', diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index db00f608a2db2c..dc4f4f889bc9b5 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -22,7 +22,7 @@ import { ExternalDiscoverySource, discoverySourceLabel, mcpDiscoverySection } fr import { IMcpRegistry } from '../mcpRegistryTypes.js'; import { McpCollectionDefinition, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; -import { ClaudeDesktopMpcDiscoveryAdapter, CursorDesktopMpcDiscoveryAdapter, NativeMpcDiscoveryAdapter, WindsurfDesktopMpcDiscoveryAdapter } from './nativeMcpDiscoveryAdapters.js'; +import { ClaudeDesktopMpcDiscoveryAdapter, CopilotMpcDiscoveryAdapter, CursorDesktopMpcDiscoveryAdapter, NativeMpcDiscoveryAdapter, WindsurfDesktopMpcDiscoveryAdapter } from './nativeMcpDiscoveryAdapters.js'; export type WritableMcpCollectionDefinition = McpCollectionDefinition & { serverDefinitions: ISettableObservable }; @@ -120,6 +120,7 @@ export abstract class NativeFilesystemMcpDiscovery extends FilesystemMcpDiscover this.adapters = [ instantiationService.createInstance(ClaudeDesktopMpcDiscoveryAdapter, remoteAuthority), + instantiationService.createInstance(CopilotMpcDiscoveryAdapter, remoteAuthority), instantiationService.createInstance(CursorDesktopMpcDiscoveryAdapter, remoteAuthority), instantiationService.createInstance(WindsurfDesktopMpcDiscoveryAdapter, remoteAuthority), ]; diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index 655666d6c6b378..0d202d8528c4ab 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -92,6 +92,19 @@ export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapt } } +export class CopilotMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter { + public override readonly discoverySource: ExternalDiscoverySource = ExternalDiscoverySource.Copilot; + + constructor(remoteAuthority: string | null) { + super(remoteAuthority); + this.id = `copilot.${this.remoteAuthority}`; + } + + override getFilePath({ homedir }: INativeMcpDiscoveryData): URI | undefined { + return URI.joinPath(homedir, '.copilot', 'mcp-config.json'); + } +} + export class WindsurfDesktopMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter { public override readonly discoverySource: ExternalDiscoverySource = ExternalDiscoverySource.Windsurf; diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index c42f18e940985a..8dabc9290855a3 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -26,6 +26,7 @@ export const mcpActivationEvent = (contributedCollectionId: string) => export const enum ExternalDiscoverySource { ClaudeDesktop = 'claude-desktop', + Copilot = 'copilot', Windsurf = 'windsurf', CursorGlobal = 'cursor-global', CursorWorkspace = 'cursor-workspace', @@ -33,6 +34,7 @@ export const enum ExternalDiscoverySource { export const allDiscoverySources = Object.keys({ [ExternalDiscoverySource.ClaudeDesktop]: true, + [ExternalDiscoverySource.Copilot]: true, [ExternalDiscoverySource.Windsurf]: true, [ExternalDiscoverySource.CursorGlobal]: true, [ExternalDiscoverySource.CursorWorkspace]: true, @@ -40,12 +42,14 @@ export const allDiscoverySources = Object.keys({ export const discoverySourceLabel: Record = { [ExternalDiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop', "Claude Desktop"), + [ExternalDiscoverySource.Copilot]: localize('mcp.discovery.source.copilot', "GitHub Copilot CLI"), [ExternalDiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf', "Windsurf"), [ExternalDiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global', "Cursor (Global)"), [ExternalDiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace', "Cursor (Workspace)"), }; export const discoverySourceSettingsLabel: Record = { [ExternalDiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop.config', "Claude Desktop configuration (`claude_desktop_config.json`)"), + [ExternalDiscoverySource.Copilot]: localize('mcp.discovery.source.copilot.config', "GitHub Copilot CLI configuration (`~/.copilot/mcp-config.json`)"), [ExternalDiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf.config', "Windsurf configurations (`~/.codeium/windsurf/mcp_config.json`)"), [ExternalDiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global.config', "Cursor global configuration (`~/.cursor/mcp.json`)"), [ExternalDiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace.config', "Cursor workspace configuration (`.cursor/mcp.json`)"), diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 62a23b1f5cb682..c8290e1cbd35cb 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -54,7 +54,7 @@ export const enum McpCollectionProvenance { WorkspaceConfiguration = 'workspaceConfiguration', // The `settings.mcp` section of a `.code-workspace` file. WorkspaceFolderConfiguration = 'workspaceFolderConfiguration', // `/.vscode/mcp.json`. WorkspaceDotMcp = 'workspaceDotMcp', // `/.mcp.json`. - ExternalConfiguration = 'externalConfiguration', // Claude Desktop, Windsurf, or Cursor user/workspace configuration. + ExternalConfiguration = 'externalConfiguration', // Claude Desktop, GitHub Copilot, Windsurf, or Cursor user/workspace configuration. Extension = 'extension', // An extension-provided `McpServerDefinitionProvider`. Plugin = 'plugin', // An agent plugin's `.mcp.json`. } diff --git a/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts b/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts index ffab0cd7262ccc..53cc4493dd66c5 100644 --- a/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts @@ -5,13 +5,110 @@ import * as assert from 'assert'; import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Platform } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IFileService, IFileSystemWatcher } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { INativeMcpDiscoveryData } from '../../../../../platform/mcp/common/nativeMcpDiscoveryHelper.js'; +import { IMcpRegistry } from '../../common/mcpRegistryTypes.js'; +import { NativeFilesystemMcpDiscovery } from '../../common/discovery/nativeMcpDiscoveryAbstract.js'; import { claudeConfigToServerDefinition } from '../../common/discovery/nativeMcpDiscoveryAdapters.js'; +import { ExternalDiscoverySource, mcpDiscoverySection } from '../../common/mcpConfiguration.js'; import { McpServerTransportType } from '../../common/mcpTypes.js'; +class TestNativeFilesystemMcpDiscovery extends NativeFilesystemMcpDiscovery { + override start(): void { } + + setDetailsForTest(details: INativeMcpDiscoveryData): void { + this.setDetails(details); + } +} + suite('MCP Discovery - nativeMcpDiscoveryAdapters', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function readNativeDiscoveryPaths(discoverySources: boolean | Partial>): string[] { + const paths: string[] = []; + const fileService = upcastPartial({ + createWatcher: () => upcastPartial({ + onDidChange: Event.None, + dispose: () => { }, + }), + readFile: resource => { + paths.push(resource.path); + return Promise.reject(new Error('Test does not provide configuration contents')); + }, + }); + const instantiationService = store.add(new TestInstantiationService()); + const discovery = store.add(new TestNativeFilesystemMcpDiscovery( + null, + upcastPartial({}), + fileService, + instantiationService, + upcastPartial({}), + new TestConfigurationService({ [mcpDiscoverySection]: discoverySources }), + )); + discovery.setDetailsForTest({ + platform: Platform.Linux, + homedir: URI.file('/home/test'), + }); + return paths; + } + + test('watches existing external application MCP configurations', () => { + assert.deepStrictEqual(readNativeDiscoveryPaths({ + [ExternalDiscoverySource.ClaudeDesktop]: true, + [ExternalDiscoverySource.CursorGlobal]: true, + [ExternalDiscoverySource.Windsurf]: true, + }), [ + '/home/test/.config/Claude/claude_desktop_config.json', + '/home/test/.cursor/mcp.json', + '/home/test/.codeium/windsurf/mcp_config.json', + ]); + }); + + test('watches the Copilot user MCP configuration', () => { + assert.deepStrictEqual(readNativeDiscoveryPaths({ + [ExternalDiscoverySource.Copilot]: true, + }), ['/home/test/.copilot/mcp-config.json']); + }); + + test('does not watch the Copilot user MCP configuration by default', () => { + assert.deepStrictEqual(readNativeDiscoveryPaths({}), []); + }); + + test('parses the Copilot user MCP configuration schema', async () => { + const definitions = await claudeConfigToServerDefinition('copilot', VSBuffer.fromString(JSON.stringify({ + mcpServers: { + 'local-server': { + type: 'local', + command: 'node', + args: ['server.js'], + env: { TOKEN: 'value' }, + tools: ['*'], + }, + 'remote-server': { + type: 'http', + url: 'https://example.com/mcp', + headers: { Authorization: 'value' }, + tools: ['*'], + }, + }, + }))); + + assert.deepStrictEqual(definitions?.map(definition => ({ + label: definition.label, + transport: definition.launch.type, + })), [ + { label: 'local-server', transport: McpServerTransportType.Stdio }, + { label: 'remote-server', transport: McpServerTransportType.HTTP }, + ]); + }); test('claudeConfigToServerDefinition forwards HTTP headers', async () => { const contents = VSBuffer.fromString(JSON.stringify({ From 49d49121bb45af30d577b9e5a616caedb1f53043 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 1 Sep 2026 16:00:01 -0700 Subject: [PATCH 02/23] Honor COPILOT_HOME in MCP discovery Use the configured Copilot home when locating mcp-config.json in both local and remote native discovery, while retaining ~/.copilot as the default location. Refs #333613 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcp/common/nativeMcpDiscoveryHelper.ts | 1 + .../node/nativeMcpDiscoveryHelperService.ts | 8 ++--- .../nativeMcpDiscoveryHelperService.test.ts | 30 +++++++++++++++++++ .../discovery/nativeMcpDiscoveryAbstract.ts | 1 + .../discovery/nativeMcpDiscoveryAdapters.ts | 4 +-- .../common/nativeMcpDiscoveryAdapters.test.ts | 14 ++++++++- 6 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts diff --git a/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts b/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts index 31fc9e2604a651..71bab1a57bd5c7 100644 --- a/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts +++ b/src/vs/platform/mcp/common/nativeMcpDiscoveryHelper.ts @@ -15,6 +15,7 @@ export interface INativeMcpDiscoveryData { // platform and homedir are duplicated by the remote/native environment, but here for convenience platform: Platform; homedir: URI; + copilotHome?: URI; winAppData?: URI; xdgHome?: URI; } diff --git a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts index 987f25f9a7cd33..71e4cca181566b 100644 --- a/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts +++ b/src/vs/platform/mcp/node/nativeMcpDiscoveryHelperService.ts @@ -4,30 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { homedir } from 'os'; -import { platform } from '../../../base/common/platform.js'; +import { IProcessEnvironment, platform } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { INativeMcpDiscoveryData, INativeMcpDiscoveryHelperService } from '../common/nativeMcpDiscoveryHelper.js'; export class NativeMcpDiscoveryHelperService implements INativeMcpDiscoveryHelperService { declare readonly _serviceBrand: undefined; - constructor() { } + constructor(private readonly environment: IProcessEnvironment = process.env) { } load(): Promise { return Promise.resolve({ platform, homedir: URI.file(homedir()), + copilotHome: this.uriFromEnvVariable('COPILOT_HOME'), winAppData: this.uriFromEnvVariable('APPDATA'), xdgHome: this.uriFromEnvVariable('XDG_CONFIG_HOME'), }); } private uriFromEnvVariable(varName: string) { - const envVar = process.env[varName]; + const envVar = this.environment[varName]; if (!envVar) { return undefined; } return URI.file(envVar); } } - diff --git a/src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts b/src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts new file mode 100644 index 00000000000000..4f1f2d350ce5d6 --- /dev/null +++ b/src/vs/platform/mcp/test/node/nativeMcpDiscoveryHelperService.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NativeMcpDiscoveryHelperService } from '../../node/nativeMcpDiscoveryHelperService.js'; + +suite('NativeMcpDiscoveryHelperService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads native configuration roots from the process environment', async () => { + const data = await new NativeMcpDiscoveryHelperService({ + COPILOT_HOME: '/custom/copilot', + APPDATA: '/custom/app-data', + XDG_CONFIG_HOME: '/custom/config', + }).load(); + + assert.deepStrictEqual({ + copilotHome: data.copilotHome?.path, + winAppData: data.winAppData?.path, + xdgHome: data.xdgHome?.path, + }, { + copilotHome: '/custom/copilot', + winAppData: '/custom/app-data', + xdgHome: '/custom/config', + }); + }); +}); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts index dc4f4f889bc9b5..0f0468a5294024 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAbstract.ts @@ -134,6 +134,7 @@ export abstract class NativeFilesystemMcpDiscovery extends FilesystemMcpDiscover const details: INativeMcpDiscoveryData = { ...detailsDto, homedir: URI.revive(detailsDto.homedir), + copilotHome: detailsDto.copilotHome ? URI.revive(detailsDto.copilotHome) : undefined, xdgHome: detailsDto.xdgHome ? URI.revive(detailsDto.xdgHome) : undefined, winAppData: detailsDto.winAppData ? URI.revive(detailsDto.winAppData) : undefined, }; diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index 0d202d8528c4ab..d294cd827eacf7 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -100,8 +100,8 @@ export class CopilotMpcDiscoveryAdapter extends ClaudeDesktopMpcDiscoveryAdapter this.id = `copilot.${this.remoteAuthority}`; } - override getFilePath({ homedir }: INativeMcpDiscoveryData): URI | undefined { - return URI.joinPath(homedir, '.copilot', 'mcp-config.json'); + override getFilePath({ copilotHome, homedir }: INativeMcpDiscoveryData): URI | undefined { + return URI.joinPath(copilotHome ?? URI.joinPath(homedir, '.copilot'), 'mcp-config.json'); } } diff --git a/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts b/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts index 53cc4493dd66c5..0fbcdd4242a076 100644 --- a/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/nativeMcpDiscoveryAdapters.test.ts @@ -32,7 +32,10 @@ class TestNativeFilesystemMcpDiscovery extends NativeFilesystemMcpDiscovery { suite('MCP Discovery - nativeMcpDiscoveryAdapters', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function readNativeDiscoveryPaths(discoverySources: boolean | Partial>): string[] { + function readNativeDiscoveryPaths( + discoverySources: boolean | Partial>, + details: Partial = {}, + ): string[] { const paths: string[] = []; const fileService = upcastPartial({ createWatcher: () => upcastPartial({ @@ -56,6 +59,7 @@ suite('MCP Discovery - nativeMcpDiscoveryAdapters', () => { discovery.setDetailsForTest({ platform: Platform.Linux, homedir: URI.file('/home/test'), + ...details, }); return paths; } @@ -82,6 +86,14 @@ suite('MCP Discovery - nativeMcpDiscoveryAdapters', () => { assert.deepStrictEqual(readNativeDiscoveryPaths({}), []); }); + test('watches the configured Copilot home instead of the default', () => { + assert.deepStrictEqual(readNativeDiscoveryPaths({ + [ExternalDiscoverySource.Copilot]: true, + }, { + copilotHome: URI.file('/custom/copilot'), + }), ['/custom/copilot/mcp-config.json']); + }); + test('parses the Copilot user MCP configuration schema', async () => { const definitions = await claudeConfigToServerDefinition('copilot', VSBuffer.fromString(JSON.stringify({ mcpServers: { From e565cb95913467158774c1fcfe23ab79af2d2023 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 1 Sep 2026 16:03:52 -0700 Subject: [PATCH 03/23] Rename Copilot MCP source kind Use CopilotHome to align the source identifier with the COPILOT_HOME environment variable that defines this configuration location. Refs #333613 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentHost/agentHostMcpServerSupport.ts | 8 ++++---- .../agentSessions/agentHostMcpServerSupport.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts index 02b49939d683e1..e678cc3162b180 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts @@ -57,7 +57,7 @@ export const enum AgentHostMcpServerSourceKind { WorkspaceConfiguration = 'workspaceConfiguration', WorkspaceDotMcp = 'workspaceDotMcp', ClaudeDesktop = 'claudeDesktop', - CopilotUser = 'copilotUser', + CopilotHome = 'copilotHome', Windsurf = 'windsurf', CursorUser = 'cursorUser', CursorWorkspace = 'cursorWorkspace', @@ -254,7 +254,7 @@ async function resolveMcpServerForAgentHostDelivery( ); } - if (source.kind === AgentHostMcpServerSourceKind.CopilotUser && isCopilotCliSessionType(sessionType)) { + if (source.kind === AgentHostMcpServerSourceKind.CopilotHome && isCopilotCliSessionType(sessionType)) { return createResolution(server, definition, source, applicability, AgentHostMcpServerDelivery.RuntimeDiscovered, supported()); } @@ -402,7 +402,7 @@ function getExternalConfigurationSourceKind(discoverySource: ExternalDiscoverySo case ExternalDiscoverySource.ClaudeDesktop: return AgentHostMcpServerSourceKind.ClaudeDesktop; case ExternalDiscoverySource.Copilot: - return AgentHostMcpServerSourceKind.CopilotUser; + return AgentHostMcpServerSourceKind.CopilotHome; case ExternalDiscoverySource.Windsurf: return AgentHostMcpServerSourceKind.Windsurf; case ExternalDiscoverySource.CursorGlobal: @@ -419,7 +419,7 @@ function getMcpServerSourceGroup(kind: AgentHostMcpServerSourceKind): AICustomiz case AgentHostMcpServerSourceKind.UserProfile: case AgentHostMcpServerSourceKind.RemoteUser: case AgentHostMcpServerSourceKind.ClaudeDesktop: - case AgentHostMcpServerSourceKind.CopilotUser: + case AgentHostMcpServerSourceKind.CopilotHome: case AgentHostMcpServerSourceKind.Windsurf: case AgentHostMcpServerSourceKind.CursorUser: return AICustomizationSources.user; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts index 58db9b6a1f4220..069ecda977230d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts @@ -78,7 +78,7 @@ suite('agentHostMcpServerSupport', () => { })), [ { name: 'mcp.config.usrlocal.user', source: AgentHostMcpServerSourceKind.UserProfile, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, { name: 'workspace-dot-mcp.0.root', source: AgentHostMcpServerSourceKind.WorkspaceDotMcp, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, - { name: 'copilot.null.user', source: AgentHostMcpServerSourceKind.CopilotUser, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, + { name: 'copilot.null.user', source: AgentHostMcpServerSourceKind.CopilotHome, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, { name: 'plugin.test/server', source: AgentHostMcpServerSourceKind.AgentPlugin, delivery: AgentHostMcpServerDelivery.AgentPlugin, compatibility: 'supported' }, { name: 'extension.server', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, { name: 'github', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ProviderBuiltIn, compatibility: 'supported' }, @@ -192,7 +192,7 @@ suite('agentHostMcpServerSupport', () => { delivery: result.delivery, compatibility: result.compatibility, }, { - source: AgentHostMcpServerSourceKind.CopilotUser, + source: AgentHostMcpServerSourceKind.CopilotHome, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: { kind: 'supported' }, }); From cfc47b16a2850cef6262d8ee9685cbb38b0970c7 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 1 Sep 2026 21:58:36 -0700 Subject: [PATCH 04/23] Fix MCP detail source for discovered servers Use the MCP collection origin when an installed server definition does not carry its own origin, while preserving definition-level precedence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/aiCustomization/mcpListWidget.ts | 8 +-- .../aiCustomization/mcpListWidget.test.ts | 53 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 5a5ae3ab13eda2..28aec0ee2b9923 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -987,15 +987,17 @@ function createBuiltinEntry(server: IMcpServer, activeSessionServer?: AgentHostM }; } -function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpServerDetailInput { +export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpServerDetailInput { if (entry.type === 'server-item') { return createWorkbenchMcpServerDetailInput(entry.server); } const activeSessionServer = getActiveSessionServer(entry); const localServer = entry.type === 'session-server-item' ? undefined : entry.localServer; - const localDefinition = localServer?.readDefinitions().get().server; - const localSource = localDefinition?.presentation?.origin; + const localDefinitions = localServer?.readDefinitions().get(); + const localDefinition = localDefinitions?.server; + const collectionOrigin = localDefinitions?.collection?.presentation?.origin; + const localSource = localDefinition?.presentation?.origin ?? (collectionOrigin ? { uri: collectionOrigin } : undefined); const activeSessionSource = activeSessionServer?.sourceUri ? { uri: activeSessionServer.sourceUri, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 2697adc59ee271..7d19ecffcb23ca 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -23,12 +23,13 @@ import { IAICustomizationWorkspaceService } from '../../../common/aiCustomizatio import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IMcpService, McpConnectionState } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpServer, IMcpService, McpConnectionState, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; import { DisableMcpServerForWorkspaceAction, DisableMcpServerGloballyAction, EnableMcpServerForWorkspaceAction, EnableMcpServerGloballyAction } from '../../../../mcp/browser/mcpServerActions.js'; import { AgentHostMcpServer, authenticateMcpServer, createBuiltinActiveSessionMcpEntries, + createInstalledMcpServerDetailInput, getActiveSessionServerLifecycleAction, getActiveSessionServerPresentation, getBuiltinMcpServerEnablementActions, @@ -96,6 +97,29 @@ function createMcpService(enablement: ContributionEnablementState): { service: I return { service, calls }; } +function createMcpDetailTestServer(definitionOrigin?: URI, collectionOrigin?: URI): IMcpServer { + const definitions = observableValue('definitions', { + server: { + id: 'server-1', + label: 'Server One', + launch: { + type: McpServerTransportType.Stdio, + command: 'server', + args: [], + env: {}, + }, + cacheNonce: 'server-1', + presentation: definitionOrigin ? { origin: { uri: definitionOrigin } } : undefined, + }, + collection: { + presentation: collectionOrigin ? { origin: collectionOrigin } : undefined, + }, + }); + return { + readDefinitions: () => definitions, + } as unknown as IMcpServer; +} + function runAction(action: IAction | undefined): void { assert.ok(action, 'expected an action to be defined'); void action.run(); @@ -193,6 +217,33 @@ suite('mcpListWidget', () => { }); }); + test('uses collection origin as the installed MCP detail fallback', () => { + const definitionOrigin = URI.file('/definition/mcp.json'); + const collectionOrigin = URI.file('/collection/mcp-config.json'); + const collectionFallback = createInstalledMcpServerDetailInput({ + type: 'builtin-item', + id: 'collection-origin', + label: 'Collection Origin', + description: '', + localServer: createMcpDetailTestServer(undefined, collectionOrigin), + }); + const definitionPrecedence = createInstalledMcpServerDetailInput({ + type: 'builtin-item', + id: 'definition-origin', + label: 'Definition Origin', + description: '', + localServer: createMcpDetailTestServer(definitionOrigin, collectionOrigin), + }); + + assert.deepStrictEqual({ + collectionFallback: collectionFallback.source, + definitionPrecedence: definitionPrecedence.source, + }, { + collectionFallback: { uri: collectionOrigin }, + definitionPrecedence: { uri: definitionOrigin }, + }); + }); + test('toggles MCP enablement without changing its scope', () => { assert.deepStrictEqual([ getToggledMcpEnablementState(ContributionEnablementState.EnabledProfile), From 845b798ec5db7d01755e071a1bbbe3d845ae43a7 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 1 Sep 2026 22:22:54 -0700 Subject: [PATCH 05/23] Remove CopilotHome source kind and related references from MCP server support --- .../agentHost/agentHostMcpServerSupport.ts | 8 ----- .../agentHostMcpServerSupport.test.ts | 32 +------------------ .../contrib/mcp/common/mcpConfiguration.ts | 2 +- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts index e678cc3162b180..13b83cb136f11f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostMcpServerSupport.ts @@ -57,7 +57,6 @@ export const enum AgentHostMcpServerSourceKind { WorkspaceConfiguration = 'workspaceConfiguration', WorkspaceDotMcp = 'workspaceDotMcp', ClaudeDesktop = 'claudeDesktop', - CopilotHome = 'copilotHome', Windsurf = 'windsurf', CursorUser = 'cursorUser', CursorWorkspace = 'cursorWorkspace', @@ -254,10 +253,6 @@ async function resolveMcpServerForAgentHostDelivery( ); } - if (source.kind === AgentHostMcpServerSourceKind.CopilotHome && isCopilotCliSessionType(sessionType)) { - return createResolution(server, definition, source, applicability, AgentHostMcpServerDelivery.RuntimeDiscovered, supported()); - } - if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection) && !McpCollectionDefinition.isVscodeMcpJson(collection)) { return createResolution( server, @@ -401,8 +396,6 @@ function getExternalConfigurationSourceKind(discoverySource: ExternalDiscoverySo switch (discoverySource) { case ExternalDiscoverySource.ClaudeDesktop: return AgentHostMcpServerSourceKind.ClaudeDesktop; - case ExternalDiscoverySource.Copilot: - return AgentHostMcpServerSourceKind.CopilotHome; case ExternalDiscoverySource.Windsurf: return AgentHostMcpServerSourceKind.Windsurf; case ExternalDiscoverySource.CursorGlobal: @@ -419,7 +412,6 @@ function getMcpServerSourceGroup(kind: AgentHostMcpServerSourceKind): AICustomiz case AgentHostMcpServerSourceKind.UserProfile: case AgentHostMcpServerSourceKind.RemoteUser: case AgentHostMcpServerSourceKind.ClaudeDesktop: - case AgentHostMcpServerSourceKind.CopilotHome: case AgentHostMcpServerSourceKind.Windsurf: case AgentHostMcpServerSourceKind.CursorUser: return AICustomizationSources.user; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts index 069ecda977230d..18492e31935025 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerSupport.test.ts @@ -14,7 +14,7 @@ import { ExtensionIdentifier } from '../../../../../../platform/extensions/commo import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../../../platform/storage/common/storage.js'; -import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostMcpServerEnablementState, AgentHostMcpServerSourceKind, AgentHostMcpSupportReason, assessMcpServersForCopilotAgentHost, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID, mergeInstalledMcpServersIntoAgentHostSupportAssessment, resolveMcpServersForAgentHostDelivery } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupport.js'; +import { AgentHostMcpServerApplicability, AgentHostMcpServerDelivery, AgentHostMcpServerEnablementState, AgentHostMcpServerSourceKind, AgentHostMcpSupportReason, assessMcpServersForCopilotAgentHost, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID, mergeInstalledMcpServersIntoAgentHostSupportAssessment } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupport.js'; import { AgentHostMcpServerSupportScope } from '../../../browser/agentSessions/agentHost/agentHostMcpServerSupportScope.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { ExternalDiscoverySource } from '../../../../mcp/common/mcpConfiguration.js'; @@ -41,13 +41,6 @@ suite('agentHostMcpServerSupport', () => { configTarget: ConfigurationTarget.WORKSPACE_FOLDER, collectionOrigin: URI.joinPath(root, '.mcp.json'), }), - makeMcpServer({ - id: 'copilot.null.user', - collectionId: 'copilot.null', - provenance: McpCollectionProvenance.ExternalConfiguration, - discoverySource: ExternalDiscoverySource.Copilot, - collectionOrigin: URI.file('/home/test/.copilot/mcp-config.json'), - }), makeMcpServer({ id: 'plugin.test/server', collectionId: 'plugin.file:///plugin', @@ -78,7 +71,6 @@ suite('agentHostMcpServerSupport', () => { })), [ { name: 'mcp.config.usrlocal.user', source: AgentHostMcpServerSourceKind.UserProfile, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, { name: 'workspace-dot-mcp.0.root', source: AgentHostMcpServerSourceKind.WorkspaceDotMcp, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, - { name: 'copilot.null.user', source: AgentHostMcpServerSourceKind.CopilotHome, delivery: AgentHostMcpServerDelivery.RuntimeDiscovered, compatibility: 'supported' }, { name: 'plugin.test/server', source: AgentHostMcpServerSourceKind.AgentPlugin, delivery: AgentHostMcpServerDelivery.AgentPlugin, compatibility: 'supported' }, { name: 'extension.server', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ClientForwarded, compatibility: 'supported' }, { name: 'github', source: AgentHostMcpServerSourceKind.Extension, delivery: AgentHostMcpServerDelivery.ProviderBuiltIn, compatibility: 'supported' }, @@ -176,28 +168,6 @@ suite('agentHostMcpServerSupport', () => { ]); }); - test('forwards Copilot user configuration to agent hosts that do not discover it', async () => { - const [result] = await resolveMcpServersForAgentHostDelivery([ - makeMcpServer({ - id: 'copilot.null.user', - collectionId: 'copilot.null', - provenance: McpCollectionProvenance.ExternalConfiguration, - discoverySource: ExternalDiscoverySource.Copilot, - collectionOrigin: URI.file('/home/test/.copilot/mcp-config.json'), - }), - ], makeConfigurationResolverService(), 'agent-host-claude', []); - - assert.deepStrictEqual({ - source: result.source.kind, - delivery: result.delivery, - compatibility: result.compatibility, - }, { - source: AgentHostMcpServerSourceKind.CopilotHome, - delivery: AgentHostMcpServerDelivery.ClientForwarded, - compatibility: { kind: 'supported' }, - }); - }); - test('keeps scope applicability separate from configuration compatibility', async () => { const server = makeMcpServer({ id: 'mcp.config.ws0.server', diff --git a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts index 8dabc9290855a3..4be492843164f8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts @@ -49,7 +49,7 @@ export const discoverySourceLabel: Record = { }; export const discoverySourceSettingsLabel: Record = { [ExternalDiscoverySource.ClaudeDesktop]: localize('mcp.discovery.source.claude-desktop.config', "Claude Desktop configuration (`claude_desktop_config.json`)"), - [ExternalDiscoverySource.Copilot]: localize('mcp.discovery.source.copilot.config', "GitHub Copilot CLI configuration (`~/.copilot/mcp-config.json`)"), + [ExternalDiscoverySource.Copilot]: localize('mcp.discovery.source.copilot.config', "GitHub Copilot CLI configuration (`mcp-config.json` in `COPILOT_HOME`, or `~/.copilot/mcp-config.json` when unset)"), [ExternalDiscoverySource.Windsurf]: localize('mcp.discovery.source.windsurf.config', "Windsurf configurations (`~/.codeium/windsurf/mcp_config.json`)"), [ExternalDiscoverySource.CursorGlobal]: localize('mcp.discovery.source.cursor-global.config', "Cursor global configuration (`~/.cursor/mcp.json`)"), [ExternalDiscoverySource.CursorWorkspace]: localize('mcp.discovery.source.cursor-workspace.config', "Cursor workspace configuration (`.cursor/mcp.json`)"), From 1b7f001f6cf1cae59263ff33debf84cbb3e6af1f Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:25:12 +0200 Subject: [PATCH 06/23] Agents - move more actions to the right menu (#333962) --- .../changes/browser/changesViewActions.ts | 12 ++++----- .../sessionsChangesAccessibilityHelp.ts | 2 +- .../test/browser/changesViewActions.test.ts | 26 +++++++++---------- .../browser/codeReview.contributions.ts | 2 +- .../test/browser/codeReviewService.test.ts | 16 ++++++------ .../multiDiffEditor/browser/actions.ts | 4 +-- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index addcd03f330ed2..6d1edb789dcb4b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -266,8 +266,8 @@ class CollapseAllSessionChangesDiffsAction extends Action2 { icon: Codicon.collapseAll, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '1_diff', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/1_diff', order: 10, when: ContextKeyExpr.and( singlePaneChangesEditorTitleVisible, @@ -296,8 +296,8 @@ class ExpandAllSessionChangesDiffsAction extends Action2 { icon: Codicon.expandAll, f1: false, menu: { - id: Menus.SessionsEditorTitle, - group: '1_diff', + id: Menus.SessionsEditorHeaderLayout, + group: 'secondary/1_diff', order: 10, when: ContextKeyExpr.and( singlePaneChangesEditorActive, @@ -325,7 +325,7 @@ registerAction2(ExpandAllSessionChangesDiffsAction); // The action changes the preferred layout. Side by side still falls back to inline // when the editor is narrow, so the label must not promise an immediate layout. -MenuRegistry.appendMenuItem(Menus.SessionsEditorTitle, { +MenuRegistry.appendMenuItem(Menus.SessionsEditorHeaderLayout, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, title: localize('alwaysShowInlineDiff', "Always Show Inline Diff"), @@ -333,7 +333,7 @@ MenuRegistry.appendMenuItem(Menus.SessionsEditorTitle, { icon: Codicon.diffSidebyside, toggled: SessionsDiffRenderSideBySideContext.negate(), }, - group: '1_diff', + group: 'secondary/1_diff', order: 20, when: singlePaneDiffEditorTitleVisible }); diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts index 0edea0d059055f..bb1bd3d93546ff 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -36,7 +36,7 @@ export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplemen content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); content.push(localize('sessionsChanges.operations', "When available, the Changes toolbar or editor title bar also provides actions to commit, merge, sync, or create a pull request. When Agent Merge is the primary action, activate it to toggle Agent Merge and use its dropdown to configure it. Use Tab and Shift+Tab to move between the file list and toolbar actions.")); content.push(layoutService.isSinglePaneLayoutEnabled - ? localize('sessionsChanges.diffView.singlePane', "File diffs can prefer side-by-side or inline layout. Unless screen reader optimized mode is enabled, side-by-side diffs automatically use inline layout when space is limited. Use Always Show Inline Diff in the editor title bar's More Actions menu, or use the Toggle Preferred Diff View command to switch the preference{0}.", '') + ? localize('sessionsChanges.diffView.singlePane', "File diffs can prefer side-by-side or inline layout. Unless screen reader optimized mode is enabled, side-by-side diffs automatically use inline layout when space is limited. Use Always Show Inline Diff in the editor header's More Actions menu, or use the Toggle Preferred Diff View command to switch the preference{0}.", '') : localize('sessionsChanges.diffView.classic', "File diffs can use side-by-side or inline layout. Use Inline View in the editor title area's More Actions menu, or use the Toggle Inline View command to switch the layout{0}.", '')); return new AccessibleContentProvider( diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index 8687a09cd6ca75..454f6d49b384c1 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -135,12 +135,12 @@ suite('Changes View Actions', () => { ]); }); - test('collapse all diffs is contributed to the editor title bar overflow menu', () => { - const item = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + test('collapse all diffs is contributed to the editor header layout overflow menu', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) .filter(isIMenuItem) .find(item => item.command.id === 'workbench.action.agentSessions.collapseAllDiffs'); - assert.ok(item, 'expected collapse all diffs action in the editor title bar overflow menu'); + assert.ok(item, 'expected collapse all diffs action in the editor header layout overflow menu'); const when = item.when?.serialize() ?? ''; assert.deepStrictEqual({ group: item.group, @@ -151,7 +151,7 @@ suite('Changes View Actions', () => { hasSinglePaneConfigGate: when.includes(SinglePaneLayoutEnabledContext.key), hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), }, { - group: '1_diff', + group: 'secondary/1_diff', order: 10, icon: Codicon.collapseAll.id, hasSessionsWindowGate: true, @@ -161,12 +161,12 @@ suite('Changes View Actions', () => { }); }); - test('expand all diffs is contributed to the editor title bar overflow menu', () => { - const item = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + test('expand all diffs is contributed to the editor header layout overflow menu', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) .filter(isIMenuItem) .find(item => item.command.id === 'workbench.action.agentSessions.expandAllDiffs'); - assert.ok(item, 'expected expand all diffs action in the editor title bar overflow menu'); + assert.ok(item, 'expected expand all diffs action in the editor header layout overflow menu'); const when = item.when?.serialize() ?? ''; assert.deepStrictEqual({ group: item.group, @@ -178,7 +178,7 @@ suite('Changes View Actions', () => { hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), hasAllCollapsedGate: when.includes(EditorContextKeys.multiDiffEditorAllCollapsed.key), }, { - group: '1_diff', + group: 'secondary/1_diff', order: 10, icon: Codicon.expandAll.id, hasSessionsWindowGate: true, @@ -189,12 +189,12 @@ suite('Changes View Actions', () => { }); }); - test('always show inline diff is contributed to the editor title bar overflow menu for multi-file and single-file diffs', () => { - const item = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + test('always show inline diff is contributed to the editor header layout overflow menu for multi-file and single-file diffs', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) .filter(isIMenuItem) .find(item => item.command.id === 'toggle.diff.renderSideBySide'); - assert.ok(item, 'expected the preferred diff view action in the editor title bar overflow menu'); + assert.ok(item, 'expected the preferred diff view action in the editor header layout overflow menu'); const when = item.when?.serialize() ?? ''; const toggled = item.command.toggled; const toggledCondition = isICommandActionToggleInfo(toggled) ? toggled.condition : toggled; @@ -228,7 +228,7 @@ suite('Changes View Actions', () => { }, { id: 'toggle.diff.renderSideBySide', title: 'Always Show Inline Diff', - group: '1_diff', + group: 'secondary/1_diff', order: 20, icon: Codicon.diffSidebyside.id, tooltip: 'Always uses inline layout.', @@ -286,7 +286,7 @@ suite('Changes View Actions', () => { } test('Changes accessibility help describes the single-pane diff action', () => { - assert.strictEqual(getChangesAccessibilityHelp(true).includes('Use Always Show Inline Diff in the editor title bar\'s More Actions menu'), true); + assert.strictEqual(getChangesAccessibilityHelp(true).includes('Use Always Show Inline Diff in the editor header\'s More Actions menu'), true); }); test('Changes accessibility help describes the classic diff action', () => { diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index db89b36aced01b..840484f5a354bc 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -71,7 +71,7 @@ class RunSessionCodeReviewAction extends Action2 { when: codeReviewChangesToolbarWhen, }, { - id: Menus.SessionsEditorTitle, + id: Menus.SessionsEditorHeaderLayout, group: 'navigation', order: 10, when: singlePaneCodeReviewWhen, diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts index 7f8f11c8b799e5..f5f6016555973a 100644 --- a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -415,24 +415,24 @@ suite('Code Review Contributions', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('Run Code Review is contributed to the editor title bar', () => { - const titleItem = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + test('Run Code Review is contributed to the editor header layout actions', () => { + const headerItem = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderLayout) .filter(isIMenuItem) .find(item => item.command.id === 'sessions.codeReview.run'); - assert.ok(titleItem, 'expected Run Code Review in the editor title bar'); - const when = titleItem.when?.serialize() ?? ''; + assert.ok(headerItem, 'expected Run Code Review in the editor header layout actions'); + const when = headerItem.when?.serialize() ?? ''; const enablementContext = new Context(1, null); enablementContext.setValue(ChatContextKeys.hasAgentSessionChanges.key, false); enablementContext.setValue(SessionHasChangesContext.key, true); - const enabledFromSessionChanges = titleItem.command.precondition?.evaluate(enablementContext); + const enabledFromSessionChanges = headerItem.command.precondition?.evaluate(enablementContext); enablementContext.setValue(ChatContextKeys.hasAgentSessionChanges.key, true); enablementContext.setValue(SessionHasChangesContext.key, false); assert.deepStrictEqual({ - group: titleItem.group, - order: titleItem.order, + group: headerItem.group, + order: headerItem.order, enabledFromSessionChanges, - enabledFromChatChanges: titleItem.command.precondition?.evaluate(enablementContext), + enabledFromChatChanges: headerItem.command.precondition?.evaluate(enablementContext), hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditorInput.EDITOR_ID), hasSinglePaneLayoutGate: when.includes(SinglePaneLayoutEnabledContext.key), diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts index 9e00ce25cba75e..413bd9e3d7acbc 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/actions.ts @@ -187,7 +187,7 @@ export class CollapseAllAction extends Action2 { icon: Codicon.collapseAll, precondition: ContextKeyExpr.and(ContextKeyExpr.equals('activeEditor', MultiDiffEditor.ID), ContextKeyExpr.not('multiDiffEditorAllCollapsed')), menu: [ - // In the agents window this action lives in the editor title overflow (...) menu instead of as a primary toolbar icon. + // In the agents window this action lives in the editor header overflow (...) menu instead of as a primary toolbar icon. { id: MenuId.EditorTitle, when: ContextKeyExpr.and(ContextKeyExpr.equals('activeEditor', MultiDiffEditor.ID), ContextKeyExpr.not('multiDiffEditorAllCollapsed'), IsSessionsWindowContext.toNegated()), @@ -236,7 +236,7 @@ export class ExpandAllAction extends Action2 { icon: Codicon.expandAll, precondition: ContextKeyExpr.and(ContextKeyExpr.equals('activeEditor', MultiDiffEditor.ID), ContextKeyExpr.has('multiDiffEditorAllCollapsed')), menu: [ - // In the agents window this action lives in the editor title overflow (...) menu instead of as a primary toolbar icon. + // In the agents window this action lives in the editor header overflow (...) menu instead of as a primary toolbar icon. { id: MenuId.EditorTitle, when: ContextKeyExpr.and(ContextKeyExpr.equals('activeEditor', MultiDiffEditor.ID), ContextKeyExpr.has('multiDiffEditorAllCollapsed'), IsSessionsWindowContext.toNegated()), From c3a0ee2b9889e58a2640b16087e91ccbea8e2121 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:04:22 +0200 Subject: [PATCH 07/23] Refactor .monaco-icon-label styles to remove unnecessary gap and padding (#333960) --- src/vs/workbench/contrib/chat/browser/widget/media/chat.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index b4debcf921d83b..ca62d8b89c30d3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3248,11 +3248,9 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-icon-label { padding: 0 var(--vscode-spacing-size40); - gap: 2px; &::before { width: var(--vscode-codiconFontSize); - padding-right: 0; } } } From 359cba2f0b72a099db9d2fa502de4a2f09894908 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:00:21 +0200 Subject: [PATCH 08/23] Add ready message when creating from PR (#333968) * Add ready message when creating from PR Fixes #331194 * Address comment --- .../sessions/contrib/chat/browser/chatView.ts | 28 +++++++++++++------ .../chat/test/browser/chatView.test.ts | 21 +++++++++++++- .../github/browser/pullRequestPicker.ts | 1 + .../test/browser/pullRequestPicker.test.ts | 2 ++ .../contrib/chat/browser/widget/chatWidget.ts | 5 ++-- .../common/attachments/chatVariableEntries.ts | 4 +++ .../stateToProgressAdapter.test.ts | 3 ++ 7 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 750e7aec47a58b..772a21f6d48e86 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -30,7 +30,7 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../workbench/common/theme.js'; import { chatPersistentContentVisibleClass, ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; import { setModelPreservingInputTypedWhileLoading } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { IChatModelReference, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatModelReference, IChatService, ResponseModelState } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { isChatTranscriptContextVariableEntry, IChatRequestTranscriptContextVariableEntry, IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; @@ -338,6 +338,11 @@ export class ChatView extends AbstractChatView { const activity = typeof statusMessage === 'string' ? statusMessage : statusMessage ? renderAsPlaintext(statusMessage) : undefined; const model = chatModel.read(reader); let showProgress: boolean; + let requestCount = 0; + let visibleRequestCount = 0; + let hiddenRequestIncomplete: boolean | undefined; + let hiddenRequestState: ResponseModelState | undefined; + let readyMessage: string | undefined; if (!resource) { showProgress = false; } else if (!model) { @@ -345,14 +350,17 @@ export class ChatView extends AbstractChatView { } else { const requests = model.getRequests(); const lastRequest = model.lastRequestObs.read(reader); - const visibleRequestCount = requests.filter(request => !request.isRequestHiddenFromTranscript).length; - const hiddenRequestIncomplete = lastRequest?.isRequestHiddenFromTranscript - ? lastRequest.response?.isIncomplete.read(reader) - : undefined; - showProgress = shouldShowTranscriptPreparationProgress(requests.length, visibleRequestCount, hiddenRequestIncomplete); + requestCount = requests.length; + visibleRequestCount = requests.filter(request => !request.isRequestHiddenFromTranscript).length; + const hiddenResponse = lastRequest?.isRequestHiddenFromTranscript ? lastRequest.response : undefined; + hiddenRequestIncomplete = hiddenResponse?.isIncomplete.read(reader); + hiddenRequestState = hiddenResponse?.state; + readyMessage = findTranscriptContextEntry(requests.filter(request => request.isHiddenFromTranscript))?.readyMessage?.trim(); + showProgress = shouldShowTranscriptPreparationProgress(requestCount, visibleRequestCount, hiddenRequestIncomplete); } - const progress = getTranscriptProgress(showProgress, activity); - this._widget.setTranscriptProgress(progress, progress); + const showCompletion = shouldShowTranscriptPreparationCompletion(requestCount, visibleRequestCount, hiddenRequestState, readyMessage); + const progress = showCompletion ? readyMessage : getTranscriptProgress(showProgress, activity); + this._widget.setTranscriptProgress(progress, progress, showCompletion ? { complete: true } : undefined); })); } @@ -652,6 +660,10 @@ export function shouldShowTranscriptPreparationProgress(requestCount: number, vi return requestCount === 0 || (visibleRequestCount === 0 && hiddenRequestIncomplete !== false); } +export function shouldShowTranscriptPreparationCompletion(requestCount: number, visibleRequestCount: number, hiddenRequestState: ResponseModelState | undefined, readyMessage: string | undefined): boolean { + return requestCount > 0 && visibleRequestCount === 0 && hiddenRequestState === ResponseModelState.Complete && !!readyMessage; +} + export function getTranscriptProgress(showProgress: boolean, activity: string | undefined): string | undefined { if (!showProgress) { return undefined; diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index 70581bab8a8253..a161588cb102ae 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -13,9 +13,10 @@ import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT } from '../../../../../workbench/con import { IChatRequestTranscriptContextVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { ResponseModelState } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionsChatBackgroundRenderer } from '../../../../services/chatBackground/browser/chatBackgroundRenderer.js'; -import { findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; +import { findInitialTranscriptContextEntry, findTranscriptContextEntry, getTranscriptProgress, NewChatView, shouldShowSessionChatTip, shouldShowTranscriptPreparationCompletion, shouldShowTranscriptPreparationProgress } from '../../browser/chatView.js'; import { SessionsChatViewStateService } from '../../browser/chatViewStateService.js'; import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; import { NewChatWidget } from '../../browser/newChatWidget.js'; @@ -627,6 +628,24 @@ suite('Sessions - Chat View', () => { }); }); + test('shows transcript preparation completion until visible content appears', () => { + assert.deepStrictEqual({ + hiddenComplete: shouldShowTranscriptPreparationCompletion(1, 0, ResponseModelState.Complete, 'Session ready'), + hiddenPending: shouldShowTranscriptPreparationCompletion(1, 0, ResponseModelState.Pending, 'Session ready'), + hiddenFailed: shouldShowTranscriptPreparationCompletion(1, 0, ResponseModelState.Failed, 'Session ready'), + hiddenCancelled: shouldShowTranscriptPreparationCompletion(1, 0, ResponseModelState.Cancelled, 'Session ready'), + visibleRequest: shouldShowTranscriptPreparationCompletion(2, 1, ResponseModelState.Complete, 'Session ready'), + noReadyMessage: shouldShowTranscriptPreparationCompletion(1, 0, ResponseModelState.Complete, undefined), + }, { + hiddenComplete: true, + hiddenPending: false, + hiddenFailed: false, + hiddenCancelled: false, + visibleRequest: false, + noReadyMessage: false, + }); + }); + test('shows the session-list status message in the pre-request progress surface', () => { assert.deepStrictEqual({ fallback: getTranscriptProgress(true, 'Working...'), diff --git a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts index 9b380ba20de85c..69e038687094fb 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts @@ -185,6 +185,7 @@ export function createPullRequestContextAttachment(context: IGitHubPullRequestCo icon: Codicon.gitPullRequest, uri: URI.parse(context.url), tooltip: localize('pullRequest.context.tooltip', "Pull request #{0} by @{1}", context.number, context.author), + readyMessage: localize('pullRequest.sessionReady', "Session ready. Pull request #{0} is checked out and attached.", context.number), }; } diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts index d4479d95506b33..9147bb8cd88341 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts @@ -165,6 +165,7 @@ suite('Create Session from Pull Request', () => { fullName: attachment.fullName, icon: attachment.icon?.id, uri: attachment.uri.toString(), + readyMessage: attachment.readyMessage, value: JSON.parse(attachment.value ?? ''), }, { kind: 'transcriptContext', @@ -172,6 +173,7 @@ suite('Create Session from Pull Request', () => { fullName: '#42 Improve sessions', icon: 'git-pull-request', uri: 'https://github.com/owner/repo/pull/42', + readyMessage: 'Session ready. Pull request #42 is checked out and attached.', value: { usageInstructions: 'Use this snapshot as the primary source for questions about the pull request. Do not fetch pull request data or run tools unless the user explicitly asks for refreshed information or the requested information is absent from this snapshot.', owner: 'owner', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index b979b5aaea5968..b8c50f6e8d122c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1484,7 +1484,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return (this.viewModel?.getItems().length ?? 0) === 0; } - setTranscriptProgress(message: string | undefined, ariaLabel = message): void { + setTranscriptProgress(message: string | undefined, ariaLabel = message, options?: { readonly complete?: boolean }): void { if (!this.transcriptProgress) { const container = dom.append(this.listContainer, $('.chat-transcript-progress')); container.hidden = true; @@ -1501,7 +1501,8 @@ export class ChatWidget extends Disposable implements IChatWidget { const renderer = this.instantiationService.createInstance(ChatContentMarkdownRenderer); const renderedMessage = store.add(renderer.render(new MarkdownString().appendText(message))); const progressPart = store.add(this.instantiationService.createInstance(ChatProgressSubPart, renderedMessage.element, Codicon.check, undefined)); - progressPart.domNode.classList.add('shimmer-progress'); + progressPart.domNode.classList.toggle('shimmer-progress', options?.complete !== true); + progressPart.domNode.classList.toggle('show-checkmarks', options?.complete === true); dom.append(this.transcriptProgress.content, progressPart.domNode); this.transcriptProgressPart.value = store; } diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 89727f98189d8c..3ed021492d1a8b 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -283,6 +283,8 @@ export interface IChatRequestTranscriptContextVariableEntry extends IBaseChatReq readonly value: string; readonly uri: URI; readonly tooltip?: string; + /** Message shown when hidden transcript preparation for this context completes. */ + readonly readyMessage?: string; } export interface IChatRequestWorkspaceVariableEntry extends IBaseChatRequestVariableEntry { @@ -819,6 +821,7 @@ export function toChatTranscriptContextAttachmentMeta(entry: IChatRequestTranscr iconId: entry.icon?.id, tooltip: entry.tooltip, fullName: entry.fullName, + readyMessage: entry.readyMessage, }, }; } @@ -839,6 +842,7 @@ export function restoreChatTranscriptContextVariableEntry(label: string, value: ...(typeof record.fullName === 'string' ? { fullName: record.fullName } : {}), ...(typeof record.iconId === 'string' ? { icon: ThemeIcon.fromId(record.iconId) } : {}), ...(typeof record.tooltip === 'string' ? { tooltip: record.tooltip } : {}), + ...(typeof record.readyMessage === 'string' ? { readyMessage: record.readyMessage } : {}), value, uri: URI.parse(record.uri), _meta: meta, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 33e355a0dcc998..c334b8822a4005 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -157,6 +157,7 @@ suite('stateToProgressAdapter', () => { fullName: '#42 Improve sessions', icon: Codicon.gitPullRequest, tooltip: 'Pull request #42 by @author', + readyMessage: 'Session ready', value: '{"number":42}', uri: URI.parse('https://github.com/owner/repo/pull/42'), }; @@ -177,6 +178,7 @@ suite('stateToProgressAdapter', () => { value: restored.value, uri: restored.kind === 'transcriptContext' ? restored.uri.toString() : undefined, tooltip: restored.kind === 'transcriptContext' ? restored.tooltip : undefined, + readyMessage: restored.kind === 'transcriptContext' ? restored.readyMessage : undefined, }, { kind: 'transcriptContext', name: '#42 Improve sessions', @@ -185,6 +187,7 @@ suite('stateToProgressAdapter', () => { value: '{"number":42}', uri: 'https://github.com/owner/repo/pull/42', tooltip: 'Pull request #42 by @author', + readyMessage: 'Session ready', }); }); From 9dfde22995466b642f6dbd06b245bcc6591bd487 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:49:21 +0200 Subject: [PATCH 09/23] Implement file icons in component fixtures (#333969) * Agent Host changes for benibenj/agents/fix-component-fixtures-file-icons * Fix file icons in component fixtures documentation --- build/rspack/rspack.serve-out.config.mts | 8 ++- .../browser/componentFixtures/fixtureUtils.ts | 72 ++++++++++++++----- .../componentFixtures/fixtureUtilsCss.ts | 12 +++- .../blocks-ci-screenshots.md | 4 +- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 760c9880d12214..58125249708aab 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -103,10 +103,14 @@ export default { }, }, { - // Built-in theme JSON files use JSONC (comments / trailing + test: /\.woff$/, + type: 'asset/resource', + }, + { + // Built-in color and file icon theme JSON files use JSONC (comments / trailing // commas), so import them as raw strings and let VS Code's // JSON parser handle them. - test: /[\\/]extensions[\\/]theme-defaults[\\/]themes[\\/].*\.json$/, + test: /[\\/]extensions[\\/](?:theme-defaults[\\/]themes|theme-seti[\\/]icons)[\\/].*\.json$/, type: 'asset/source', }, ], diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 3f4243617fae3a..837d65b948f955 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -9,6 +9,7 @@ import { defineFixture, defineFixtureGroup, defineFixtureVariants } from '@vscod // eslint-disable-next-line local/code-import-patterns, local/code-amd-node-module import { z } from 'zod'; import { DisposableStore, DisposableTracker, IDisposable, IReference, MutableDisposable, setDisposableTracker, toDisposable } from '../../../../base/common/lifecycle.js'; +import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ModifierKeyEmitter } from '../../../../base/browser/dom.js'; // eslint-disable-next-line local/code-import-patterns @@ -25,6 +26,7 @@ import { IEnvironmentService } from '../../../../platform/environment/common/env import { IExtensionResourceLoaderService } from '../../../../platform/extensionResourceLoader/common/extensionResourceLoader.js'; import { ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { FileIconThemeData, FileIconThemeLoader } from '../../../services/themes/browser/fileIconThemeData.js'; import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; import { ExtensionData } from '../../../services/themes/common/workbenchThemeService.js'; import { ensureGlobalStylesInstalled, getStylesheetDocumentFiles, overrideStylesheetOrder, ReverseStylesheetsOption } from './fixtureUtilsCss.js'; @@ -253,8 +255,8 @@ class NullStorageService implements IStorageService { // Themes // ============================================================================ -// Eagerly bundle all built-in theme JSON files so they can be served to -// `_loadColorTheme` via the IExtensionResourceLoaderService code path. The +// Eagerly bundle the built-in color and file icon theme JSON files so they can +// be served through the IExtensionResourceLoaderService code path. The // rspack config maps these JSON files to `asset/source`, so they are imported // as raw text (not parsed JSON) — this lets VS Code's JSONC parser handle // comments and trailing commas the way it does in the real product. @@ -266,16 +268,32 @@ import hc_black from '../../../../../../extensions/theme-defaults/themes/hc_blac import light_modern from '../../../../../../extensions/theme-defaults/themes/light_modern.json' with { type: 'json' }; import light_plus from '../../../../../../extensions/theme-defaults/themes/light_plus.json' with { type: 'json' }; import light_vs from '../../../../../../extensions/theme-defaults/themes/light_vs.json' with { type: 'json' }; +import vsSetiIconTheme from '../../../../../../extensions/theme-seti/icons/vs-seti-icon-theme.json' with { type: 'json' }; /* eslint-enable local/code-import-patterns */ +function toThemeJsonText(theme: string | object): string { + return typeof theme === 'string' ? theme : JSON.stringify(theme); +} + +// Keep the synthetic theme beside the emitted font so its relative asset URL resolves in every build mode. +const setiFontResource = URI.parse(new URL('../../../../../../extensions/theme-seti/icons/seti.woff', import.meta.url).href); +const setiFileIconThemeLocation = joinPath(dirname(setiFontResource), 'vs-seti-icon-theme.json'); +const setiFontSource = JSON.stringify('./seti.woff'); +const setiFontTarget = JSON.stringify(`./${basename(setiFontResource)}`); +const setiFileIconThemeJson = toThemeJsonText(vsSetiIconTheme); +if (!setiFileIconThemeJson.includes(setiFontSource)) { + throw new Error('Seti file icon theme does not reference its expected font.'); +} + const themeJsonModules: Record = { - '/extensions/theme-defaults/themes/dark_modern.json': dark_modern as unknown as string, - '/extensions/theme-defaults/themes/dark_plus.json': dark_plus as unknown as string, - '/extensions/theme-defaults/themes/dark_vs.json': dark_vs as unknown as string, - '/extensions/theme-defaults/themes/hc_black.json': hc_black as unknown as string, - '/extensions/theme-defaults/themes/light_modern.json': light_modern as unknown as string, - '/extensions/theme-defaults/themes/light_plus.json': light_plus as unknown as string, - '/extensions/theme-defaults/themes/light_vs.json': light_vs as unknown as string, + '/extensions/theme-defaults/themes/dark_modern.json': toThemeJsonText(dark_modern), + '/extensions/theme-defaults/themes/dark_plus.json': toThemeJsonText(dark_plus), + '/extensions/theme-defaults/themes/dark_vs.json': toThemeJsonText(dark_vs), + '/extensions/theme-defaults/themes/hc_black.json': toThemeJsonText(hc_black), + '/extensions/theme-defaults/themes/light_modern.json': toThemeJsonText(light_modern), + '/extensions/theme-defaults/themes/light_plus.json': toThemeJsonText(light_plus), + '/extensions/theme-defaults/themes/light_vs.json': toThemeJsonText(light_vs), + [setiFileIconThemeLocation.path]: setiFileIconThemeJson.replaceAll(setiFontSource, setiFontTarget), }; const fixtureExtensionResourceLoaderService = new class implements IExtensionResourceLoaderService { @@ -304,6 +322,11 @@ function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector): Colo export const darkTheme = createBuiltInTheme('/extensions/theme-defaults/themes/dark_modern.json', ThemeTypeSelector.VS_DARK); export const lightTheme = createBuiltInTheme('/extensions/theme-defaults/themes/light_modern.json', ThemeTypeSelector.VS); const darkHighContrastTheme = createBuiltInTheme('/extensions/theme-defaults/themes/hc_black.json', ThemeTypeSelector.HC_BLACK); +const defaultFileIconTheme = FileIconThemeData.fromExtensionTheme( + { id: 'vs-seti', path: './vs-seti-icon-theme.json', _watch: false }, + setiFileIconThemeLocation, + ExtensionData.fromName('vscode', 'vscode-theme-seti', true) +); type ComponentFixtureThemeVariant = { readonly label: string; @@ -330,10 +353,29 @@ function ensureThemeLoaded(theme: ColorThemeData): Promise { return themeLoadedPromise; } +let defaultFileIconThemeLoadedPromise: Promise | undefined; +function ensureDefaultFileIconThemeLoaded(): Promise { + return defaultFileIconThemeLoadedPromise ??= (async () => { + const languageService = new LanguageService(); + try { + const styleSheetContent = await defaultFileIconTheme.ensureLoaded(new FileIconThemeLoader(fixtureExtensionResourceLoaderService, languageService)); + if (styleSheetContent === undefined) { + throw new Error('Seti file icon theme did not produce a stylesheet.'); + } + return styleSheetContent; + } finally { + languageService.dispose(); + } + })(); +} + export async function setupTheme(container: HTMLElement, theme: ColorThemeData, scopeThemingParticipants = false): Promise { - await ensureThemeLoaded(theme); - await ensureGlobalStylesInstalled(theme, scopeThemingParticipants); - container.classList.add('component-fixture', 'monaco-workbench', getPlatformClass(), 'disable-animations', ...theme.classNames); + const [, fileIconThemeStyleSheetContent] = await Promise.all([ + ensureThemeLoaded(theme), + ensureDefaultFileIconThemeLoaded(), + ]); + await ensureGlobalStylesInstalled(theme, scopeThemingParticipants, fileIconThemeStyleSheetContent); + container.classList.add('component-fixture', 'monaco-workbench', 'file-icons-enabled', getPlatformClass(), 'disable-animations', ...theme.classNames); } /** @@ -546,11 +588,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre define(IConfigurationService, TestConfigurationService); define(ITextResourcePropertiesService, TestTextResourcePropertiesService); defineInstance(IStorageService, new NullStorageService()); - if (options?.colorTheme) { - defineInstance(IThemeService, new TestThemeService(options.colorTheme)); - } else { - define(IThemeService, TestThemeService); - } + defineInstance(IThemeService, new TestThemeService(options?.colorTheme, defaultFileIconTheme)); define(ILogService, FixtureLogService); define(IModelService, FixtureModelService); define(ICodeEditorService, TestCodeEditorService); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts index f44dfbd2ecf382..0bc6ffb1bb3511 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts @@ -25,6 +25,7 @@ const activeOverrides: { }[] = []; let originalDisabledStates: readonly boolean[] | undefined; let iconsStyleSheetCache: CSSStyleSheet | undefined; +let fileIconThemeStyleSheetCache: CSSStyleSheet | undefined; const themeStyleSheetCache = new WeakMap(); const installedThemes = new WeakSet(); @@ -175,6 +176,14 @@ function getIconsStyleSheetCached(): CSSStyleSheet { return iconsStyleSheetCache; } +function getFileIconThemeStyleSheetCached(styleSheetContent: string): CSSStyleSheet { + if (!fileIconThemeStyleSheetCache) { + fileIconThemeStyleSheetCache = new CSSStyleSheet(); + fileIconThemeStyleSheetCache.replaceSync(styleSheetContent); + } + return fileIconThemeStyleSheetCache; +} + function createScopedThemingParticipant(scopeSelector: string, scopeRootSelector: string, participants: readonly IThemingParticipant[]): IThemingParticipant { return (theme, collector, environment) => { const rules = new Set(); @@ -212,7 +221,7 @@ function getThemeStyleSheet(theme: ColorThemeData, scopeThemingParticipants: boo * Installs shared global styles once and appends a scoped stylesheet for each newly requested theme. * The reversal overlay keeps a stable identity and position for {@link overrideStylesheetOrder}. */ -export async function ensureGlobalStylesInstalled(theme: ColorThemeData, scopeThemingParticipants: boolean): Promise { +export async function ensureGlobalStylesInstalled(theme: ColorThemeData, scopeThemingParticipants: boolean, fileIconThemeStyleSheetContent: string): Promise { baseStylesInstalledPromise ??= (async () => { await readBundle(); const overlay = overlaySheet = new CSSStyleSheet(); @@ -220,6 +229,7 @@ export async function ensureGlobalStylesInstalled(theme: ColorThemeData, scopeTh ...document.adoptedStyleSheets, overlay, getIconsStyleSheetCached(), + getFileIconThemeStyleSheetCached(fileIconThemeStyleSheetContent), ]; })(); await baseStylesInstalledPromise; diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 0e7c987b6e402e..1781f8cb3e3d41 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -187,10 +187,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/62ff8ab266d68ed5dc3f46d122cc05f67c1580222b0eeb3c83d7baea6c9e0937) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b37045f2e22c3d9d6a396f113cfbf92c879f32d3612e1fb8b60bd0b769fb266c) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/41ea93d7262689955020b1eced4f7c7b8b423bd592bb29fba739d1b982b67a17) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/46d3951ed96515724efb23c0754e07f935e8f0cad534ca955a9e606d2abe84ae) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/7228d732b817fa0201537ab73726c055a32e82ec4744abf94122e1b5b9f6054f) From b948a396185004e68f477c351d22d24601db8af5 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:55:34 +0200 Subject: [PATCH 10/23] Component fixtures: support selectable file icon themes (#333985) Support file icon themes in component fixtures Allow fixtures to select Seti, Minimal, or no file icon theme while scoping generated theme CSS to each fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/component-fixtures/SKILL.md | 20 +++ build/rspack/rspack.serve-out.config.mts | 20 ++- .../theme/test/common/testThemeService.ts | 5 + .../editor/editorTabBar.fixture.ts | 13 +- .../browser/componentFixtures/fixtureUtils.ts | 140 ++++++++++++------ .../componentFixtures/fixtureUtilsCss.ts | 33 ++++- 6 files changed, 168 insertions(+), 63 deletions(-) diff --git a/.github/skills/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md index 5672a0a58110bb..53aa7f96a25a52 100644 --- a/.github/skills/component-fixtures/SKILL.md +++ b/.github/skills/component-fixtures/SKILL.md @@ -61,6 +61,26 @@ Key points: - Always register created widgets with `disposableStore.add(...)` to prevent leaks - Pass `colorTheme: theme` to `createEditorServices` so theme colors render correctly +### File icon themes + +Fixtures use Seti file icons by default. Select another built-in theme, or disable file icons, on the individual fixture: + +```typescript +defineComponentFixture({ fileIconTheme: 'vs-minimal', render: renderMyComponent }); +defineComponentFixture({ fileIconTheme: 'none', render: renderMyComponent }); +``` + +When the rendered component reads `IThemeService`, pass the selected theme from `ComponentFixtureContext` to `createEditorServices`: + +```typescript +function renderMyComponent({ disposableStore, theme, fileIconTheme }: ComponentFixtureContext): void { + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + fileIconTheme, + }); +} +``` + ## Utilities from fixtureUtils.ts | Export | Purpose | diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 58125249708aab..58b6a7beacce27 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type Configuration, HtmlRspackPlugin, rspack } from '@rspack/core'; +import { type Configuration, CopyRspackPlugin, HtmlRspackPlugin, rspack } from '@rspack/core'; import { ComponentExplorerPlugin } from '@vscode/component-explorer-webpack-plugin'; import fs from 'fs'; import net from 'net'; @@ -12,6 +12,10 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '../..'); const isStaticComponentExplorerBuild = process.env['COMPONENT_EXPLORER_STATIC_BUILD'] === '1'; +const builtInFileIconThemeDirectories = [ + 'extensions/theme-defaults/fileicons', + 'extensions/theme-seti/icons', +]; function findFreePort(startPort: number): Promise { return new Promise(resolve => { @@ -103,19 +107,21 @@ export default { }, }, { - test: /\.woff$/, - type: 'asset/resource', - }, - { - // Built-in color and file icon theme JSON files use JSONC (comments / trailing + // Built-in color theme JSON files use JSONC (comments / trailing // commas), so import them as raw strings and let VS Code's // JSON parser handle them. - test: /[\\/]extensions[\\/](?:theme-defaults[\\/]themes|theme-seti[\\/]icons)[\\/].*\.json$/, + test: /[\\/]extensions[\\/]theme-defaults[\\/]themes[\\/].*\.json$/, type: 'asset/source', }, ], }, plugins: [ + ...(isStaticComponentExplorerBuild ? [new CopyRspackPlugin({ + patterns: builtInFileIconThemeDirectories.map(directory => ({ + from: path.join(repoRoot, directory), + to: directory, + })), + })] : []), new ComponentExplorerPlugin({ include: 'src/**/*.fixture.ts', }), diff --git a/src/vs/platform/theme/test/common/testThemeService.ts b/src/vs/platform/theme/test/common/testThemeService.ts index 817d76b3e435fe..f7486a084a8697 100644 --- a/src/vs/platform/theme/test/common/testThemeService.ts +++ b/src/vs/platform/theme/test/common/testThemeService.ts @@ -93,6 +93,11 @@ export class TestThemeService implements IThemeService { return this._fileIconTheme; } + setFileIconTheme(theme: IFileIconTheme): void { + this._fileIconTheme = theme; + this._onFileIconThemeChange.fire(theme); + } + public get onDidFileIconThemeChange(): Event { return this._onFileIconThemeChange.event; } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index e3864c53280c07..99ad6baa399ec5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -398,7 +398,7 @@ function populateModel(model: EditorGroupModel, specs: IEditorSpec[], disposable } export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: IEditorTabBarFixtureOptions): void { - const { container, disposableStore, theme } = ctx; + const { container, disposableStore, theme, fileIconTheme } = ctx; const width = options.width ?? 820; const isGroupActive = options.active ?? true; @@ -421,8 +421,10 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: configurationService: () => configurationService, }, disposableStore); - // Feed the fixture's themed colors to the shared theme service so tab-bar `getColor(...)` resolves. - (instantiationService.get(IThemeService) as TestThemeService).setTheme(theme); + // Feed the fixture's themes to the shared theme service so tab-bar theme lookups resolve. + const themeService = instantiationService.get(IThemeService) as TestThemeService; + themeService.setTheme(theme); + themeService.setFileIconTheme(fileIconTheme); // Services the base workbench harness does not stub but the tab bar needs. instantiationService.stub(ITreeViewsDnDService, new TreeViewsDnDService()); @@ -716,6 +718,11 @@ function createThemeColorFixtures() { } export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { + FileIconThemes: defineThemedFixtureGroup({ + Seti: defineComponentFixture({ fileIconTheme: 'vs-seti', render: render(false, {}) }), + Minimal: defineComponentFixture({ fileIconTheme: 'vs-minimal', render: render(false, {}) }), + None: defineComponentFixture({ fileIconTheme: 'none', render: render(false, {}) }), + }), ModernUIOff: defineThemedFixtureGroup(createFixtures(false, ['darkHighContrast'])), ModernUIOn: defineThemedFixtureGroup({ ...createFixtures(true, ['darkHighContrast']), diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 837d65b948f955..e4a42f9e4a52f8 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -9,9 +9,8 @@ import { defineFixture, defineFixtureGroup, defineFixtureVariants } from '@vscod // eslint-disable-next-line local/code-import-patterns, local/code-amd-node-module import { z } from 'zod'; import { DisposableStore, DisposableTracker, IDisposable, IReference, MutableDisposable, setDisposableTracker, toDisposable } from '../../../../base/common/lifecycle.js'; -import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { ModifierKeyEmitter } from '../../../../base/browser/dom.js'; +import { $, ModifierKeyEmitter } from '../../../../base/browser/dom.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../../build/vite/style.css'; import '../../../browser/media/style.css'; @@ -25,7 +24,7 @@ import '../../../browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IExtensionResourceLoaderService } from '../../../../platform/extensionResourceLoader/common/extensionResourceLoader.js'; import { ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; -import { IColorTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IColorTheme, IFileIconTheme, IThemeService } from '../../../../platform/theme/common/themeService.js'; import { FileIconThemeData, FileIconThemeLoader } from '../../../services/themes/browser/fileIconThemeData.js'; import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; import { ExtensionData } from '../../../services/themes/common/workbenchThemeService.js'; @@ -255,8 +254,8 @@ class NullStorageService implements IStorageService { // Themes // ============================================================================ -// Eagerly bundle the built-in color and file icon theme JSON files so they can -// be served through the IExtensionResourceLoaderService code path. The +// Eagerly bundle the built-in color theme JSON files so they can be served +// through the IExtensionResourceLoaderService code path. The // rspack config maps these JSON files to `asset/source`, so they are imported // as raw text (not parsed JSON) — this lets VS Code's JSONC parser handle // comments and trailing commas the way it does in the real product. @@ -268,22 +267,20 @@ import hc_black from '../../../../../../extensions/theme-defaults/themes/hc_blac import light_modern from '../../../../../../extensions/theme-defaults/themes/light_modern.json' with { type: 'json' }; import light_plus from '../../../../../../extensions/theme-defaults/themes/light_plus.json' with { type: 'json' }; import light_vs from '../../../../../../extensions/theme-defaults/themes/light_vs.json' with { type: 'json' }; -import vsSetiIconTheme from '../../../../../../extensions/theme-seti/icons/vs-seti-icon-theme.json' with { type: 'json' }; /* eslint-enable local/code-import-patterns */ function toThemeJsonText(theme: string | object): string { return typeof theme === 'string' ? theme : JSON.stringify(theme); } -// Keep the synthetic theme beside the emitted font so its relative asset URL resolves in every build mode. -const setiFontResource = URI.parse(new URL('../../../../../../extensions/theme-seti/icons/seti.woff', import.meta.url).href); -const setiFileIconThemeLocation = joinPath(dirname(setiFontResource), 'vs-seti-icon-theme.json'); -const setiFontSource = JSON.stringify('./seti.woff'); -const setiFontTarget = JSON.stringify(`./${basename(setiFontResource)}`); -const setiFileIconThemeJson = toThemeJsonText(vsSetiIconTheme); -if (!setiFileIconThemeJson.includes(setiFontSource)) { - throw new Error('Seti file icon theme does not reference its expected font.'); -} +export type ComponentFixtureFileIconTheme = 'none' | 'vs-seti' | 'vs-minimal'; +type BuiltInComponentFixtureFileIconTheme = Exclude; + +const fileIconThemeResources = { + 'vs-seti': URI.parse(new URL('./extensions/theme-seti/icons/vs-seti-icon-theme.json', document.baseURI).href), + 'vs-minimal': URI.parse(new URL('./extensions/theme-defaults/fileicons/vs_minimal-icon-theme.json', document.baseURI).href), +} satisfies Record; +const fileIconThemeResourceUrls = new Set(Object.values(fileIconThemeResources).map(resource => resource.toString(true))); const themeJsonModules: Record = { '/extensions/theme-defaults/themes/dark_modern.json': toThemeJsonText(dark_modern), @@ -293,17 +290,26 @@ const themeJsonModules: Record = { '/extensions/theme-defaults/themes/light_modern.json': toThemeJsonText(light_modern), '/extensions/theme-defaults/themes/light_plus.json': toThemeJsonText(light_plus), '/extensions/theme-defaults/themes/light_vs.json': toThemeJsonText(light_vs), - [setiFileIconThemeLocation.path]: setiFileIconThemeJson.replaceAll(setiFontSource, setiFontTarget), }; const fixtureExtensionResourceLoaderService = new class implements IExtensionResourceLoaderService { declare readonly _serviceBrand: undefined; async readExtensionResource(uri: URI): Promise { const content = themeJsonModules[uri.path]; - if (content === undefined) { - throw new Error(`Fixture extension resource not found: ${uri.toString()}`); + if (content !== undefined) { + return content; + } + + const resourceUrl = uri.toString(true); + if (fileIconThemeResourceUrls.has(resourceUrl)) { + const response = await fetch(resourceUrl); + if (!response.ok) { + throw new Error(`Failed to load fixture file icon theme ${resourceUrl}: ${response.status} ${response.statusText}`); + } + return response.text(); } - return content; + + throw new Error(`Fixture extension resource not found: ${uri.toString()}`); } supportsExtensionGalleryResources(): Promise { return Promise.resolve(false); } isExtensionGalleryResource(): Promise { return Promise.resolve(false); } @@ -322,11 +328,22 @@ function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector): Colo export const darkTheme = createBuiltInTheme('/extensions/theme-defaults/themes/dark_modern.json', ThemeTypeSelector.VS_DARK); export const lightTheme = createBuiltInTheme('/extensions/theme-defaults/themes/light_modern.json', ThemeTypeSelector.VS); const darkHighContrastTheme = createBuiltInTheme('/extensions/theme-defaults/themes/hc_black.json', ThemeTypeSelector.HC_BLACK); -const defaultFileIconTheme = FileIconThemeData.fromExtensionTheme( - { id: 'vs-seti', path: './vs-seti-icon-theme.json', _watch: false }, - setiFileIconThemeLocation, - ExtensionData.fromName('vscode', 'vscode-theme-seti', true) -); + +function createBuiltInFileIconTheme(id: BuiltInComponentFixtureFileIconTheme, extensionName: string): FileIconThemeData { + const location = fileIconThemeResources[id]; + return FileIconThemeData.fromExtensionTheme( + { id, path: location.path, _watch: false }, + location, + ExtensionData.fromName('vscode', extensionName, true) + ); +} + +const fileIconThemes = { + none: FileIconThemeData.noIconTheme, + 'vs-seti': createBuiltInFileIconTheme('vs-seti', 'vscode-theme-seti'), + 'vs-minimal': createBuiltInFileIconTheme('vs-minimal', 'theme-defaults'), +} satisfies Record; +const defaultFileIconTheme = fileIconThemes['vs-seti']; type ComponentFixtureThemeVariant = { readonly label: string; @@ -353,29 +370,54 @@ function ensureThemeLoaded(theme: ColorThemeData): Promise { return themeLoadedPromise; } -let defaultFileIconThemeLoadedPromise: Promise | undefined; -function ensureDefaultFileIconThemeLoaded(): Promise { - return defaultFileIconThemeLoadedPromise ??= (async () => { - const languageService = new LanguageService(); - try { - const styleSheetContent = await defaultFileIconTheme.ensureLoaded(new FileIconThemeLoader(fixtureExtensionResourceLoaderService, languageService)); - if (styleSheetContent === undefined) { - throw new Error('Seti file icon theme did not produce a stylesheet.'); +const fileIconThemeLoadedPromises = new WeakMap>(); +function ensureFileIconThemeLoaded(theme: FileIconThemeData): Promise { + let fileIconThemeLoadedPromise = fileIconThemeLoadedPromises.get(theme); + if (!fileIconThemeLoadedPromise) { + fileIconThemeLoadedPromise = (async () => { + if (theme.isLoaded) { + return theme.styleSheetContent; } - return styleSheetContent; - } finally { - languageService.dispose(); - } - })(); + const languageService = new LanguageService(); + try { + return await theme.ensureLoaded(new FileIconThemeLoader(fixtureExtensionResourceLoaderService, languageService)); + } finally { + languageService.dispose(); + } + })(); + fileIconThemeLoadedPromises.set(theme, fileIconThemeLoadedPromise); + } + return fileIconThemeLoadedPromise; } -export async function setupTheme(container: HTMLElement, theme: ColorThemeData, scopeThemingParticipants = false): Promise { +export async function setupTheme( + container: HTMLElement, + theme: ColorThemeData, + scopeThemingParticipants = false, + fileIconThemeId: ComponentFixtureFileIconTheme = 'vs-seti', + fileIconThemeScope: HTMLElement = container +): Promise { + const fileIconTheme = fileIconThemes[fileIconThemeId]; const [, fileIconThemeStyleSheetContent] = await Promise.all([ ensureThemeLoaded(theme), - ensureDefaultFileIconThemeLoaded(), + ensureFileIconThemeLoaded(fileIconTheme), ]); - await ensureGlobalStylesInstalled(theme, scopeThemingParticipants, fileIconThemeStyleSheetContent); - container.classList.add('component-fixture', 'monaco-workbench', 'file-icons-enabled', getPlatformClass(), 'disable-animations', ...theme.classNames); + const fileIconThemeClassName = fileIconThemeId === 'none' ? undefined : `component-fixture-file-icon-theme-${fileIconThemeId}`; + if (fileIconThemeClassName && fileIconThemeStyleSheetContent === undefined) { + throw new Error(`Fixture file icon theme '${fileIconThemeId}' did not produce a stylesheet.`); + } + + await ensureGlobalStylesInstalled(theme, scopeThemingParticipants, fileIconThemeClassName && fileIconThemeStyleSheetContent !== undefined ? { + scopeSelector: `.${fileIconThemeClassName}`, + styleSheetContent: fileIconThemeStyleSheetContent, + } : undefined); + container.classList.add('component-fixture', 'monaco-workbench', getPlatformClass(), 'disable-animations', ...theme.classNames); + fileIconThemeScope.classList.toggle('component-fixture-file-icon-theme-vs-seti', fileIconThemeId === 'vs-seti'); + fileIconThemeScope.classList.toggle('component-fixture-file-icon-theme-vs-minimal', fileIconThemeId === 'vs-minimal'); + if (fileIconThemeClassName) { + container.classList.add('file-icons-enabled'); + } + return fileIconTheme; } /** @@ -473,6 +515,10 @@ export interface CreateServicesOptions { * The color theme to use for the theme service. */ colorTheme?: IColorTheme; + /** + * The file icon theme to use for the theme service. + */ + fileIconTheme?: IFileIconTheme; /** * Additional services to register after the base editor services. */ @@ -588,7 +634,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre define(IConfigurationService, TestConfigurationService); define(ITextResourcePropertiesService, TestTextResourcePropertiesService); defineInstance(IStorageService, new NullStorageService()); - defineInstance(IThemeService, new TestThemeService(options?.colorTheme, defaultFileIconTheme)); + defineInstance(IThemeService, new TestThemeService(options?.colorTheme, options?.fileIconTheme ?? defaultFileIconTheme)); define(ILogService, FixtureLogService); define(IModelService, FixtureModelService); define(ICodeEditorService, TestCodeEditorService); @@ -930,6 +976,7 @@ export interface ComponentFixtureContext { disposableStore: DisposableStore; disposableStackStore: DisposableStackStore; theme: ColorThemeData; + fileIconTheme: IFileIconTheme; } export interface ComponentFixtureOptions { @@ -937,6 +984,7 @@ export interface ComponentFixtureOptions { labels?: ThemedFixtureGroupLabels; virtualTime?: { enabled?: boolean; durationMs?: number; teardownDrainMs?: number }; additionalThemes?: readonly ComponentFixtureAdditionalTheme[]; + fileIconTheme?: ComponentFixtureFileIconTheme; expectedVisualDescriptions?: readonly string[]; } @@ -975,7 +1023,9 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed reverseStylesheets: { placement: 'toolbar', label: 'Reverse Stylesheets' }, enableAnimations: { placement: 'toolbar', label: 'Enable Animations' }, }, - render: async (container: HTMLElement, context) => { + render: async (fixtureHost: HTMLElement, context) => { + const container = $('.component-fixture-container'); + fixtureHost.appendChild(container); const disposableStore = new DisposableStore(); const input = parseFixtureInput(context.input); const { label: themeLabel, theme, scopeThemingParticipants } = themeVariant; @@ -1075,7 +1125,7 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed }); async function actualRender() { - await setupTheme(container, theme, scopeThemingParticipants); + const fileIconTheme = await setupTheme(container, theme, scopeThemingParticipants, options.fileIconTheme, fixtureHost); const stylesheetOrderOverride = disposableStore.add(new MutableDisposable()); const updateStylesheetOrder = (input: unknown) => { @@ -1118,7 +1168,7 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed try { const disposableStackStore = disposableStore.add(new DisposableStackStore()); - const result = options.render({ container, disposableStore, disposableStackStore, theme }); + const result = options.render({ container, disposableStore, disposableStackStore, theme, fileIconTheme }); const p2 = virtualTimeEnabled ? p.run({ diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts index 0bc6ffb1bb3511..be6d2905bf5acc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts @@ -25,7 +25,7 @@ const activeOverrides: { }[] = []; let originalDisabledStates: readonly boolean[] | undefined; let iconsStyleSheetCache: CSSStyleSheet | undefined; -let fileIconThemeStyleSheetCache: CSSStyleSheet | undefined; +const fileIconThemeStyleSheetCache = new Map(); const themeStyleSheetCache = new WeakMap(); const installedThemes = new WeakSet(); @@ -176,12 +176,19 @@ function getIconsStyleSheetCached(): CSSStyleSheet { return iconsStyleSheetCache; } -function getFileIconThemeStyleSheetCached(styleSheetContent: string): CSSStyleSheet { - if (!fileIconThemeStyleSheetCache) { - fileIconThemeStyleSheetCache = new CSSStyleSheet(); - fileIconThemeStyleSheetCache.replaceSync(styleSheetContent); +function getFileIconThemeStyleSheetCached(scopeSelector: string, styleSheetContent: string): CSSStyleSheet { + let fileIconThemeStyleSheet = fileIconThemeStyleSheetCache.get(scopeSelector); + if (!fileIconThemeStyleSheet) { + const fontFaceRules: string[] = []; + const scopedRules = styleSheetContent.replace(/@font-face\s*\{[^}]*\}/g, rule => { + fontFaceRules.push(rule); + return ''; + }); + fileIconThemeStyleSheet = new CSSStyleSheet(); + fileIconThemeStyleSheet.replaceSync(`${fontFaceRules.join('\n')}\n@scope (${scopeSelector}) {\n${scopedRules}\n}`); + fileIconThemeStyleSheetCache.set(scopeSelector, fileIconThemeStyleSheet); } - return fileIconThemeStyleSheetCache; + return fileIconThemeStyleSheet; } function createScopedThemingParticipant(scopeSelector: string, scopeRootSelector: string, participants: readonly IThemingParticipant[]): IThemingParticipant { @@ -221,7 +228,11 @@ function getThemeStyleSheet(theme: ColorThemeData, scopeThemingParticipants: boo * Installs shared global styles once and appends a scoped stylesheet for each newly requested theme. * The reversal overlay keeps a stable identity and position for {@link overrideStylesheetOrder}. */ -export async function ensureGlobalStylesInstalled(theme: ColorThemeData, scopeThemingParticipants: boolean, fileIconThemeStyleSheetContent: string): Promise { +export async function ensureGlobalStylesInstalled( + theme: ColorThemeData, + scopeThemingParticipants: boolean, + fileIconThemeStyles?: { readonly scopeSelector: string; readonly styleSheetContent: string } +): Promise { baseStylesInstalledPromise ??= (async () => { await readBundle(); const overlay = overlaySheet = new CSSStyleSheet(); @@ -229,11 +240,17 @@ export async function ensureGlobalStylesInstalled(theme: ColorThemeData, scopeTh ...document.adoptedStyleSheets, overlay, getIconsStyleSheetCached(), - getFileIconThemeStyleSheetCached(fileIconThemeStyleSheetContent), ]; })(); await baseStylesInstalledPromise; + if (fileIconThemeStyles && !fileIconThemeStyleSheetCache.has(fileIconThemeStyles.scopeSelector)) { + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + getFileIconThemeStyleSheetCached(fileIconThemeStyles.scopeSelector, fileIconThemeStyles.styleSheetContent), + ]; + } + if (installedThemes.has(theme)) { return; } From 3a01d74248b1c04c33dbd24a140129e26b4ff408 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 2 Sep 2026 16:23:53 +0200 Subject: [PATCH 11/23] automations: fix: apply Assisted permissions for Autopilot (#333949) Translate the legacy Automations Autopilot permission value at the Agent Host protocol boundary so native automation sessions use Assisted permissions.\n\nFixes #333723\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../providers/agentHost/browser/agentHostAutomationStore.ts | 3 ++- .../agentHost/test/browser/agentHostAutomationStore.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index a62240a1737306..9358174e3acbc6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -25,6 +25,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; +import { ChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAutomationStorageService } from '../../../automations/common/automationStorageService.js'; @@ -833,7 +834,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro const config = { ...existing?.session.config }; const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(descriptor.modelId); setOptional(config, SessionConfigKey.Mode, descriptor.mode); - setOptional(config, SessionConfigKey.AutoApprove, descriptor.permissionLevel); + setOptional(config, SessionConfigKey.AutoApprove, descriptor.permissionLevel === ChatPermissionLevel.Autopilot ? ChatPermissionLevel.Assisted : descriptor.permissionLevel); if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index b355573ab8cd58..ef15dc40a8eb5d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -405,6 +405,7 @@ suite('AgentHostAutomationStore', () => { prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + permissionLevel: 'autopilot', }); const create = connection.dispatched[0].action; const trigger = create.type === ActionType.AutomationCreateRequested ? create.definition.triggers[0] : undefined; @@ -413,6 +414,7 @@ suite('AgentHostAutomationStore', () => { subscribedChannel: connection.subscribedChannel, dispatchChannel: connection.dispatched[0].channel, definitionMeta: create.type === ActionType.AutomationCreateRequested ? create.definition._meta : undefined, + sessionConfig: create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, triggerExpression: trigger?.kind === AutomationTriggerKind.Schedule ? trigger.schedule.expression : undefined, automation: { name: automation.name, @@ -425,6 +427,7 @@ suite('AgentHostAutomationStore', () => { subscribedChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), dispatchChannel: AUTOMATION_CATALOG_URI, definitionMeta: undefined, + sessionConfig: { autoApprove: 'assisted' }, triggerExpression: '30 9 * * *', automation: { name: 'Review changes', From 4fe1813bd3f358237737cd1ef1d7162bb49ec80f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 16:46:55 +0200 Subject: [PATCH 12/23] sessions: refine title-bar layout and actions (#334000) * sessions: refine single-pane details behavior Keep the side-pane boundary stable when toggling docked details, align new-session editor closing with existing sessions, and reveal details when Empty Files opens. Also order the details action before maximize and allow workspace-backed draft sessions to open Changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: preserve hidden details after Empty Files Reveal docked Details only when Empty Files becomes active or visible, rather than on unrelated reactive updates, and clarify the last-editor close contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: fix left title-bar sidebar-toggle icon lag on toggle The Toggle Side Bar action's icon (and its aria-pressed state) is driven by MenuWorkbenchToolBar for Menus.TitleBarLeftLayout, which did not override eventDebounceDelay. MenuWorkbenchToolBar always forwards that key to IMenuService.createMenu even when undefined, which defeats MenuService's own 50ms default and falls back to DebounceEmitter's 100ms internal default. The sidebar itself hides/shows synchronously when clicked, so for up to ~100ms afterward the button kept showing its previous open/closed glyph, reading as the action jumping once it finally caught up to the real layout state. A previous SidebarToggleActionViewItem derived the icon directly and synchronously from IWorkbenchLayoutService.isVisible(...), sidestepping this lag, but was removed in #310869 without restoring instant feedback. editorGroupView.ts already uses eventDebounceDelay: 0 for the analogous editor-title menu for the same reason; apply the same override to the left title-bar toolbar. Add a focused regression test that constructs a real TitlebarPart with a real ContextKeyService/MenuService and asserts the toggle icon's class and aria-pressed state track the layout service's sidebar visibility within a short, bounded window. Verified the test fails without the fix and passes with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: stop the left title-bar from resizing when the sidebar is toggled The titlebar row and the sidebar/content row are siblings under the same vertical grid split (root data: [titleBarNode, contentSection]), so the grid library forces them to share one width - there is no way to size them independently within that split. _layoutGrid() computed the width passed to workbenchGrid.layout() as mainContainerWidth minus a gutter that grew from one AGENTS_FLOATING_PANEL_GAP to two whenever the sidebar was hidden (.nosidebar), mirrored by a conditional margin-left on the shared grid root in workbench.css. Because the titlebar shares that same root, its own grid-allocated width - and hence the left toolbar hosting the sidebar-toggle icon - shifted and shrank by 4px every time the sidebar was shown or hidden. This is a real, measurable position/width change (confirmed via DevTools: the titlebar part's own box resizes on toggle), not the debounce-lag fixed in 957b0fa280f - both are genuine, independent contributors to the reported 'icon jumps' symptom. Make the gutter (and its matching CSS margin) constant, independent of sidebar visibility, so the shared grid root - and the titlebar within it - never changes size on toggle. The tradeoff is that the content area's substitute-for-sidebar card (rounded corners) now sits flush against the window edge instead of leaving a 4px gap when the sidebar is hidden; visually verified this reads as a normal edge-to-edge card, not a regression, and it avoids a much larger, riskier change (the card's content-size math in agentsPartCard.ts is mirrored in JS and would need updating in lockstep with any new part-level margin to avoid a content clipping mismatch). Add a regression test asserting workbenchGrid.layout() receives the identical width regardless of partVisibility.sidebar. Verified it fails (1192 vs 1196) without the fix and passes with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "sessions: fix left title-bar sidebar-toggle icon lag on toggle" This reverts commit 957b0fa280fa325d1ec8dda1c2c13ff41172c2ba. * sessions: reduce layout comment verbosity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: remove verbose layout comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: show New action by default in Insiders Use the experiment treatment when assigned, while defaulting the collapsed-sidebar New action on for Insiders builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/media/workbench.css | 4 --- src/vs/sessions/browser/workbench.ts | 4 +-- .../sessions/browser/sessionsActions.ts | 4 ++- .../sessions/test/browser/workbench.test.ts | 30 +++++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index 9a562e6a26ebb8..cd17ad8eec366d 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -27,10 +27,6 @@ height: calc(100% - var(--vscode-agents-layout-floatingPanelGap)); } -.monaco-workbench.agent-sessions-workbench.nosidebar > .monaco-grid-view { - margin-left: var(--vscode-agents-layout-floatingPanelGap); -} - .monaco-workbench.agent-sessions-workbench.shell-gradient-background { position: relative; isolation: isolate; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 228d91f26e6708..fa0dea88f8d383 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -1876,9 +1876,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic protected _layoutGrid(): void { const mobileTopBarHeight = this.mobileTopBarElement?.offsetHeight ?? 0; - // Keep in sync with the desktop grid margin in workbench.css. + // Keep the desktop grid margin stable when sidebar visibility changes. const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; - const gridGutterW = isPhone ? 0 : AGENTS_FLOATING_PANEL_GAP + (this.partVisibility.sidebar ? 0 : AGENTS_FLOATING_PANEL_GAP); + const gridGutterW = isPhone ? 0 : AGENTS_FLOATING_PANEL_GAP; const gridGutterH = isPhone ? 0 : AGENTS_FLOATING_PANEL_GAP; this.workbenchGrid.layout( this._mainContainerDimension.width - gridGutterW, diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 9513284e6dde91..6d415191959cda 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -44,6 +44,7 @@ import { IAction } from '../../../../base/common/actions.js'; import { OS } from '../../../../base/common/platform.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { asCssVariable } from '../../../../platform/theme/common/colorRegistry.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; @@ -1329,6 +1330,7 @@ export class NewSessionActionViewItemContribution extends Disposable implements @IContextKeyService contextKeyService: IContextKeyService, @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IProductService private readonly productService: IProductService, ) { super(); @@ -1359,7 +1361,7 @@ export class NewSessionActionViewItemContribution extends Disposable implements return; } const enabled = await this.assignmentService.getTreatment(NewSessionActionViewItemContribution.NEW_SESSION_TITLEBAR_TREATMENT); - this.titleBarEnabledContext.set(enabled === true); + this.titleBarEnabledContext.set(enabled ?? this.productService.quality === 'insider'); } } diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index defff36019518b..ee013c65ef0247 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -70,6 +70,7 @@ suite('Sessions - Workbench', () => { const toggleSecondarySideBar = Workbench.prototype.toggleSecondarySideBar as (this: ITestWorkbench) => void; const restoreSessionsPartOnActivation = Reflect.get(Workbench.prototype, '_restoreSessionsPartOnActivation') as (this: ITestWorkbench) => void; const restoreEditorPartOnActivation = Reflect.get(Workbench.prototype, '_restoreEditorPartOnActivation') as (this: ITestWorkbench) => void; + const layoutGrid = Reflect.get(Workbench.prototype, '_layoutGrid') as (this: IContainerResizeTestHarness) => void; const layoutSinglePaneGrid = Reflect.get(SinglePaneWorkbench.prototype, '_layoutGrid') as (this: IContainerResizeTestHarness) => void; const preserveSessionsEditorRatio = Reflect.get(SinglePaneWorkbench.prototype, '_preserveSessionsEditorRatio') as (this: IProportionalResizeTestHarness, previousSessionsWidth: number, previousEditorWidth: number) => void; const registerNotificationRowHeight = Reflect.get(Workbench.prototype, 'registerNotificationRowHeight') as (this: { @@ -649,6 +650,35 @@ suite('Sessions - Workbench', () => { }); }); + test('sidebar visibility does not change the grid width passed to layout', () => { + const layoutCalls: IViewSize[] = []; + const host: IContainerResizeTestHarness = { + partVisibility: { sidebar: true, editor: false, auxiliaryBar: false }, + mobileTopBarElement: undefined, + layoutPolicy: { viewportClass: { get: () => 'desktop' } }, + _mainContainerDimension: { width: 1200, height: 800 }, + sessionsPartView: { minimumWidth: 300 }, + editorPartView: { minimumWidth: 300 }, + workbenchGrid: { + getViewSize: () => ({ width: 0, height: 0 }), + resizeView: () => { }, + isViewVisible: () => true, + layout: (width, height) => { layoutCalls.push({ width, height }); }, + }, + _runWithEditorResizeSyncSuspended: fn => fn(), + }; + Object.setPrototypeOf(host, Workbench.prototype); + + layoutGrid.call(host); + host.partVisibility.sidebar = false; + layoutGrid.call(host); + + assert.deepStrictEqual(layoutCalls, [ + { width: 1196, height: 796 }, + { width: 1196, height: 796 }, + ]); + }); + test('single-pane sidebar visibility leaves a detail-only pane width unchanged', () => { const host = createHost({ single: true, sideBarWidth: 280, editorWidth: 620, dockedWidth: 300, partVisibility: { sidebar: true, editor: false, auxiliaryBar: true } }); From 967c9f09ae09972a6aafa8999d1441944a1e50d4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 17:00:46 +0200 Subject: [PATCH 13/23] Fix session-row hover actions and layout in narrow panes (#333980) * Agent Host changes for sandy081/agents/bugfix-general-issue * sessions: Cover narrow row toolbar layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: Update narrow toolbar screenshot baselines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/media/sessionsList.css | 2 + .../sessions/sessionsList.fixture.ts | 48 ++++++++++++++++++- .../blocks-ci-screenshots.md | 6 +++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index facf3410541846..75e27e16a6b68e 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -193,6 +193,7 @@ box-sizing: border-box; position: relative; overflow: visible; + min-width: 0; padding: 8px 6px 8px 12px; &.archived { @@ -507,6 +508,7 @@ box-sizing: border-box; position: relative; overflow: visible; + min-width: 0; padding: 0 var(--vscode-spacing-size120) 0 var(--vscode-spacing-size360); color: var(--vscode-foreground); font-size: var(--vscode-fontSize-body1); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 543f29aa740a1a..556ba6ecca2627 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -15,6 +15,7 @@ import { IListService, ListService } from '../../../../../platform/list/browser/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IMenu, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; @@ -170,9 +171,10 @@ interface IRenderOptions { readonly width?: number; readonly phone?: boolean; readonly revealHierarchyGuides?: boolean; + readonly showFocusedToolbar?: boolean; } -function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): void { +function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): void | Promise { const { container, disposableStore } = ctx; const approvals = new Map(); const sessions = options.sessions.map(spec => createSession(spec, approvals)); @@ -189,6 +191,25 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption colorTheme: ctx.theme, additionalServices: reg => { registerWorkbenchServices(reg); + if (options.showFocusedToolbar) { + const archiveAction = new class extends mock() { + override readonly id = 'sessions.fixture.archive'; + override readonly label = 'Archive'; + override readonly tooltip = 'Archive'; + override readonly class = ThemeIcon.asClassName(Codicon.archive); + override readonly enabled = true; + override async run(): Promise { } + }(); + reg.defineInstance(IMenuService, new class extends mock() { + override createMenu(): IMenu { + return { + onDidChange: Event.None, + getActions: () => [['navigation', [archiveAction]]], + dispose: () => { }, + }; + } + }()); + } reg.define(IListService, ListService); reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IAgentHostConnectionsService, new class extends mock() { }()); @@ -300,6 +321,19 @@ function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOption })); list.layout(options.phone ? 260 : 220, width); + if (options.showFocusedToolbar) { + return Promise.resolve().then(() => { + const sessionRow = listHost.querySelector('.session-item')?.closest('.monaco-list-row'); + const toolbar = sessionRow?.querySelector('.session-title-toolbar'); + const actions = toolbar?.querySelector('.actions-container'); + if (!sessionRow || !toolbar || !actions) { + throw new Error('Expected a session row toolbar.'); + } + sessionRow.classList.add('focused'); + toolbar.style.display = 'block'; + }); + } + if (options.revealHierarchyGuides) { const sessionItem = listHost.querySelector('.session-item'); if (!sessionItem) { @@ -330,6 +364,18 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { width: 260, }), }), + SessionsList_NarrowHoverToolbar: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['A narrow session row truncates its long title and shows the Archive toolbar action fully inside the rounded row boundary.'], + render: ctx => renderSessionsList(ctx, { + sessions: [ + { id: 'a', title: 'Review PR 333429: sessions fix normalize Windows workspace path casing', workspace: 'vscode', minutesAgo: 12, group: GROUP.id, changesSummary: { files: 4, additions: 104, deletions: 4 } }, + ], + groups: [GROUP], + showFocusedToolbar: true, + width: 260, + }), + }), SessionsList_CustomGroup_InProgress: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: [ diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 1781f8cb3e3d41..aa695151173ee0 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -210,6 +210,12 @@ #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/79d4608ac44ba3bf43cb4555a8e02fca4165866a2fb6b518d3c3994b9228e37c) +#### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8af0c707c9c8e321ac7c8fd792b3c242a0d394cdaf68c3fe1c61804095395030) + +#### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d6baba279be027d3b1699b970a86bb168981c641332a46d3392287a14237b117) + #### sessions/sessionsList/SessionsList_NestedChatHierarchyGuides/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/ae6689aace7015963b6fc3812c77019921e63523f97f630afde63579729077f2) From 23d058278720d0043b77b04f60d5b055b7b36948 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci <39117631+Giuspepe@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:02:53 +0200 Subject: [PATCH 14/23] agentHost: restrict Codex Copilot models to OpenAI (#333987) * agentHost: restrict Codex Copilot models to OpenAI Third-party models can advertise the Responses endpoint without supporting the complete Codex request lifecycle. Keep picker and endpoint eligibility checks while limiting the Codex harness catalog to models whose vendor is OpenAI. * agentHost: reuse OpenAI model provider constant * agentHost: add vendor to shared Codex model fixtures The OpenAI-only model filter relies on the required vendor field. Keep the shared create-chat and prewarm fixtures valid so their gpt-test model remains available. --- .../agentHost/node/codex/codexAgent.ts | 14 +++++++- .../test/node/codex/codexCreateChat.test.ts | 2 +- .../test/node/codex/codexModelRefresh.test.ts | 36 ++++++++++++++----- .../node/codex/codexPrewarmEviction.test.ts | 2 +- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 7a7a77019c5b6f..503600216029e4 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { CCAModel } from '@vscode/copilot-api'; import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -240,6 +241,17 @@ const CODEX_COPILOT_MODEL_GROUP = 'copilot'; const CODEX_OPENAI_MODEL_PROVIDER = 'openai'; const CODEX_MODEL_SELECTION_PREFIX = '@provider='; +/** + * The Codex harness relies on OpenAI Responses semantics beyond the endpoint + * shape. Other vendors can advertise `/responses` without supporting the full + * Codex request lifecycle, so only publish OpenAI's picker-eligible models. + */ +function isCodexCompatibleCopilotModel(model: CCAModel): boolean { + return model.vendor.toLowerCase() === CODEX_OPENAI_MODEL_PROVIDER + && !!model.model_picker_enabled + && !!model.supported_endpoints?.includes(CODEX_RESPONSES_ENDPOINT); +} + export function toCodexModelSelectionId(modelProvider: string, modelId: string): string { return `${CODEX_MODEL_SELECTION_PREFIX}${encodeURIComponent(modelProvider)}:${encodeURIComponent(modelId)}`; } @@ -1984,7 +1996,7 @@ export class CodexAgent extends Disposable implements IAgent { // OpenAI-shaped Responses endpoint. The chosen id is forwarded straight // through; CAPI remains the authority on what the token may actually use. const models = all - .filter(m => m.model_picker_enabled && m.supported_endpoints?.includes(CODEX_RESPONSES_ENDPOINT)) + .filter(isCodexCompatibleCopilotModel) .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default)) .map((m): IAgentModelInfo => ({ provider: CODEX_AGENT_PROVIDER_ID, diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index e9756d5ceb5bb2..dc9f5abc7d256a 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -176,7 +176,7 @@ function createSessionDatabaseReference(database: ISessionDatabase) { } async function createAgent(disposables: Pick, options: ICreateAgentOptions = {}): Promise { - const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const instantiationService = new TestInstantiationService(); const logService = new NullLogService(); const fileService = disposables.add(new FileService(logService)); diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 8555b28c3f71d8..cf746e617444d1 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -208,7 +208,7 @@ suite('CodexAgent model refresh', () => { }); test('restored model waits for an authentication refresh queued behind activation', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const firstRefreshStarted = new DeferredPromise(); const releaseFirstRefresh = new DeferredPromise(); const authenticatedRefreshStarted = new DeferredPromise(); @@ -260,7 +260,7 @@ suite('CodexAgent model refresh', () => { }); test('model resolution starts discovery when the catalog is empty', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => copilotModels); agent['_githubToken'] = 'token'; agent['_isSdkResolvableWithoutDownload'] = async () => false; @@ -299,7 +299,7 @@ suite('CodexAgent model refresh', () => { }); test('queues a fresh model refresh when Codex activates during an ambient refresh', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const ambientRefreshStarted = new DeferredPromise(); const ambientCodexRefreshFinished = new DeferredPromise(); const releaseAmbientRefresh = new DeferredPromise(); @@ -838,8 +838,8 @@ suite('CodexAgent model refresh', () => { test('does not publish Copilot models disabled for the model picker', async () => { const models = [ - { id: 'picker-enabled', name: 'Picker Enabled', model_picker_enabled: true, supported_endpoints: ['/responses'] }, - { id: 'picker-disabled', name: 'Picker Disabled', model_picker_enabled: false, supported_endpoints: ['/responses'] }, + { id: 'picker-enabled', name: 'Picker Enabled', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }, + { id: 'picker-disabled', name: 'Picker Disabled', model_picker_enabled: false, supported_endpoints: ['/responses'], vendor: 'OpenAI' }, ] as CCAModel[]; const agent = createAgent(disposables, async () => models); agent['_isSdkResolvableWithoutDownload'] = async () => false; @@ -852,6 +852,24 @@ suite('CodexAgent model refresh', () => { ]); }); + test('publishes only OpenAI Copilot models', async () => { + const models = [ + { id: 'grok-4.5', name: 'Grok 4.5', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'grok-4.5' }, vendor: 'xAI' }, + { id: 'grok-4.6', name: 'Grok 4.6', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'grok-4.6' }, vendor: 'xAI' }, + { id: 'mai-code-1.1-flash', name: 'MAI-Code-1.1-Flash', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'oswe-vscode-modelD' }, vendor: 'Microsoft' }, + { id: 'gpt-5.6', name: 'GPT-5.6', model_picker_enabled: true, supported_endpoints: ['/responses'], capabilities: { family: 'gpt-5.6' }, vendor: 'OpenAI' }, + ] as CCAModel[]; + const agent = createAgent(disposables, async () => models); + agent['_isSdkResolvableWithoutDownload'] = async () => false; + + await agent.authenticate(agent.getProtectedResources()[0].resource, 'token'); + await agent.refreshModels(); + + assert.deepStrictEqual(agent.models.get().map(model => model.id), [ + toCodexModelSelectionId('vscode-proxy', 'gpt-5.6'), + ]); + }); + test('waits for an app-server already starting when signed-out use becomes enabled', async () => { const agent = createAgent(disposables, async () => [], {}); const connection = createChatGPTConnection(); @@ -873,7 +891,7 @@ suite('CodexAgent model refresh', () => { }); test('publishes no ChatGPT models when the app server reports no account', async () => { - const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => copilotModels, { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); agent['_githubToken'] = 'token'; agent['_connection'] = createChatGPTConnection(null) as never; @@ -911,7 +929,7 @@ suite('CodexAgent model refresh', () => { test('keeps the last known-good models when a periodic refresh fails', async () => { let shouldFail = false; - const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => { if (shouldFail) { throw new Error('transient failure'); @@ -931,7 +949,7 @@ suite('CodexAgent model refresh', () => { test('retries Copilot model discovery after a transient authentication refresh failure', async () => { let attempts = 0; - const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const agent = createAgent(disposables, async () => { attempts++; if (attempts === 1) { @@ -1007,7 +1025,7 @@ suite('CodexAgent model refresh', () => { }); test('omits the thinking level when a Copilot model advertises no reasoning efforts', async () => { - const model = { id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'] } as CCAModel; + const model = { id: 'gpt-5.5', name: 'GPT-5.5', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' } as CCAModel; const agent = createAgent(disposables, async () => [model]); await agent.authenticate(agent.getProtectedResources()[0].resource, 'token'); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index 92947217be4463..bed7b96fff06f1 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -197,7 +197,7 @@ class TestCodexConfigurationService extends AgentConfigurationService { } async function createAgent(disposables: Pick, options: ICreateAgentOptions = {}): Promise { - const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'] }] as CCAModel[]; + const models = [{ id: 'gpt-test', name: 'GPT Test', model_picker_enabled: true, supported_endpoints: ['/responses'], vendor: 'OpenAI' }] as CCAModel[]; const instantiationService = new TestInstantiationService(); const logService = new TestCodexLogService(); const fileService = disposables.add(new TestCodexFileService(logService)); From 79f132ec3a12aba7194c475feadf39acf7228c15 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 2 Sep 2026 08:04:01 -0700 Subject: [PATCH 15/23] agentHost: preserve search icon while streaming (#333925) Initialize search-specific rendering data when a streaming tool invocation is created so text searches do not briefly use the generic tool icon. Add regression coverage for rg calls before they become ready.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/stateToProgressAdapter.ts | 4 +++- .../agentSessions/stateToProgressAdapter.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 8b041af73c6bb1..d3c7d8f9d42137 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -2483,7 +2483,9 @@ export function toolCallStateToStreamingInvocation(tc: ToolCallState, subAgentIn } else if (isRenameChatTool(tc)) { invocation.presentation = ToolInvocationPresentation.Hidden; } - if (sessionResource && isSubagentTool(tc)) { + if (getToolKind(tc) === 'search') { + invocation.toolSpecificData = { kind: 'search' }; + } else if (sessionResource && isSubagentTool(tc)) { invocation.toolSpecificData = toolCallStateToInvocation(tc, subAgentInvocationId, sessionResource, connectionAuthority ?? '', mcpServerAuthority).toolSpecificData; } return invocation; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index c334b8822a4005..2ccc33aab68a12 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -1948,6 +1948,18 @@ suite('stateToProgressAdapter', () => { }); }); + test('toolCallStateToStreamingInvocation preserves search rendering before ready', () => { + const invocation = toolCallStateToStreamingInvocation({ + toolCallId: 'tc-rg', + toolName: 'rg', + displayName: 'Search', + status: ToolCallStatus.Streaming, + _meta: { toolKind: 'search' }, + }, undefined); + + assert.deepStrictEqual(invocation.toolSpecificData, { kind: 'search' }); + }); + test('toolCallStateToStreamingInvocation preserves subagent metadata before ready', () => { const sessionResource = URI.parse('copilotcli:/session-1'); const invocation = toolCallStateToStreamingInvocation({ From cc9dea5d874df916730aef38730e2e9a5372110f Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci <39117631+Giuspepe@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:15:23 +0200 Subject: [PATCH 16/23] agentHost: tolerate unavailable catalogs in direct session tools (#333991) * agentHost: avoid catalog lookup for direct session tools Use targeted session metadata for get_current_session and resolve explicit create_session workspaces without enumerating provider-wide sessions.\n\nFixes #333794. * agentHost: preserve URI-shaped project name resolution Prefer catalog-based project resolution when available, while retaining the explicit workspace fallback for providers that cannot enumerate sessions. --- .../node/shared/sessionServerTools.ts | 30 +++++- .../test/node/sessionServerTools.test.ts | 96 +++++++++++++++++-- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index e3a13d572d71b2..2cddea6e377c72 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -407,6 +407,28 @@ function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMet return parsed; } +async function getCreateSessionCatalog(accessor: ISessionServerToolAccessor, rawArgs: unknown): Promise { + const args = (rawArgs ?? {}) as ICreateSessionArgs; + if (getCreateSessionRelationship(args) !== 'independent') { + return []; + } + const workspace = getOptionalString(args.workspace, 'workspace', SessionServerToolName.CreateSession); + if (workspace === undefined) { + return []; + } + try { + // Prefer the catalog because a project display name can also be a valid URI. + return await accessor.listSessions(); + } catch (error) { + // An explicit URI/path is self-contained, so it remains usable when a + // provider cannot enumerate its session catalog. + if (parseWorkspaceUri(workspace) !== undefined) { + return []; + } + throw error; + } +} + function resolveModel(modelName: string | undefined, models: readonly IAgentModelInfo[], provider?: AgentProvider): IAgentModelInfo | undefined { if (modelName === undefined) { return undefined; @@ -720,7 +742,7 @@ export interface ICreateSessionResult { * Creates work with the requested relationship and sends its initial prompt. */ export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI, sourceTurnId?: string): Promise { - const sessions = await accessor.listSessions(); + const sessions = await getCreateSessionCatalog(accessor, rawArgs); const currentSession = source ? currentSessionUri(source.toString()) : undefined; const currentProvider = currentSession ? AgentSession.provider(currentSession) : undefined; const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels(), currentProvider); @@ -1391,7 +1413,11 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs))); } case SessionServerToolName.GetCurrentSession: - return serializeCurrentSession(currentSessionUri(currentChannel), await accessor.listSessions()); + { + const currentSession = currentSessionUri(currentChannel); + const metadata = await accessor.getSession(currentSession); + return serializeCurrentSession(currentSession, metadata ? [metadata] : []); + } case SessionServerToolName.CreateSession: { const relationship = getCreateSessionRelationship(rawArgs); if (relationship === 'currentSession' && createdChatCount >= maxCreatedChats) { diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 6f4d8c1357fd5d..f4e3969ea7782f 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -82,9 +82,10 @@ suite('SessionServerTools', () => { if (!config) { return undefined; } - const { _meta, ...rest } = config; + const { _meta, workingDirectories, ...rest } = config; return { ...rest, + ...(workingDirectories !== undefined ? { workingDirectories: workingDirectories.map(directory => directory.toString()) } : {}), createdBySession: readSessionCreationReference(_meta), }; } @@ -606,7 +607,7 @@ suite('SessionServerTools', () => { const text = await group.execute(stateManager, executionContext('copilot:/caller'), SessionServerToolName.CreateSession, { relationship: 'independent', workspace: workspace.toString(), prompt: 'do it', title: 'New Task', model: 'gpt-4o' }); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', model: { id: 'gpt-4o' }, createdBySession: { @@ -638,6 +639,64 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('create_session falls back to an explicit workspace when listing sessions fails', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + let created: IAgentCreateSessionConfig | undefined; + let catalogRequests = 0; + const accessor = createAccessor({ + listSessions: async () => { + catalogRequests++; + throw new Error('Provider codex cannot enumerate its native session catalog yet'); + }, + onCreate: config => { created = config; }, + }); + const group = createSessionServerToolGroup(accessor); + + const text = await group.execute(stateManager, executionContext('codex:/caller'), SessionServerToolName.CreateSession, { + relationship: 'independent', + workspace: workspace.toString(), + prompt: 'do it', + title: 'New Task', + }); + + assert.deepStrictEqual({ + catalogRequests, + workingDirectories: created?.workingDirectories?.map(directory => directory.toString()), + result: text.startsWith('New session created'), + }, { + catalogRequests: 1, + workingDirectories: [workspace.toString()], + result: true, + }); + store.dispose(); + }); + + test('create_session prefers a URI-shaped project display name from the catalog', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const project = URI.parse('file:///projects/repo-main'); + let created: IAgentCreateSessionConfig | undefined; + const accessor = createAccessor({ + listSessions: async () => [{ + ...sessionMeta('project', SessionStatus.Idle, URI.parse('file:///worktrees/repo-main')), + project: { uri: project, displayName: 'repo:main' }, + }], + onCreate: config => { created = config; }, + }); + const group = createSessionServerToolGroup(accessor); + + await group.execute(stateManager, executionContext('copilot:/caller'), SessionServerToolName.CreateSession, { + relationship: 'independent', + workspace: 'repo:main', + prompt: 'do it', + title: 'New Task', + }); + + assert.deepStrictEqual(created?.workingDirectories?.map(directory => directory.toString()), [project.toString()]); + store.dispose(); + }); + test('create_session and send_message results are neutral, non-terminal statements (issue #330138)', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); @@ -700,7 +759,7 @@ suite('SessionServerTools', () => { }, { creationSource: source.toString(), created: { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', model: { id: 'gpt-inherited' }, createdBySession: { @@ -732,7 +791,7 @@ suite('SessionServerTools', () => { assert.deepStrictEqual(created.map(createConfigSnapshot), [ { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', createdBySession: { session: 'copilot:/source', @@ -741,7 +800,7 @@ suite('SessionServerTools', () => { config: { [SessionConfigKey.Isolation]: 'worktree' }, }, { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'copilot', createdBySession: { session: 'copilot:/quick-chat', @@ -765,7 +824,7 @@ suite('SessionServerTools', () => { await applyCreateSessionTool(accessor, { relationship: 'independent', workspace: workspace.toString(), prompt: 'do it', title: 'Provider Task' }, URI.parse('claude:/source')); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [workspace], + workingDirectories: [workspace.toString()], provider: 'claude', createdBySession: { session: 'claude:/source', @@ -794,7 +853,7 @@ suite('SessionServerTools', () => { }, URI.parse('copilot:/source')); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [gitWorkspace], + workingDirectories: [gitWorkspace.toString()], provider: 'copilot', createdBySession: { session: 'copilot:/source', @@ -828,7 +887,7 @@ suite('SessionServerTools', () => { }, URI.parse('copilot:/source')); assert.deepStrictEqual(createConfigSnapshot(created), { - workingDirectories: [remoteProject], + workingDirectories: [remoteProject.toString()], provider: 'claude', model: { id: 'claude-sonnet' }, config: { [SessionConfigKey.Isolation]: 'folder' }, @@ -1453,6 +1512,27 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('get_current_session does not depend on listing sessions', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const metadata = { ...sessionMeta('s1', SessionStatus.Idle, workspace), session: URI.parse('codex:/s1') }; + const group = createSessionServerToolGroup(createAccessor({ + listSessions: async () => { throw new Error('Provider codex cannot enumerate its native session catalog yet'); }, + getSession: async session => session.toString() === metadata.session.toString() ? metadata : undefined, + })); + + const text = await group.execute(stateManager, executionContext('codex:/s1'), SessionServerToolName.GetCurrentSession, {}); + + assert.deepStrictEqual(JSON.parse(text), { + session: 'codex:/s1', + openLink: 'agent-host-session://codex/s1', + title: 'title-s1', + status: 'idle', + workingDirectory: 'file:///workspace/app', + }); + store.dispose(); + }); + test('getDeleteSessionArgs validates and refuses the current session', () => { const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace), sessionMeta('s2', SessionStatus.Idle, workspace)]; assert.strictEqual(getDeleteSessionArgs({ session: 'copilot:/s2' }, sessions).toString(), 'copilot:/s2'); From ea742eb8ea33725fda1f0c3528bda6b214084977 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci <39117631+Giuspepe@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:26:12 +0200 Subject: [PATCH 17/23] agentHost: hide routine Codex approval review notices (#333956) * agentHost: hide routine Codex approval review notices Keep individual review outcomes out of chat transcripts while preserving the circuit-breaker warning that interrupts a turn. * agentHost: surface incomplete Codex approval reviews Keep routine successful reviews quiet while preserving structured timeout, aborted, and circuit-breaker feedback in the chat UI. --- .../meta/agentSystemNotificationMeta.ts | 9 ++ .../agentHost/node/codex/codexAgent.ts | 50 ++++-- .../node/codex/codexGuardianReview.ts | 19 +++ .../test/node/codex/codexAgent.test.ts | 143 +++++++++++++++++- .../node/codex/codexGuardianReview.test.ts | 15 +- .../agentHost/stateToProgressAdapter.ts | 6 + .../stateToProgressAdapter.test.ts | 18 +++ 7 files changed, 245 insertions(+), 15 deletions(-) diff --git a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts index a96df3fe4b87fc..b3f6c1efde0261 100644 --- a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts @@ -5,6 +5,12 @@ export const enum AgentSystemNotificationKind { WorktreeCreationFailure = 'worktreeCreationFailure', + /** An automatic approval review did not finish before its deadline. */ + AutomaticApprovalReviewTimedOut = 'automaticApprovalReviewTimedOut', + /** An automatic approval review stopped before reaching a decision. */ + AutomaticApprovalReviewAborted = 'automaticApprovalReviewAborted', + /** Automatic approval review denials triggered the turn circuit breaker. */ + AutomaticApprovalReviewInterrupted = 'automaticApprovalReviewInterrupted', /** Agent Merge started monitoring the session's branch. */ AgentMergeEnabled = 'agentMergeEnabled', /** Effective Agent Merge behavior changed while monitoring. */ @@ -19,6 +25,9 @@ export const enum AgentSystemNotificationSeverity { const knownKinds: ReadonlySet = new Set([ AgentSystemNotificationKind.WorktreeCreationFailure, + AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut, + AgentSystemNotificationKind.AutomaticApprovalReviewAborted, + AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted, AgentSystemNotificationKind.AgentMergeEnabled, AgentSystemNotificationKind.AgentMergeConfigurationChanged, AgentSystemNotificationKind.AgentMergeDisabled, diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 503600216029e4..2143c0f4240a38 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -25,6 +25,7 @@ import { IProductService } from '../../../product/common/productService.js'; import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, createAgentModelGroupMeta, createAgentModelSourceMeta } from '../../common/agentModelSource.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { AgentSdkSetupChannel } from '../agentSdkSetupChannel.js'; import { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, type ICodexAccountInfo } from '../../common/codexAccount.js'; @@ -146,7 +147,7 @@ import type { GuardianWarningNotification } from './protocol/generated/v2/Guardi import type { ThreadApproveGuardianDeniedActionResponse } from './protocol/generated/v2/ThreadApproveGuardianDeniedActionResponse.js'; import type { ConfigReadResponse } from './protocol/generated/v2/ConfigReadResponse.js'; import type { ConfigWriteResponse } from './protocol/generated/v2/ConfigWriteResponse.js'; -import { formatGuardianDenialNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js'; +import { formatGuardianDenialNotification, formatGuardianReviewStatusNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js'; import { CODEX_COMPACT_SLASH_COMMAND } from '../codexCompactCommand.js'; const CLIENT_INFO = { @@ -165,6 +166,7 @@ const CODEX_THREAD_TURNS_PAGE_SIZE = 100; const CODEX_STARTUP_ACCOUNT_PROBE_TIMEOUT_MS = 30_000; const CODEX_DESKTOP_WORKSPACE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const CODEX_DESKTOP_SESSION_META_PATTERN = /"type"\s*:\s*"session_meta".*"payload"\s*:\s*\{[^}]*"originator"\s*:\s*"Codex Desktop"/s; +const CODEX_GUARDIAN_TURN_INTERRUPTION_PREFIX = 'Automatic approval review rejected too many approval requests for this turn'; function isCodexDesktopGeneratedWorkspace(cwd: string, userHome: URI): boolean { const relativePath = extUriBiasedIgnorePathCase.relativePath(userHome, URI.file(cwd)); @@ -603,9 +605,9 @@ interface ICodexSession { */ readonly acceptedForSession: Set; /** - * Guardian (auto-review) `reviewId`s that have already been surfaced to - * the user as a denied-action approval card. Guards against acting twice - * on the same review if the completed notification is redelivered. + * Guardian (auto-review) `reviewId`s whose terminal outcome has already + * been surfaced. Guards against acting twice on the same review if the + * completed notification is redelivered. */ readonly handledGuardianReviews: Set; /** @@ -2473,11 +2475,12 @@ export class CodexAgent extends Disposable implements IAgent { subscriptions.add(client.onNotification('thread/tokenUsage/updated', params => this._dispatchTokenUsageUpdated(params))); subscriptions.add(client.onNotification('item/completed', params => this._dispatchItemCompleted(params))); subscriptions.add(client.onNotification('turn/completed', params => this._dispatchTurnCompleted(params))); - // Auto-review (guardian) surfacing. The guardian warning is shown as a - // system notification; a completed *denied* review is turned into a + // Auto-review (guardian) surfacing. The guardian turn-interruption warning + // is shown as a system notification; terminal review failures are surfaced + // from the structured completion event, and a denied review also gets a // retroactive "Approve anyway" tool-call card. The review lifecycle is - // non-blocking (codex does not wait on us), so the completed handler is - // async and resolves its session directly rather than via _dispatchByThread. + // non-blocking (codex does not wait on us), so the completed handler is async + // and resolves its session directly rather than via _dispatchByThread. subscriptions.add(client.onNotification('guardianWarning', params => this._dispatchByThread(params.threadId, s => this._handleGuardianWarning(s, params)))); subscriptions.add(client.onNotification('item/autoApprovalReview/completed', params => { void this._handleGuardianReviewCompleted(client, params); })); @@ -3676,6 +3679,11 @@ export class CodexAgent extends Disposable implements IAgent { } private _handleGuardianWarning(session: ICodexSession, params: GuardianWarningNotification): ChatAction[] { + // Individual review outcomes are handled by the structured review event. The + // warning channel is only needed when the review circuit breaker ends a turn. + if (!params.message.startsWith(CODEX_GUARDIAN_TURN_INTERRUPTION_PREFIX)) { + return []; + } const turnId = session.currentTurnId; if (turnId === undefined) { this._logService.trace(`[Codex:${session.sessionId}] guardianWarning without active turn; ignoring`); @@ -3687,6 +3695,7 @@ export class CodexAgent extends Disposable implements IAgent { part: { kind: ResponsePartKind.SystemNotification, content: params.message, + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted }), }, }]; } @@ -3698,18 +3707,18 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.trace(`[Codex] autoApprovalReview/completed for unknown threadId=${params.threadId}; ignoring`); return; } - if (params.review.status !== 'denied') { + const status = params.review.status; + if (status === 'approved' || status === 'inProgress') { return; } if (session.handledGuardianReviews.has(params.reviewId)) { return; } - // Bind the denial surfacing to the review's OWN turn (mapped app→host), + // Bind review surfacing to the review's OWN turn (mapped app→host), // not whatever turn happens to be current. An `autoApprovalReview/completed` // that arrives out of order — after its turn ended, or once a later turn is - // active — must not mis-attribute the notice/card to a different turn, nor - // apply this review's stale action against it. When the review's turn is no - // longer the active turn there is nothing left to approve within it, so ignore. + // active — must not mis-attribute its notice to a different turn. A denied + // review's override also stops being actionable after its turn ends. const turnId = this._hostTurnId(session, params.turnId); if (session.currentTurnId !== turnId) { this._logService.trace(`[Codex:${sessionId}] autoApprovalReview/completed for non-current turn ${turnId} (current=${session.currentTurnId ?? '(none)'}); ignoring reviewId=${params.reviewId}`); @@ -3719,6 +3728,21 @@ export class CodexAgent extends Disposable implements IAgent { session.handledGuardianReviews.add(params.reviewId); const summary = summarizeGuardianReviewAction(params.action); + if (status === 'timedOut' || status === 'aborted') { + const kind = status === 'timedOut' + ? AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut + : AgentSystemNotificationKind.AutomaticApprovalReviewAborted; + this._fire(session.sessionUri, { + type: ActionType.ChatResponsePart, + turnId, + part: { + kind: ResponsePartKind.SystemNotification, + content: formatGuardianReviewStatusNotification(summary, status, params.review.rationale), + _meta: toAgentSystemNotificationMeta({ kind }), + }, + }); + return; + } // Durable record: a Markdown response part survives turn completion AND is // rendered by the live streaming path (unlike a system-notification part, diff --git a/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts b/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts index d73e4ee94ed418..f5a736cbacf195 100644 --- a/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts +++ b/src/vs/platform/agentHost/node/codex/codexGuardianReview.ts @@ -7,6 +7,7 @@ import type { GuardianApprovalReviewAction } from './protocol/generated/v2/Guard import type { ItemGuardianApprovalReviewCompletedNotification } from './protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js'; import type { RequestPermissionProfile } from './protocol/generated/v2/RequestPermissionProfile.js'; import type { JsonValue } from './protocol/generated/serde_json/JsonValue.js'; +import { localize } from '../../../../nls.js'; import { unwrapShellInvocation } from './codexShellCommand.js'; /** @@ -199,3 +200,21 @@ export function formatGuardianDenialNotification(summary: IGuardianActionSummary // part; trailing newline keeps subsequent model output on its own block. return `\n\n${quoted}\n`; } + +/** Compose a compact, collapsible notification for a review that did not decide. */ +export function formatGuardianReviewStatusNotification(summary: IGuardianActionSummary, status: 'timedOut' | 'aborted', rationale: string | null): string { + const title = status === 'timedOut' + ? localize('codex.guardianReview.timedOut', "Auto-review timed out") + : localize('codex.guardianReview.aborted', "Auto-review stopped"); + const detail = summary.detail?.trim(); + const action = detail ? `${summary.title} ${inlineCode(detail)}` : summary.title; + const lines = [ + title, + localize('codex.guardianReview.requestedAction', "Requested action: {0}", action), + ]; + const reason = rationale?.trim(); + if (reason) { + lines.push('', reason); + } + return lines.join('\n'); +} diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index be0f0153c0b4f1..6d4e7745a8a8bf 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -11,13 +11,17 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentChatMigrationDeferred, AgentSession, CODEX_AGENT_PROVIDER_ID, type AgentProvider, type IAgentChatContext, type IAgentDiscoveredChat } from '../../../common/agent.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; +import { ActionType, type ChatAction } from '../../../common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpServerStatus, type McpServerCustomization } from '../../../common/state/protocol/channels-session/state.js'; -import { buildDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../../../common/state/sessionState.js'; +import { buildDefaultChatUri, parseRequiredSessionUriFromChatUri, ResponsePartKind } from '../../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { getCustomizationEnablementKey, type CustomizationEnablementResolution, type ICustomizationEnablementTarget } from '../../../node/agentHostCustomizationEnablementService.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { CodexClientCustomizationStore, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; import type { ICodexMcpServerConfigJson, ICodexMcpServerEntry } from '../../../node/codex/codexMcpServers.js'; +import type { ItemGuardianApprovalReviewCompletedNotification } from '../../../node/codex/protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js'; +import type { GuardianWarningNotification } from '../../../node/codex/protocol/generated/v2/GuardianWarningNotification.js'; import { targetForMcpServer } from '../../../node/shared/customizationEnablementGate.js'; import { McpCustomizationController, type IMcpCustomizationControllerOptions } from '../../../node/shared/mcpCustomizationController.js'; import { createGitHubMcpServerConfiguration, getGitHubMcpTools } from '../../../node/shared/githubMcpServer.js'; @@ -79,6 +83,31 @@ interface ICodexAuthenticateHarness { authenticate(resource: string, token: string): Promise; } +interface ICodexGuardianWarningHarness { + readonly _logService: NullLogService; +} + +interface ICodexGuardianWarningSession { + readonly sessionId: string; + readonly currentTurnId: string | undefined; +} + +interface ICodexGuardianReviewSession { + readonly sessionId: string; + readonly sessionUri: URI; + readonly currentTurnId: string | undefined; + readonly hostTurnIdByAppTurnId: Map; + readonly handledGuardianReviews: Set; +} + +interface ICodexGuardianReviewHarness { + readonly _logService: NullLogService; + readonly _sessionIdByThreadId: Map; + readonly _sessions: Map; + _hostTurnId(session: ICodexGuardianReviewSession, appTurnId: string): string; + _fire(sessionUri: URI, action: ChatAction): void; +} + function resolveConversationSession(harness: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined { const resolver = (CodexAgent.prototype as unknown as { _resolveConversationSession(this: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined; @@ -100,6 +129,20 @@ function handleMcpRequest(harness: ICodexMcpRequestHarness, chat: URI): Promise< return handler.call(harness, chat, 'server', 'tools/list', undefined); } +function handleGuardianWarning(harness: ICodexGuardianWarningHarness, session: ICodexGuardianWarningSession, params: GuardianWarningNotification): ChatAction[] { + const handler = (CodexAgent.prototype as unknown as { + _handleGuardianWarning(this: ICodexGuardianWarningHarness, session: ICodexGuardianWarningSession, params: GuardianWarningNotification): ChatAction[]; + })._handleGuardianWarning; + return handler.call(harness, session, params); +} + +function handleGuardianReviewCompleted(harness: ICodexGuardianReviewHarness, params: ItemGuardianApprovalReviewCompletedNotification): Promise { + const handler = (CodexAgent.prototype as unknown as { + _handleGuardianReviewCompleted(this: ICodexGuardianReviewHarness, client: never, params: ItemGuardianApprovalReviewCompletedNotification): Promise; + })._handleGuardianReviewCompleted; + return handler.call(harness, undefined as never, params); +} + function emptyHarness(): ICodexConversationResolverHarness { return { id: CODEX_AGENT_PROVIDER_ID, _sessionIdByChatUri: new Map() }; } @@ -108,6 +151,104 @@ suite('CodexAgent', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('ignores guardian review outcome warnings handled by structured events', () => { + const harness: ICodexGuardianWarningHarness = { _logService: new NullLogService() }; + const session: ICodexGuardianWarningSession = { sessionId: 'session', currentTurnId: 'turn' }; + + for (const message of [ + 'Automatic approval review approved (risk: low, authorization: high): Safe read.', + 'Automatic approval review denied (risk: high, authorization: unknown): Unsafe action.', + 'Automatic approval review timed out while evaluating the requested approval.', + ]) { + assert.deepStrictEqual(handleGuardianWarning(harness, session, { threadId: 'thread', message }), []); + } + }); + + test('surfaces guardian turn-interruption warnings', () => { + const harness: ICodexGuardianWarningHarness = { _logService: new NullLogService() }; + const session: ICodexGuardianWarningSession = { sessionId: 'session', currentTurnId: 'turn' }; + const message = 'Automatic approval review rejected too many approval requests for this turn (5 consecutive, 5 in the last 10 reviews); interrupting the turn.'; + + assert.deepStrictEqual(handleGuardianWarning(harness, session, { threadId: 'thread', message }), [{ + type: ActionType.ChatResponsePart, + turnId: 'turn', + part: { + kind: ResponsePartKind.SystemNotification, + content: message, + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted }), + }, + }]); + }); + + test('surfaces terminal guardian review failures once on their current turn', async () => { + const actions: ChatAction[] = []; + const session: ICodexGuardianReviewSession = { + sessionId: 'session', + sessionUri: URI.parse('codex:/session'), + currentTurnId: 'host-turn', + hostTurnIdByAppTurnId: new Map([ + ['app-turn', 'host-turn'], + ['stale-app-turn', 'stale-host-turn'], + ]), + handledGuardianReviews: new Set(), + }; + const harness: ICodexGuardianReviewHarness = { + _logService: new NullLogService(), + _sessionIdByThreadId: new Map([['thread', session.sessionId]]), + _sessions: new Map([[session.sessionId, session]]), + _hostTurnId: (reviewSession, appTurnId) => reviewSession.hostTurnIdByAppTurnId.get(appTurnId) ?? appTurnId, + _fire: (_sessionUri, action) => actions.push(action), + }; + const notification = (reviewId: string, status: ItemGuardianApprovalReviewCompletedNotification['review']['status'], turnId = 'app-turn', rationale: string | null = null): ItemGuardianApprovalReviewCompletedNotification => ({ + threadId: 'thread', + turnId, + startedAtMs: 10, + completedAtMs: 20, + reviewId, + targetItemId: null, + decisionSource: 'agent', + review: { status, riskLevel: null, userAuthorization: null, rationale }, + action: { + type: 'networkAccess', + target: 'https://example.com', + host: 'example.com', + protocol: 'https', + port: 443, + }, + }); + + await handleGuardianReviewCompleted(harness, notification('approved', 'approved')); + await handleGuardianReviewCompleted(harness, notification('in-progress', 'inProgress')); + await handleGuardianReviewCompleted(harness, notification('stale', 'timedOut', 'stale-app-turn')); + await handleGuardianReviewCompleted(harness, notification('timed-out', 'timedOut', 'app-turn', 'The reviewer did not respond in time.')); + await handleGuardianReviewCompleted(harness, notification('timed-out', 'timedOut', 'app-turn', 'The reviewer did not respond in time.')); + await handleGuardianReviewCompleted(harness, notification('aborted', 'aborted')); + + assert.deepStrictEqual({ actions, handledReviewIds: [...session.handledGuardianReviews] }, { + actions: [ + { + type: ActionType.ChatResponsePart, + turnId: 'host-turn', + part: { + kind: ResponsePartKind.SystemNotification, + content: 'Auto-review timed out\nRequested action: Network access `https://example.com`\n\nThe reviewer did not respond in time.', + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut }), + }, + }, + { + type: ActionType.ChatResponsePart, + turnId: 'host-turn', + part: { + kind: ResponsePartKind.SystemNotification, + content: 'Auto-review stopped\nRequested action: Network access `https://example.com`', + _meta: toAgentSystemNotificationMeta({ kind: AgentSystemNotificationKind.AutomaticApprovalReviewAborted }), + }, + }, + ], + handledReviewIds: ['timed-out', 'aborted'], + }); + }); + test('GitHub MCP injection respects unowned server enablement', () => { const createHarness = (enabled: boolean, customizationEnabled: boolean, token: string | undefined): ICodexGitHubMcpHarness => Object.assign(Object.create(CodexAgent.prototype), { _configurationService: { getRootValue: () => undefined }, diff --git a/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts b/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts index b57533129dcaa2..206d0a56fe8c64 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexGuardianReview.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { formatGuardianDenialNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from '../../../node/codex/codexGuardianReview.js'; +import { formatGuardianDenialNotification, formatGuardianReviewStatusNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from '../../../node/codex/codexGuardianReview.js'; import type { ItemGuardianApprovalReviewCompletedNotification } from '../../../node/codex/protocol/generated/v2/ItemGuardianApprovalReviewCompletedNotification.js'; suite('codexGuardianReview', () => { @@ -145,4 +145,17 @@ suite('codexGuardianReview', () => { ] ); }); + + test('formatGuardianReviewStatusNotification separates the compact title from review details', () => { + assert.deepStrictEqual( + [ + formatGuardianReviewStatusNotification({ title: 'Network access', detail: 'https://example.com' }, 'timedOut', 'The reviewer did not respond in time.'), + formatGuardianReviewStatusNotification({ title: 'Elevated permissions', detail: '' }, 'aborted', null), + ], + [ + 'Auto-review timed out\nRequested action: Network access `https://example.com`\n\nThe reviewer did not respond in time.', + 'Auto-review stopped\nRequested action: Elevated permissions', + ] + ); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index d3c7d8f9d42137..3b6200e65fdd0e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -484,6 +484,12 @@ export function systemNotificationToChatPart(content: StringOrMarkdown | undefin return meta.severity === AgentSystemNotificationSeverity.Warning ? { kind: 'warning', content: markdown } : { kind: 'systemNotification', content: markdown }; + case AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut: + return { kind: 'systemNotification', content: markdown, icon: Codicon.clock, collapsible: true }; + case AgentSystemNotificationKind.AutomaticApprovalReviewAborted: + return { kind: 'systemNotification', content: markdown, icon: Codicon.circleSlash, collapsible: true }; + case AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted: + return { kind: 'systemNotification', content: markdown, icon: Codicon.warning }; // Agent Merge reports a state change rather than a completed step, so the // default check would misdescribe both of these. case AgentSystemNotificationKind.AgentMergeEnabled: diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 2ccc33aab68a12..44f9c8de2e1d6c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -2535,6 +2535,24 @@ suite('stateToProgressAdapter', () => { }); }); + test('styles automatic approval review terminal states', () => { + const notice = (kind: AgentSystemNotificationKind) => activeTurnToProgress(URI.file('/'), createActiveTurnState([{ + kind: ResponsePartKind.SystemNotification, + content: 'Automatic approval review changed state', + _meta: toAgentSystemNotificationMeta({ kind }), + }]), undefined)[0]; + + assert.deepStrictEqual({ + timedOut: notice(AgentSystemNotificationKind.AutomaticApprovalReviewTimedOut), + aborted: notice(AgentSystemNotificationKind.AutomaticApprovalReviewAborted), + interrupted: notice(AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted), + }, { + timedOut: { kind: 'systemNotification', content: new MarkdownString('Automatic approval review changed state'), icon: Codicon.clock, collapsible: true }, + aborted: { kind: 'systemNotification', content: new MarkdownString('Automatic approval review changed state'), icon: Codicon.circleSlash, collapsible: true }, + interrupted: { kind: 'systemNotification', content: new MarkdownString('Automatic approval review changed state'), icon: Codicon.warning }, + }); + }); + test('gives each Agent Merge notice an icon that matches what it reports', () => { const notice = (kind: AgentSystemNotificationKind) => activeTurnToProgress(URI.file('/'), createActiveTurnState([{ kind: ResponsePartKind.SystemNotification, From 6d4875af06ded3bbb9c2f9c4d2cf90a469e1690f Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:35:34 +0200 Subject: [PATCH 18/23] Agents - enable text ellipsis for the changeset picker (#334013) --- .../browser/media/sessionChangesEditor.css | 17 +++++++++++++++-- .../editor/browser/media/editorHeader.css | 4 ++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css index abab6370bdc2ad..e0ef7f03e2b8d4 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css @@ -82,7 +82,7 @@ * `.editor-group-header-primary-actions`). It remains fully interactive/clickable, so no * hover-background override is needed here -- it keeps the normal toolbar hover. */ .changes-diff-stats-action-rich { - flex: 1 1 auto; + flex: 1 0 auto; min-width: 0; } @@ -135,6 +135,18 @@ /* Branch Changes picker (label + chevron) styling, keyed off the item's own * marker class so its chevron stays compact and vertically centered in both the * classic internal changes-editor header and the single-pane editor-group header. */ +.changes-picker-action-rich { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; +} + +.changes-picker-action-rich > .monaco-dropdown, +.changes-picker-action-rich > .monaco-dropdown > .dropdown-label { + min-width: 0; + max-width: 100%; +} + .changes-picker-action-rich .action-label { display: inline-flex; align-items: center; @@ -145,10 +157,11 @@ } .changes-picker-action-rich .action-label > span:not(.codicon) { + flex: 1 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - min-width: 0; } .changes-picker-action-rich .action-label > .codicon { diff --git a/src/vs/sessions/contrib/editor/browser/media/editorHeader.css b/src/vs/sessions/contrib/editor/browser/media/editorHeader.css index 6cb4d3ac65e254..9c40d41d995b9f 100644 --- a/src/vs/sessions/contrib/editor/browser/media/editorHeader.css +++ b/src/vs/sessions/contrib/editor/browser/media/editorHeader.css @@ -33,6 +33,7 @@ align-items: center; min-width: 0; flex: 1 1 auto; + gap: var(--vscode-spacing-size20, 2px); } .agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-primary-actions { @@ -63,6 +64,9 @@ flex-shrink: 0; } +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-primary-actions > .monaco-toolbar, +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-primary-actions > .monaco-toolbar > .monaco-action-bar, +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-primary-actions > .monaco-toolbar > .monaco-action-bar > .actions-container, .agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-secondary-actions > .monaco-toolbar, .agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-secondary-actions > .monaco-toolbar > .monaco-action-bar, .agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-secondary-actions > .monaco-toolbar > .monaco-action-bar > .actions-container { From 1ab6826f3b0bd6011d9bf4f93425c79d2f758df2 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:40:40 -0700 Subject: [PATCH 19/23] chore(ci): check build subfolders (#333870) * chore(ci): check build subfolders * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore(ci): fix nested install indentation Co-authored-by: rzhao271 <7199958+rzhao271@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../agent-sdk/agents/claude/package-lock.json | 56 ++++++++++++------- build/azure-pipelines/dependencies-check.yml | 42 +++++++++++++- build/npm/gyp/package-lock.json | 44 +++++---------- 3 files changed, 87 insertions(+), 55 deletions(-) diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index 015e6be556186d..ad04e126c4c1b7 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -182,9 +182,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "peer": true, "engines": { @@ -292,21 +292,21 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -316,6 +316,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -645,9 +659,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -792,9 +806,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", "license": "MIT", "peer": true, "engines": { @@ -847,9 +861,9 @@ "peer": true }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "peer": true, "engines": { diff --git a/build/azure-pipelines/dependencies-check.yml b/build/azure-pipelines/dependencies-check.yml index 2c81602695bb9b..af659e714bdc77 100644 --- a/build/azure-pipelines/dependencies-check.yml +++ b/build/azure-pipelines/dependencies-check.yml @@ -22,10 +22,13 @@ jobs: variables: VSCODE_ARCH: x64 steps: - - task: NodeTool@0 + - script: | + echo "##vso[task.setvariable variable=NODE_VERSION]$(cat .nvmrc)" + displayName: Read Node version from .nvmrc + + - task: UseNode@1 inputs: - versionSource: fromFile - versionFilePath: .nvmrc + version: $(NODE_VERSION) - task: AzureKeyVault@2 displayName: "Azure Key Vault: Get GitHub token" @@ -84,6 +87,30 @@ jobs: - script: | set -e + # Check if any build subfolders with package-lock.json were modified + git fetch origin main + CHANGED_FILES=$(git diff --name-only origin/main...HEAD) + MODIFIED_BUILD_FOLDERS="" + + # Check if build/package-lock.json itself was modified + if echo "$CHANGED_FILES" | grep -q "^build/package-lock\.json$"; then + MODIFIED_BUILD_FOLDERS="build" + echo "build/package-lock.json was modified" + fi + + # Find all modified package-lock.json files under build subfolders and extract their directories + for file in $CHANGED_FILES; do + if [[ $file =~ ^(build/.+)/package-lock\.json$ ]]; then + dir="${BASH_REMATCH[1]}" + if [[ ! " $MODIFIED_BUILD_FOLDERS " =~ " $dir " ]]; then + MODIFIED_BUILD_FOLDERS="$MODIFIED_BUILD_FOLDERS $dir" + echo "$dir was modified" + fi + fi + done + + echo "##vso[task.setvariable variable=MODIFIED_BUILD_FOLDERS]$MODIFIED_BUILD_FOLDERS" + for attempt in {1..120}; do if [ $attempt -gt 1 ]; then echo "Attempt $attempt: Waiting for 10 minutes before retrying..." @@ -92,6 +119,15 @@ jobs: echo "Attempt $attempt: Running npm ci" if npm i --ignore-scripts; then + # Rebuild each modified build subfolder separately + for folder in $MODIFIED_BUILD_FOLDERS; do + echo "Rebuilding modified $folder..." + if ! (cd "$folder" && npm i --ignore-scripts); then + echo "npm i failed for $folder on attempt $attempt" + continue 2 + fi + done + if node build/npm/postinstall.ts; then echo "npm i succeeded on attempt $attempt" exit 0 diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 887285c3d96c8b..828fa31cf4a071 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -138,9 +138,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -439,15 +439,11 @@ } }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "dev": true, "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -488,13 +484,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -915,13 +904,13 @@ } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -944,13 +933,6 @@ "node": ">= 14" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -1069,9 +1051,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 03978cd3a3d5a4faba125abeb1d4fbb75cce1d46 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 18:00:34 +0200 Subject: [PATCH 20/23] Show session progress for active chats (#333999) * Agent Host changes for sandy081/agents/session-progress-indicator * Handle mixed active chat statuses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update session progress screenshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/views/sessionsList.ts | 11 +++++++-- .../test/browser/sessionsList.test.ts | 24 +++++++++++++++++++ .../sessions/sessionsList.fixture.ts | 24 ++++++++++++++++++- .../blocks-ci-screenshots.md | 6 +++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index fa3818ce480c37..109218084ae841 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -214,9 +214,16 @@ function getSessionListChats(session: ISession, reader?: IReader): readonly ICha ); } -/** Returns the main-chat status for trees with chat rows, otherwise the aggregate session status. */ +/** Returns in-progress when any chat is active, then the main-chat status for trees with chat rows. */ function getSessionRowStatus(session: ISession, reader: IReader | undefined, deriveFromMainChat: boolean): SessionStatus { - return deriveFromMainChat ? session.mainChat.read(reader).status.read(reader) : session.status.read(reader); + const sessionStatus = session.status.read(reader); + if (!deriveFromMainChat) { + return sessionStatus; + } + if (session.chats.read(reader).some(chat => chat.status.read(reader) === SessionStatus.InProgress)) { + return SessionStatus.InProgress; + } + return session.mainChat.read(reader).status.read(reader); } function isSessionGroupItem(item: SessionListItem): item is ISessionGroupItem { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index fa4a342a22fe38..526c469fc604b4 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -1263,6 +1263,30 @@ suite('Sessions - SessionsList', () => { }); }); + test('parent session row shows progress while one non-main chat needs input and another is in progress', () => { + const main = createChat('Main chat'); + const waiting = createChat('Waiting chat', ChatOriginKind.User, ChatInteractivity.Full, SessionStatus.NeedsInput); + const active = createChat('Active chat', ChatOriginKind.User, ChatInteractivity.Full, SessionStatus.InProgress); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + status: constObservable(SessionStatus.NeedsInput), + chats: constObservable([main, waiting, active]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + + const container = renderSessionChats(session, undefined, true); + + assert.deepStrictEqual({ + session: sessionRowSnapshot(container), + hasProgress: !!container.querySelector('.session-item .session-icon > .monaco-pixel-spinner'), + }, { + session: { inProgress: true, needsInput: false, ariaLabel: 'Session, updated now, State: In Progress' }, + hasProgress: true, + }); + }); + test('needs-input chat row gets the same accent-pulse feedback class as a needs-input session row', () => { const main = createChat('Main chat'); const waiting = createChat('Waiting chat', ChatOriginKind.User, ChatInteractivity.Full, SessionStatus.NeedsInput); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index 556ba6ecca2627..4600407be5705c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -68,6 +68,7 @@ interface ISessionSpec { readonly title: string; readonly workspace?: string; readonly status?: SessionStatus; + readonly mainChatStatus?: SessionStatus; readonly description?: string; readonly minutesAgo: number; readonly changesSummary?: ISessionChangesSummary; @@ -128,7 +129,7 @@ function createSession(spec: ISessionSpec, approvals: Map() { override readonly resource = mainChatResource; - override readonly status: IObservable = constObservable(spec.status ?? SessionStatus.Completed); + override readonly status: IObservable = constObservable(spec.mainChatStatus ?? spec.status ?? SessionStatus.Completed); override readonly interactivity: IObservable = constObservable(ChatInteractivity.Full); }(); const nestedChats = (spec.chats ?? []).map(chatSpec => createChat(spec.id, chatSpec, updatedAt, approvals)); @@ -386,6 +387,27 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { width: 260, }), }), + SessionsList_PeerChatInProgress: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['An expanded session has a completed main chat and two nested peer chat rows. The session row and the active "Fix empty files restore" peer chat row both show blue in-progress icons, while the completed "Fix single-pane details layout" peer chat shows an inactive dot. The session details say "Working...".'], + render: ctx => renderSessionsList(ctx, { + sessions: [ + { + id: 'a', + title: 'Single-pane details behavior', + workspace: 'vscode', + minutesAgo: 0, + status: SessionStatus.InProgress, + mainChatStatus: SessionStatus.Completed, + chats: [ + { id: 'layout', title: 'Fix single-pane details layout' }, + { id: 'restore', title: 'Fix empty files restore', status: SessionStatus.InProgress }, + ], + }, + ], + width: 620, + }), + }), SessionsList_WorkspaceSection: defineComponentFixture({ render: ctx => renderSessionsList(ctx, { sessions: [{ id: 'c', title: 'Update onboarding copy', workspace: 'vscode-docs', minutesAgo: 180 }], diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index aa695151173ee0..2304943a2308ab 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -221,3 +221,9 @@ #### sessions/sessionsList/SessionsList_NestedChatHierarchyGuides/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/c46f523601ae0d57ae2bb306465cac7f1fb127adf02c3047c362f8432bd2c864) + +#### sessions/sessionsList/SessionsList_PeerChatInProgress/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e07a2bd5dbb42d2ab31f8c5a2845a1262d4d10ba78625da7461a232cf4de28d0) + +#### sessions/sessionsList/SessionsList_PeerChatInProgress/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4aceb7c295b1a4b1cb8675fbbfa3376d9951506a0d02e6f34eac1770dd148110) From f6f7c31e6cd2541fdd901f045a3418a06f2c3aca Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:01:44 -0700 Subject: [PATCH 21/23] chore(ci): remove squash requirement (#333892) --- .../azure-pipelines/product-build-ado-ci.yml | 88 ++++++++++--------- 1 file changed, 47 insertions(+), 41 deletions(-) diff --git a/build/azure-pipelines/product-build-ado-ci.yml b/build/azure-pipelines/product-build-ado-ci.yml index b2c62d5a7154de..5fcf81dbe211c4 100644 --- a/build/azure-pipelines/product-build-ado-ci.yml +++ b/build/azure-pipelines/product-build-ado-ci.yml @@ -260,57 +260,63 @@ extends: - script: | set -euo pipefail - if [ "$(Build.Reason)" = "PullRequest" ]; then - # Azure normally checks out a synthetic merge commit for PR validation. Its first - # parent is the target and its second parent is the PR head. Fall back to finding - # the merge base when the provider checks out the PR head directly. - parent_count=$(git rev-list --parents -n 1 HEAD | awk '{ print NF - 1 }') - if [ "$parent_count" = "2" ]; then - base_sha=$(git rev-parse HEAD^1) - head_sha=$(git rev-parse HEAD^2) - else - target_branch="$(System.PullRequest.TargetBranch)" - target_branch="${target_branch#refs/heads/}" - git fetch origin "$target_branch:refs/remotes/origin/$target_branch" - head_sha=$(git rev-parse HEAD) - base_sha=$(git merge-base "origin/$target_branch" "$head_sha") - fi + # Only validate trailers for PR builds + if [ "$(Build.Reason)" != "PullRequest" ]; then + echo "Skipping trailer check for non-PR builds" + exit 0 + fi + + # Azure normally checks out a synthetic merge commit for PR validation. Its first + # parent is the target and its second parent is the PR head. Fall back to finding + # the merge base when the provider checks out the PR head directly. + parent_count=$(git rev-list --parents -n 1 HEAD | awk '{ print NF - 1 }') + if [ "$parent_count" = "2" ]; then + base_sha=$(git rev-parse HEAD^1) + head_sha=$(git rev-parse HEAD^2) else - # Manual builds validate the selected branch's latest commit. + target_branch="$(System.PullRequest.TargetBranch)" + target_branch="${target_branch#refs/heads/}" + git fetch origin "$target_branch:refs/remotes/origin/$target_branch" head_sha=$(git rev-parse HEAD) - base_sha=$(git rev-parse HEAD^1) + base_sha=$(git merge-base "origin/$target_branch" "$head_sha") fi - # MSRC pull requests must be squashed to one commit before validation succeeds. - commit_count=$(git rev-list --count "$base_sha..$head_sha") - if [ "$commit_count" != "1" ]; then - echo "##vso[task.logissue type=error]This PR has $commit_count commits. release/msrc/* PRs must contain exactly one commit. Please squash your commits." - exit 1 - fi - echo "PR has a single commit." + # Check each commit in the PR for the Msrc-Case-Id trailer + commits=$(git rev-list "$base_sha..$head_sha") + commits_with_trailer=0 + total_commits=0 - # Extract every Msrc-Case-Id trailer value from the PR head commit. - mapfile -t trailer_values < <( - git log -1 --pretty='format:%(trailers:key=Msrc-Case-Id,valueonly=true)' "$head_sha" | - sed '/^[[:space:]]*$/d' - ) + for commit in $commits; do + total_commits=$((total_commits + 1)) - if [ "${#trailer_values[@]}" = "0" ]; then - echo "##vso[task.logissue type=error]Commit $head_sha is missing the required 'Msrc-Case-Id' trailer." - printf "Add a trailer to the commit message, for example:\n\n Msrc-Case-Id: 12345\n\nIf there is no associated case ID, use N/A:\n\n Msrc-Case-Id: N/A\n\n" - exit 1 - fi + # Extract Msrc-Case-Id trailer values from this commit + mapfile -t trailer_values < <( + git log -1 --pretty='format:%(trailers:key=Msrc-Case-Id,valueonly=true)' "$commit" | + sed '/^[[:space:]]*$/d' + ) + + if [ "${#trailer_values[@]}" -gt 0 ]; then + commits_with_trailer=$((commits_with_trailer + 1)) - # Every supplied case ID must be numeric or explicitly marked not applicable. - for value in "${trailer_values[@]}"; do - if [[ ! "$value" =~ ^[0-9]+$ && "$value" != "N/A" ]]; then - echo "##vso[task.logissue type=error]Commit $head_sha has an invalid 'Msrc-Case-Id' trailer value. Expected a number or N/A." - printf "Use a numeric case id or N/A, for example:\n\n Msrc-Case-Id: 12345\n\n" - exit 1 + # Validate all trailer values are numeric or N/A + for value in "${trailer_values[@]}"; do + if [[ ! "$value" =~ ^[0-9]+$ && "$value" != "N/A" ]]; then + echo "##vso[task.logissue type=error]Commit $commit has an invalid 'Msrc-Case-Id' trailer value. Expected a number or N/A." + printf "Use a numeric case id or N/A, for example:\n\n Msrc-Case-Id: 12345\n\n" + exit 1 + fi + done + + echo "Commit $commit has the following 'Msrc-Case-Id' trailers: ${trailer_values[*]}" fi done - echo "Commit $head_sha has the required 'Msrc-Case-Id' trailer." + # At least one commit must have the Msrc-Case-Id trailer + if [ "$commits_with_trailer" -lt 1 ]; then + echo "##vso[task.logissue type=error]Expected at least one commit with 'Msrc-Case-Id' trailer, but found $commits_with_trailer out of $total_commits commits with a trailer." + printf "Add a trailer to at least one commit message, for example:\n\n Msrc-Case-Id: 12345\n\nIf there is no associated case ID, use N/A:\n\n Msrc-Case-Id: N/A\n\n" + exit 1 + fi displayName: Verify Commit and Trailer - stage: Quality From ba368bcf3d87d65b0c9340b2b6224a213d63ebfb Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:17:59 +0200 Subject: [PATCH 22/23] agentHost: add draft PR Agent Merge operation (#333964) * agentHost: add draft PR Agent Merge operation Add a combined operation that creates a draft pull request and enables Agent Merge while keeping draft PRs ineligible for automatic merging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: keep Agent Merge primary until draft is ready Keep the running Agent Merge action primary while draft pull request CI or review comments remain unsettled, then restore Mark Ready once both are clear. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: drive draft readiness operations from host Advertise a separate Mark Ready operation while Agent Merge owns the primary button, and switch to the normal Mark Ready operation only after required checks and actionable review feedback are clear. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: seed Agent Merge target from known PR Use branch-matched session GitHub metadata when Agent Merge first captures its target so the enablement notice immediately reflects an existing pull request without delaying the feedback watermark. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: report Agent Merge PR completion Post a host-owned, user-only system notification with the pull request link after Agent Merge successfully merges it, using a dedicated Agent Merge completion kind and icon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: streamline Agent Merge notices Hide response footer actions for Agent Merge system notifications and render their completion time inline on hover or keyboard focus. Add full-chat fixtures for the enablement, repair, response, and merged-notice flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: align Agent Merge notice timing Align inline Agent Merge timestamps to the notification's last text baseline and place them at the chat row's right edge, matching response footer timing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: use enabled and disabled in Agent Merge notices Replace on, off, and turned-off terminology in Agent Merge transcript notices with enabled and disabled wording, including automatic merge demotion guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChangesetOperationService.ts | 1 + .../platform/agentHost/common/agentMerge.ts | 86 +++++++---- .../meta/agentSystemNotificationMeta.ts | 3 + ...ostPullRequestLifecycleOperationHandler.ts | 1 + .../agentHostPullRequestOperationHandler.ts | 5 +- .../agentHostPullRequestOperationProvider.ts | 38 ++++- .../node/agentHostPullRequestStatusService.ts | 51 +++++-- .../agentHost/node/agentMergeController.ts | 18 ++- .../agentHost/test/common/agentMerge.test.ts | 72 ++++++++- ...entHostPullRequestOperationHandler.test.ts | 29 ++++ ...ntHostPullRequestOperationProvider.test.ts | 49 +++++- .../agentHostPullRequestStatusService.test.ts | 96 +++++++++++- .../test/node/agentMergeController.test.ts | 66 +++++++- .../agentHost/test/node/agentService.test.ts | 4 +- .../agentHost/browser/agentMergeActions.ts | 11 +- .../test/browser/agentMergeActions.test.ts | 12 ++ .../agentHost/stateToProgressAdapter.ts | 12 +- .../chatSystemNotificationContentPart.ts | 26 +++- .../chatSystemNotificationContentPart.css | 44 ++++++ .../chat/browser/widget/chatListRenderer.ts | 48 +++--- .../chat/browser/widget/media/chat.css | 4 + .../chat/common/chatService/chatService.ts | 2 + .../stateToProgressAdapter.test.ts | 8 +- .../chatSystemNotificationContentPart.test.ts | 19 +++ .../chat/chatAgentMergeFlow.fixture.ts | 142 ++++++++++++++++++ .../chat/chatAgentMergeNotice.fixture.ts | 6 +- .../chat/chatWidget.fixture.ts | 33 +++- 27 files changed, 775 insertions(+), 111 deletions(-) create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeFlow.fixture.ts diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 78bb549841ebe6..ae9a30f2bc5604 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -23,6 +23,7 @@ export const AGENT_HOST_SYNC_CHANGESET_OPERATION_ID = 'sync'; */ export const AgentHostPullRequestOperationId = { MarkReady: 'pr-mark-ready', + MarkReadyWithAgentMerge: 'pr-mark-ready-with-agent-merge', Merge: 'pr-merge', EnableAutoMerge: 'pr-enable-auto-merge', DisableAutoMerge: 'pr-disable-auto-merge', diff --git a/src/vs/platform/agentHost/common/agentMerge.ts b/src/vs/platform/agentHost/common/agentMerge.ts index bea5719dbf86ba..bcb870a235ec85 100644 --- a/src/vs/platform/agentHost/common/agentMerge.ts +++ b/src/vs/platform/agentHost/common/agentMerge.ts @@ -260,48 +260,48 @@ export interface AgentMergeDisableReason { export const agentMergeDisableReasons = { sessionArchived: (): AgentMergeDisableReason => ({ log: 'the session was archived', - notice: localize('agentMerge.disabled.sessionArchived', "Agent Merge was turned off because this session was archived."), + notice: localize('agentMerge.disabled.sessionArchived', "Agent Merge was disabled because this session was archived."), }), branchChanged: (from: string, to: string): AgentMergeDisableReason => ({ log: `branch changed from ${from} to ${to}`, notice: localize( 'agentMerge.disabled.branchChanged', - "Agent Merge was turned off because the checked-out branch changed from {0} to {1}.", + "Agent Merge was disabled because the checked-out branch changed from {0} to {1}.", appendEscapedMarkdownInlineCode(from), appendEscapedMarkdownInlineCode(to) ), }), branchChangedWhileRefreshing: (): AgentMergeDisableReason => ({ log: 'the checked-out branch changed while pull request state was refreshing', - notice: localize('agentMerge.disabled.branchChangedWhileRefreshing', "Agent Merge was turned off because the checked-out branch changed while its pull request state was refreshing."), + notice: localize('agentMerge.disabled.branchChangedWhileRefreshing', "Agent Merge was disabled because the checked-out branch changed while its pull request state was refreshing."), }), differentPullRequest: (): AgentMergeDisableReason => ({ log: 'the session became associated with a different pull request', - notice: localize('agentMerge.disabled.differentPullRequest', "Agent Merge was turned off because this session became associated with a different pull request."), + notice: localize('agentMerge.disabled.differentPullRequest', "Agent Merge was disabled because this session became associated with a different pull request."), }), invalidPullRequestUrl: (): AgentMergeDisableReason => ({ log: 'the associated pull request URL is invalid', - notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was turned off because the associated pull request URL is invalid."), + notice: localize('agentMerge.disabled.invalidPullRequestUrl', "Agent Merge was disabled because the associated pull request URL is invalid."), }), differentGitHubHost: (): AgentMergeDisableReason => ({ log: 'the bound pull request belongs to a different GitHub host than the signed-in account', - notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was turned off because its pull request belongs to a different GitHub host than the signed-in account."), + notice: localize('agentMerge.disabled.differentGitHubHost', "Agent Merge was disabled because its pull request belongs to a different GitHub host than the signed-in account."), }), indeterminate: (minutes: number, reason: string): AgentMergeDisableReason => ({ log: `the pull request state could not be evaluated for ${minutes} minutes: ${reason}`, - notice: localize('agentMerge.disabled.indeterminate', "Agent Merge was turned off because its pull request state could not be evaluated for {0} minutes.", minutes), + notice: localize('agentMerge.disabled.indeterminate', "Agent Merge was disabled because its pull request state could not be evaluated for {0} minutes.", minutes), }), pullRequestClosed: (): AgentMergeDisableReason => ({ log: 'the pull request is closed or merged', - notice: localize('agentMerge.disabled.pullRequestClosed', "Agent Merge was turned off because its pull request is closed or merged."), + notice: localize('agentMerge.disabled.pullRequestClosed', "Agent Merge was disabled because its pull request is closed or merged."), }), repairBudgetExhausted: (): AgentMergeDisableReason => ({ log: 'the same pull request blockers remained after repeated repair attempts', - notice: localize('agentMerge.disabled.repairBudgetExhausted', "Agent Merge was turned off because the same pull request blockers remained after repeated repair attempts."), + notice: localize('agentMerge.disabled.repairBudgetExhausted', "Agent Merge was disabled because the same pull request blockers remained after repeated repair attempts."), }), - pullRequestMerged: (): AgentMergeDisableReason => ({ + pullRequestMerged: (pullRequestNumber: number, pullRequestUrl: string): AgentMergeDisableReason => ({ log: 'the pull request was merged', - notice: localize('agentMerge.disabled.pullRequestMerged', "Agent Merge merged its pull request and turned itself off."), + notice: localize('agentMerge.pullRequestMerged', "Agent Merge merged pull request [#{0}]({1}).", pullRequestNumber, pullRequestUrl), }), } as const; @@ -309,8 +309,8 @@ export const agentMergeDisableReasons = { export function agentMergeEnabledNotice(target: Pick, configuration: AgentMergeConfiguration): string { const lines = [ target.pullRequestUrl - ? localize('agentMerge.notice.enabled.withPullRequest', "Agent Merge is on for {0} and is monitoring its pull request.", appendEscapedMarkdownInlineCode(target.branchName)) - : localize('agentMerge.notice.enabled', "Agent Merge is on for {0}. It will wait for a pull request on this branch, then monitor it.", appendEscapedMarkdownInlineCode(target.branchName)), + ? localize('agentMerge.notice.enabled.withPullRequest', "Agent Merge is enabled for {0} and is monitoring its pull request.", appendEscapedMarkdownInlineCode(target.branchName)) + : localize('agentMerge.notice.enabled', "Agent Merge is enabled for {0}. It will wait for a pull request on this branch, then monitor it.", appendEscapedMarkdownInlineCode(target.branchName)), ]; if (configuration.addressReviews) { lines.push(localize('agentMerge.notice.enabled.addressReviews', "It will ask the agent to address new pull request review comments.")); @@ -424,9 +424,9 @@ function agentMergeMergeBehaviorChangedNotice(mergePullRequest: AgentMergeMergeP } } -/** The transcript notice shown when the user, rather than the controller, turns Agent Merge off. */ +/** The transcript notice shown when the user, rather than the controller, disables Agent Merge. */ export function agentMergeDisabledNotice(): string { - return localize('agentMerge.notice.disabled', "Agent Merge was turned off for this session."); + return localize('agentMerge.notice.disabled', "Agent Merge was disabled for this session."); } /** @@ -434,7 +434,7 @@ export function agentMergeDisabledNotice(): string { * because its own repair work changed the pull request. */ export function agentMergeMergePullRequestDemotedNotice(): string { - return localize('agentMerge.notice.mergeDemoted', "Agent Merge changed this pull request, so automatic merging was turned off for this session. Review the changes, then turn it back on if you want it merged automatically."); + return localize('agentMerge.notice.mergeDemoted', "Agent Merge changed this pull request, so automatic merging was disabled for this session. Review the changes, then enable it again if you want it merged automatically."); } export function readAgentMergeSessionState(values: Record | undefined): AgentMergeSessionState | undefined { @@ -628,16 +628,7 @@ export function evaluateAgentMerge(snapshot: PullRequestSnapshot, configuration: return { kind: 'indeterminate', reason: checks.reason, cause: `checks:${checks.reason}` }; } - const reviewThreads = snapshot.reviewThreads.value! - .filter(thread => !thread.isResolved && thread.comments.some(comment => isAgentMergeFeedbackAuthor(comment.author))); - const latestReviews = latestReviewsByAuthor(snapshot.submittedReviews.value!); - const changesRequested = latestReviews.filter(review => review.state.toUpperCase() === 'CHANGES_REQUESTED' && isAgentMergeFeedbackAuthor(review.author)); - const watermark = Date.parse(commentWatermark); - const newComments = snapshot.topLevelComments.value!.filter(comment => - isAgentMergeFeedbackAuthor(comment.author) - && comment.createdAt !== undefined - && Date.parse(comment.createdAt) > watermark - ); + const { reviewThreads, changesRequested, newComments } = getAgentMergeFeedback(snapshot, commentWatermark); const mergeability = snapshot.mergeability.value!; const behind = mergeability.mergeStateStatus?.toUpperCase() === 'BEHIND'; const conflicting = mergeability.mergeable === 'CONFLICTING'; @@ -762,6 +753,49 @@ function latestReviewsByAuthor(reviews: PullRequestSnapshot['submittedReviews'][ return [...latest.values()]; } +function getAgentMergeFeedback(snapshot: PullRequestSnapshot, commentWatermark: string) { + const reviewThreads = snapshot.reviewThreads.value! + .filter(thread => !thread.isResolved && thread.comments.some(comment => isAgentMergeFeedbackAuthor(comment.author))); + const latestReviews = latestReviewsByAuthor(snapshot.submittedReviews.value!); + const changesRequested = latestReviews.filter(review => review.state.toUpperCase() === 'CHANGES_REQUESTED' && isAgentMergeFeedbackAuthor(review.author)); + const watermark = Date.parse(commentWatermark); + const newComments = snapshot.topLevelComments.value!.filter(comment => + isAgentMergeFeedbackAuthor(comment.author) + && comment.createdAt !== undefined + && Date.parse(comment.createdAt) > watermark + ); + return { reviewThreads, changesRequested, newComments }; +} + +/** + * Returns whether a draft pull request has no pending or failed required checks + * and no actionable review feedback, or `undefined` while that state is incomplete. + */ +export function isAgentMergePullRequestReadyForReview(snapshot: PullRequestSnapshot, commentWatermark: string): boolean | undefined { + const core = snapshot.core; + if (core.status !== 'ready' || !core.complete || !core.value || core.value.state !== 'open' || !core.value.draft) { + return undefined; + } + for (const fragment of conversationFragments) { + if (!isCompleteFragment(snapshot, fragment)) { + return undefined; + } + } + if (!isCompleteHeadFragment(snapshot, 'checks', core.value.headSha)) { + return undefined; + } + const checks = classifyAgentMergeRequiredChecks(snapshot.checks.value!); + if (checks.kind === 'indeterminate') { + return undefined; + } + const { reviewThreads, changesRequested, newComments } = getAgentMergeFeedback(snapshot, commentWatermark); + return checks.failed.length === 0 + && !checks.pending + && reviewThreads.length === 0 + && changesRequested.length === 0 + && newComments.length === 0; +} + export function classifyAgentMergeRequiredChecks(checks: PullRequestChecks): AgentMergeRequiredChecks { if (!checks.requirednessComplete) { return { kind: 'indeterminate', reason: 'Required check classification is incomplete' }; diff --git a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts index b3f6c1efde0261..daf53e0da977ec 100644 --- a/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts @@ -17,6 +17,8 @@ export const enum AgentSystemNotificationKind { AgentMergeConfigurationChanged = 'agentMergeConfigurationChanged', /** Agent Merge stopped monitoring the session, usually on its own. */ AgentMergeDisabled = 'agentMergeDisabled', + /** Agent Merge merged the pull request it was monitoring. */ + AgentMergePullRequestMerged = 'agentMergePullRequestMerged', } export const enum AgentSystemNotificationSeverity { @@ -31,6 +33,7 @@ const knownKinds: ReadonlySet = new Set([ AgentSystemNotificationKind.AgentMergeEnabled, AgentSystemNotificationKind.AgentMergeConfigurationChanged, AgentSystemNotificationKind.AgentMergeDisabled, + AgentSystemNotificationKind.AgentMergePullRequestMerged, ]); interface IHasSystemNotificationMeta { diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts index c1602c25d3b51d..189f3377fbe9b6 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts @@ -38,6 +38,7 @@ export type PullRequestLifecycleAction = 'mark-ready' | 'merge' | 'enable-auto-m export class AgentHostPullRequestLifecycleOperationHandler implements IChangesetOperationHandler { public static readonly OPERATION_MARK_READY = AgentHostPullRequestOperationId.MarkReady; + public static readonly OPERATION_MARK_READY_WITH_AGENT_MERGE = AgentHostPullRequestOperationId.MarkReadyWithAgentMerge; public static readonly OPERATION_MERGE = AgentHostPullRequestOperationId.Merge; public static readonly OPERATION_ENABLE_AUTO_MERGE = AgentHostPullRequestOperationId.EnableAutoMerge; public static readonly OPERATION_DISABLE_AUTO_MERGE = AgentHostPullRequestOperationId.DisableAutoMerge; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 7685d9fea8c38a..9540d7a79e4e63 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -72,6 +72,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation public static readonly OPERATION_CREATE_PR_AUTO_SQUASH = 'create-pr-auto-squash'; public static readonly OPERATION_CREATE_PR_AUTO_REBASE = 'create-pr-auto-rebase'; public static readonly OPERATION_CREATE_PR_AGENT_MERGE = 'create-pr-agent-merge'; + public static readonly OPERATION_CREATE_DRAFT_PR_AGENT_MERGE = 'create-draft-pr-agent-merge'; constructor( private readonly _draft: boolean, @@ -337,7 +338,9 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation if (this._enableAgentMerge) { return isExisting ? localize('agentHost.changeset.pr.existing.agentMerge', "Pull request [#{0}]({1}) already exists; enabled Agent Merge.", pr.number, pr.url) - : localize('agentHost.changeset.pr.created.agentMerge', "Created pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url); + : this._draft + ? localize('agentHost.changeset.pr.createdDraft.agentMerge', "Created draft pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url) + : localize('agentHost.changeset.pr.created.agentMerge', "Created pull request [#{0}]({1}) and enabled Agent Merge.", pr.number, pr.url); } let mergeMethodLabel: string | undefined; diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts index 8e4ee53a0880ff..20a2a86a7393e9 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -15,8 +15,9 @@ import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } fr import { AgentHostPullRequestLifecycleOperationHandler } from './agentHostPullRequestLifecycleOperationHandler.js'; import { IAgentHostPullRequestStatusService } from './agentHostPullRequestStatusService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; -import { AgentMergeConfigKey, agentMergeRootConfigSchema } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { ActionType } from '../common/state/sessionActions.js'; export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { @@ -49,15 +50,18 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem const createAutoSquashPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'SQUASH', false, getSessionState, resolveBaseBranchName, onCreated); const createAutoRebasePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, 'REBASE', false, getSessionState, resolveBaseBranchName, onCreated); const createAgentMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, undefined, true, getSessionState, resolveBaseBranchName, onCreated); + const createDraftAgentMergePrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, undefined, true, getSessionState, resolveBaseBranchName, onCreated); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_MERGE, createAutoMergePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_SQUASH, createAutoSquashPrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AUTO_REBASE, createAutoRebasePrHandler)); store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, createAgentMergePrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE, createDraftAgentMergePrHandler)); for (const [operationId, action] of [ [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, 'mark-ready'], + [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE, 'mark-ready'], [AgentHostPullRequestLifecycleOperationHandler.OPERATION_MERGE, 'merge'], [AgentHostPullRequestLifecycleOperationHandler.OPERATION_ENABLE_AUTO_MERGE, 'enable-auto-merge'], [AgentHostPullRequestLifecycleOperationHandler.OPERATION_DISABLE_AUTO_MERGE, 'disable-auto-merge'], @@ -66,6 +70,11 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem } store.add(this._pullRequestStatusService.onDidChangePullRequestStatus(sessionKey => registry.onDidChangeOperations(sessionKey))); + store.add(this._stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionConfigChanged) { + registry.onDidChangeOperations(envelope.channel); + } + })); let agentMergeEnabled = this._isAgentMergeEnabled(); store.add(this._configurationService.onDidRootConfigChange(() => { const nextAgentMergeEnabled = this._isAgentMergeEnabled(); @@ -112,6 +121,7 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem return undefined; } + const agentMergeEnabled = this._isAgentMergeEnabled(); return [{ id: 'create-pr', label: localize('agentHost.changeset.createPR', "Create PR"), @@ -144,9 +154,9 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle, }, - ...(this._isAgentMergeEnabled() ? [{ + ...(agentMergeEnabled ? [{ id: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR_AGENT_MERGE, - label: localize('agentHost.changeset.createPRAgentMerge', "Create PR & Enable Agent Merge"), + label: localize('agentHost.changeset.createPRAgentMerge', "Create PR & Agent Merge"), icon: 'git-merge', group: 'pull-request', scopes: [ChangesetOperationScope.Changeset], @@ -159,7 +169,16 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem group: 'pull-request_draft', scopes: [ChangesetOperationScope.Changeset], status: ChangesetOperationStatus.Idle, - }] satisfies ChangesetOperation[]; + }, + ...(agentMergeEnabled ? [{ + id: AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE, + label: localize('agentHost.changeset.createDraftPRAgentMerge', "Create Draft PR & Agent Merge"), + icon: 'git-merge', + group: 'pull-request_draft', + scopes: [ChangesetOperationScope.Changeset], + status: ChangesetOperationStatus.Idle, + }] : []), + ] satisfies ChangesetOperation[]; } private _isAgentMergeEnabled(): boolean { @@ -193,8 +212,12 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem const operations: ChangesetOperation[] = []; if (status.draft) { + const agentMergeRunning = this._isAgentMergeRunning(sessionKey); + const operationId = agentMergeRunning && status.agentMergeReadyForReview !== true + ? AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE + : AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY; operations.push({ - id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY, + id: operationId, label: localize('agentHost.changeset.markReady', "Mark Ready"), description: localize('agentHost.changeset.markReady.description', "Take the pull request out of draft so it can be reviewed and merged."), icon: 'git-pull-request', @@ -243,6 +266,11 @@ export class AgentHostPullRequestOperationContribution extends Disposable implem return operations; } + private _isAgentMergeRunning(sessionKey: string): boolean { + return this._isAgentMergeEnabled() + && readAgentMergeSessionState(this._stateManager.getSessionState(sessionKey)?.config?.values)?.enabled === true; + } + /** * Logs the advertised operations whenever the set changes for a session. * `getOperations` is recomputed on every git/GitHub state change and once diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts index e7a11c311b4c1a..b2f03010679d71 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts @@ -9,7 +9,7 @@ import { Disposable, DisposableStore, type IDisposable } from '../../../base/com import { autorun } from '../../../base/common/observable.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../github/common/githubPullRequestService.js'; +import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription, PullRequestSubscriptionOptions } from '../../github/common/githubPullRequestService.js'; import type { GitHubCredentialInvalidation } from '../../github/common/githubCredentialService.js'; import type { GitHubAccountHandle } from '../../github/common/githubTypes.js'; import { IGitHubService } from '../../github/common/githubService.js'; @@ -19,6 +19,7 @@ import { getSessionRelatedPullRequestUrls, hasSessionPullRequestForBranch, isSes import { ActionType } from '../common/state/sessionActions.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { parsePullRequestUrl } from './agentMergeController.js'; +import { isAgentMergePullRequestReadyForReview, readAgentMergeSessionState } from '../common/agentMerge.js'; /** * Merge states GitHub reports for a pull request that can still be merged @@ -42,6 +43,8 @@ export interface IAgentHostPullRequestStatus { readonly draft: boolean; /** True once the pull request is open and can be merged as-is. */ readonly mergeReady: boolean; + /** Whether Agent Merge has observed all required checks and review feedback as ready. */ + readonly agentMergeReadyForReview?: boolean; readonly viewerCanEnableAutoMerge: boolean; readonly autoMergeEnabled: boolean; readonly allowedMergeMethods: readonly ('MERGE' | 'SQUASH' | 'REBASE')[]; @@ -55,10 +58,10 @@ export const IAgentHostPullRequestStatusService = createDecorator this._sync(session))); this._register(this._stateManager.onDidRemoveSession(session => this._stopWatch(session))); this._register(this._stateManager.onDidEmitEnvelope(envelope => { - if (envelope.action.type === ActionType.SessionIsArchivedChanged) { + if (envelope.action.type === ActionType.SessionIsArchivedChanged || envelope.action.type === ActionType.SessionConfigChanged) { this._sync(envelope.channel); } })); @@ -259,6 +262,8 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg const existing = this._watches.get(sessionKey); if (existing && sameRefAndHost(existing.ref, parsed)) { + existing.subscription.update(this._getSubscriptionOptions(sessionKey)); + this._updateStatus(sessionKey, existing, existing.subscription.resource.snapshot.get()); return; } @@ -288,11 +293,7 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg this._stopWatch(sessionKey, `replaced by ${describeRef(ref)}`); const store = new DisposableStore(); - const subscription = store.add(this._gitHubService.pullRequests.subscribePullRequest(ref, { - priority: 'visible', - core: true, - mergeability: true, - })); + const subscription = store.add(this._gitHubService.pullRequests.subscribePullRequest(ref, this._getSubscriptionOptions(sessionKey))); const watch: IWatch = { ref, subscription, @@ -313,6 +314,24 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg this._logService.debug(`[AgentHostPullRequestStatusService] Watching pull request: session=${sessionKey}, pr=${describeRef(ref)}`); } + private _getSubscriptionOptions(sessionKey: string): PullRequestSubscriptionOptions { + const agentMergeEnabled = readAgentMergeSessionState(this._stateManager.getSessionState(sessionKey)?.config?.values)?.enabled === true; + return { + priority: 'visible', + core: true, + mergeability: true, + ...(agentMergeEnabled ? { + conversation: { + topLevelComments: true, + submittedReviews: true, + reviewThreads: true, + includeBodies: true, + }, + checks: { required: true }, + } : {}), + }; + } + private _hasPersistedMergedState(sessionKey: string, ref: PullRequestRef): boolean { const gitHubState = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta); const persistedPullRequest = gitHubState?.pullRequestStateUrl ? parsePullRequestUrl(gitHubState.pullRequestStateUrl) : undefined; @@ -371,7 +390,8 @@ export class AgentHostPullRequestStatusService extends Disposable implements IAg if (snapshot.core.status !== 'ready' && (watch.status?.state === 'merged' || persistedMergedStateApplies)) { return; } - this._setStatus(sessionKey, watch, toPullRequestStatus(snapshot)); + const agentMerge = readAgentMergeSessionState(this._stateManager.getSessionState(sessionKey)?.config?.values); + this._setStatus(sessionKey, watch, toPullRequestStatus(snapshot, agentMerge?.enabled ? agentMerge.target?.commentWatermark : undefined)); } private _setStatus(sessionKey: string, watch: IWatch, status: IAgentHostPullRequestStatus | undefined): void { @@ -429,6 +449,7 @@ function describeStatus(status: IAgentHostPullRequestStatus | undefined): string `state=${status.state}`, `draft=${status.draft}`, `mergeReady=${status.mergeReady}`, + `agentMergeReadyForReview=${status.agentMergeReadyForReview ?? 'unknown'}`, `autoMergeEnabled=${status.autoMergeEnabled}`, `canEnableAutoMerge=${status.viewerCanEnableAutoMerge}`, `allowedMergeMethods=${status.allowedMergeMethods.join('|') || 'none'}`, @@ -440,7 +461,7 @@ function describeStatus(status: IAgentHostPullRequestStatus | undefined): string * while either fragment the button bar depends on is still unresolved. Holding * back on partial data keeps the client from flashing a wrong primary button. */ -function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullRequestStatus | undefined { +function toPullRequestStatus(snapshot: PullRequestSnapshot, agentMergeCommentWatermark?: string): IAgentHostPullRequestStatus | undefined { const core = snapshot.core.value; if (!core) { return undefined; @@ -467,6 +488,9 @@ function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullReque if (!mergeability || snapshot.mergeability.headSha !== core.headSha) { return undefined; } + const agentMergeReadyForReview = agentMergeCommentWatermark !== undefined + ? isAgentMergePullRequestReadyForReview(snapshot, agentMergeCommentWatermark) + : undefined; return { ...(core.id ? { pullRequestId: core.id } : {}), @@ -479,6 +503,7 @@ function toPullRequestStatus(snapshot: PullRequestSnapshot): IAgentHostPullReque && mergeability.mergeable === 'MERGEABLE' && mergeability.viewerCanMerge && MERGEABLE_STATES.has(mergeability.mergeStateStatus?.toUpperCase() ?? 'CLEAN'), + ...(agentMergeReadyForReview !== undefined ? { agentMergeReadyForReview } : {}), viewerCanEnableAutoMerge: mergeability.viewerCanEnableAutoMerge, autoMergeEnabled: mergeability.autoMergeEnabled, allowedMergeMethods: mergeability.allowedMergeMethods, diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 3545e22b34316b..8021c5efc0cacb 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -420,7 +420,11 @@ export class AgentMergeController extends Disposable { let target = agentMerge.target; if (!target) { const now = new Date().toISOString(); - target = { branchName, enabledAt: now, commentWatermark: now }; + const currentGitHubState = readSessionGitHubState(this._stateManager.getSessionState(session)?._meta); + const pullRequestUrl = currentGitHubState?.pullRequestBranchName === branchName + ? getSessionRelatedPullRequestUrls(currentGitHubState)[0] + : undefined; + target = { branchName, enabledAt: now, commentWatermark: now, ...(pullRequestUrl ? { pullRequestUrl } : {}) }; this._logService.info(`[AgentMergeController] Captured session branch and feedback watermark: session=${session}`); // Announce only on the first capture: a resumed session already has a // target, so restarting the host must not repeat the notice. @@ -811,7 +815,13 @@ export class AgentMergeController extends Disposable { } const result = await this._gitHubService.mutations.merge(preparation, { method, authorization }, runtime.abortController.signal); this._logService.info(`[AgentMergeController] Pull request merged natively: session=${session}, method=${method}, outcome=${result.outcome}`); - this._disable(session, currentState, agentMergeDisableReasons.pullRequestMerged()); + const mergedPullRequest = preparation.snapshot.core.value!; + this._disable( + session, + currentState, + agentMergeDisableReasons.pullRequestMerged(mergedPullRequest.number, mergedPullRequest.url), + AgentSystemNotificationKind.AgentMergePullRequestMerged, + ); } private async _completeTurn(session: string): Promise { @@ -946,14 +956,14 @@ export class AgentMergeController extends Disposable { }); } - private _disable(session: string, current: AgentMergeSessionState, reason: AgentMergeDisableReason): void { + private _disable(session: string, current: AgentMergeSessionState, reason: AgentMergeDisableReason, notificationKind = AgentSystemNotificationKind.AgentMergeDisabled): void { this._logService.info(`[AgentMergeController] Disabling Agent Merge for ${session}: ${reason.log}`); this._activeTurns.delete(session); // Claim the transition before the config write re-enters `_doSyncSession`, // so the reasoned notice below is the only one the user sees. this._monitoredSessions.delete(session); this._announcedConfigurations.delete(session); - this._postNotice(session, AgentSystemNotificationKind.AgentMergeDisabled, reason.notice); + this._postNotice(session, notificationKind, reason.notice); const patch: Record = { [SessionConfigKey.AgentMerge]: { enabled: false, diff --git a/src/vs/platform/agentHost/test/common/agentMerge.test.ts b/src/vs/platform/agentHost/test/common/agentMerge.test.ts index dece3d12ed626c..50dbc8854e713e 100644 --- a/src/vs/platform/agentHost/test/common/agentMerge.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMerge.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentMergeConfiguration, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeConfigurationChangedNotice, agentMergeEnabledNotice, evaluateAgentMerge, getNonMergeSessionConfigValues, readAgentMergeSessionState, shouldStopMergingAfterAgentChanges } from '../../common/agentMerge.js'; +import { AgentMergeConfiguration, AGENT_MERGE_UNKNOWN_COMMIT, agentMergeConfigurationChangedNotice, agentMergeDisableReasons, agentMergeEnabledNotice, evaluateAgentMerge, getNonMergeSessionConfigValues, isAgentMergePullRequestReadyForReview, readAgentMergeSessionState, shouldStopMergingAfterAgentChanges } from '../../common/agentMerge.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; @@ -75,6 +75,62 @@ suite('Agent Merge gate', () => { }); }); + test('repairs draft pull requests without merging them', () => { + const repair = evaluateAgentMerge(readySnapshot({ + draft: true, + reviewThreads: [{ + id: 'thread-1', + isResolved: false, + comments: [{ id: 'comment-1', author: { login: 'maintainer', association: 'MEMBER' }, body: 'Please fix this' }], + }], + checks: [{ id: 'required', type: 'checkRun', name: 'Build', required: true, status: 'COMPLETED', conclusion: 'FAILURE' }], + }), configuration, '2026-08-02T00:00:00.000Z'); + const ready = evaluateAgentMerge(readySnapshot({ draft: true }), configuration, '2026-08-02T00:00:00.000Z'); + + assert.deepStrictEqual({ + repair: repair.kind === 'prompt' ? repair.actions : repair.kind, + ready: ready.kind, + }, { + repair: ['addressReviews', 'fixCI'], + ready: 'noWork', + }); + }); + + test('reports when required checks and review feedback are ready', () => { + const watermark = '2026-08-02T00:00:00.000Z'; + assert.deepStrictEqual({ + ready: isAgentMergePullRequestReadyForReview(readySnapshot({ draft: true }), watermark), + notDraft: isAgentMergePullRequestReadyForReview(readySnapshot(), watermark), + pendingChecks: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + checks: [{ id: 'required', type: 'checkRun', name: 'Build', required: true, status: 'IN_PROGRESS' }], + }), watermark), + failingChecks: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + checks: [{ id: 'required', type: 'checkRun', name: 'Build', required: true, status: 'COMPLETED', conclusion: 'FAILURE' }], + }), watermark), + reviewComments: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + reviewThreads: [{ + id: 'thread-1', + isResolved: false, + comments: [{ id: 'comment-1', author: { login: 'maintainer', association: 'MEMBER' }, body: 'Please fix this' }], + }], + }), watermark), + newComments: isAgentMergePullRequestReadyForReview(readySnapshot({ + draft: true, + topLevelComments: [{ id: 'comment-1', author: { login: 'maintainer', association: 'MEMBER' }, body: 'Please fix this', createdAt: '2026-08-03T00:00:00.000Z' }], + }), watermark), + }, { + ready: true, + notDraft: undefined, + pendingChecks: false, + failingChecks: false, + reviewComments: false, + newComments: false, + }); + }); + test('merges only from complete ready state', () => { assert.deepStrictEqual(evaluateAgentMerge(readySnapshot(), configuration, '2026-08-02T00:00:00.000Z').kind, 'merge'); }); @@ -215,7 +271,7 @@ suite('Agent Merge gate', () => { ...configuration, mergePullRequest: 'never', }), [ - 'Agent Merge is on for `feature`. It will wait for a pull request on this branch, then monitor it.', + 'Agent Merge is enabled for `feature`. It will wait for a pull request on this branch, then monitor it.', 'It will ask the agent to address new pull request review comments.', 'It will ask the agent to fix failing CI checks.', 'It will ask the agent to resolve merge conflicts and update the branch when it falls behind.', @@ -225,6 +281,13 @@ suite('Agent Merge gate', () => { ].map((line, index) => index === 0 ? `${line}\n` : `- ${line}`).join('\n')); }); + test('reports when Agent Merge merges a pull request', () => { + assert.strictEqual( + agentMergeDisableReasons.pullRequestMerged(123, 'https://github.com/octo/repo/pull/123').notice, + 'Agent Merge merged pull request [#123](https://github.com/octo/repo/pull/123).', + ); + }); + test('describes effective Agent Merge configuration changes', () => { const previous: AgentMergeConfiguration = { ...configuration, @@ -262,7 +325,7 @@ suite('Agent Merge gate', () => { mergePullRequest: 'always', mergeMethod: 'squash', }), [ - 'Agent Merge is on for `feature` and is monitoring its pull request.', + 'Agent Merge is enabled for `feature` and is monitoring its pull request.', 'It will ask the agent to fix failing CI checks.', 'It will ask the agent to resolve merge conflicts and update the branch when it falls behind.', 'After each update, it will wait for new CI results.', @@ -372,6 +435,7 @@ suite('Agent Merge gate', () => { }); function readySnapshot(overrides?: { + readonly draft?: boolean; readonly topLevelComments?: NonNullable; readonly submittedReviews?: NonNullable; readonly reviewThreads?: NonNullable; @@ -390,7 +454,7 @@ function readySnapshot(overrides?: { title: 'Change', url: 'https://github.com/octo/repo/pull/1', state: 'open', - draft: false, + draft: overrides?.draft ?? false, headSha: 'head', headRef: 'feature', baseSha: 'base', diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 0441df9a391c1f..6f800c856eefd7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -362,6 +362,35 @@ suite('AgentHostPullRequestOperationHandler', () => { }); }); + test('creates a draft pull request and enables Agent Merge', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const { handler, session, sessionConfigUpdates } = setup(disposables, gitService, octoKitService, { + draft: true, + enableAgentMerge: true, + }); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR_AGENT_MERGE }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + octoCalls: octoKitService.calls, + sessionConfigUpdates, + }, { + message: { markdown: 'Created draft pull request [#123](https://github.com/microsoft/vscode/pull/123) and enabled Agent Merge.' }, + octoCalls: [ + 'findPullRequestByHeadBranch:feature/test', + 'createPullRequest:true', + ], + sessionConfigUpdates: [{ + [SessionConfigKey.AgentMerge]: { + enabled: true, + }, + [SessionConfigKey.AgentMergeController]: {}, + }], + }); + }); + test('creates a generated branch before committing when the current branch is the base branch', async () => { const gitService = new TestGitService(); gitService.uncommitted = true; diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index d25bea09e54de8..83d5f6ad19b4b2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -10,6 +10,7 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; +import { AgentHostPullRequestLifecycleOperationHandler } from '../../node/agentHostPullRequestLifecycleOperationHandler.js'; import type { IAgentHostPullRequestStatus, IAgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; import { SessionStatus, type ISessionGitHubState, type ISessionGitState } from '../../common/state/sessionState.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; @@ -73,10 +74,10 @@ const pullRequestForBranch: ISessionGitHubState = { suite('AgentHostPullRequestOperationContribution', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None, agentMergeEnabled = false): AgentHostPullRequestOperationContribution { + function createContribution(status?: IAgentHostPullRequestStatus, isolation?: 'folder' | 'worktree', onDidChangePullRequestStatus = Event.None, agentMergeEnabled = false, sessionAgentMergeEnabled = false): AgentHostPullRequestOperationContribution { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); - if (isolation) { - stateManager.createSession({ + if (isolation || sessionAgentMergeEnabled) { + const session = { resource: 'agent:/session', provider: 'copilot', title: 'Session', @@ -84,10 +85,18 @@ suite('AgentHostPullRequestOperationContribution', () => { createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), workingDirectories: ['file:///repo'], - }); + }; + if (sessionAgentMergeEnabled) { + stateManager.restoreSession(session, []); + } else { + stateManager.createSession(session); + } stateManager.setSessionConfig('agent:/session', { schema: { type: 'object', properties: {} }, - values: { [SessionConfigKey.Isolation]: isolation }, + values: { + ...(isolation ? { [SessionConfigKey.Isolation]: isolation } : {}), + ...(sessionAgentMergeEnabled ? { [SessionConfigKey.AgentMerge]: { enabled: true } } : {}), + }, }); } const configurationService = new class extends mock() { @@ -113,12 +122,20 @@ suite('AgentHostPullRequestOperationContribution', () => { assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-draft-pr']); }); - test('advertises Create PR and Enable Agent Merge as the last Create PR option when Agent Merge is enabled', () => { + test('advertises Agent Merge variants as the last ready and draft Create PR options when Agent Merge is enabled', () => { const provider = createContribution(undefined, undefined, Event.None, true); const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); - assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-pr-auto-merge', 'create-pr-auto-squash', 'create-pr-auto-rebase', 'create-pr-agent-merge', 'create-draft-pr']); + assert.deepStrictEqual(operations?.map(({ id, label }) => ({ id, label })), [ + { id: 'create-pr', label: 'Create PR' }, + { id: 'create-pr-auto-merge', label: 'Create PR (Auto-Merge)' }, + { id: 'create-pr-auto-squash', label: 'Create PR (Auto-Squash)' }, + { id: 'create-pr-auto-rebase', label: 'Create PR (Auto-Rebase)' }, + { id: 'create-pr-agent-merge', label: 'Create PR & Agent Merge' }, + { id: 'create-draft-pr', label: 'Create Draft PR' }, + { id: 'create-draft-pr-agent-merge', label: 'Create Draft PR & Agent Merge' }, + ]); }); test('does not advertise PR operations for folder sessions with outgoing changes', () => { @@ -158,7 +175,7 @@ suite('AgentHostPullRequestOperationContribution', () => { }); test('advertises lifecycle operations for a pull request on the current branch', () => { - const operationsFor = (status?: IAgentHostPullRequestStatus) => createContribution(status) + const operationsFor = (status?: IAgentHostPullRequestStatus, agentMergeEnabled = false) => createContribution(status, undefined, Event.None, agentMergeEnabled, agentMergeEnabled) .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }) ?.map(op => op.id); @@ -166,6 +183,9 @@ suite('AgentHostPullRequestOperationContribution', () => { unresolved: operationsFor(undefined), merged: operationsFor(openPullRequest({ state: 'merged' })), draft: operationsFor(openPullRequest({ draft: true, viewerCanEnableAutoMerge: true })), + agentMergeDraftWaiting: operationsFor(openPullRequest({ draft: true, agentMergeReadyForReview: false }), true), + agentMergeDraftUnknown: operationsFor(openPullRequest({ draft: true }), true), + agentMergeDraftReady: operationsFor(openPullRequest({ draft: true, agentMergeReadyForReview: true }), true), mergeable: operationsFor(openPullRequest({ mergeReady: true })), blocked: operationsFor(openPullRequest({ viewerCanEnableAutoMerge: true })), autoMerging: operationsFor(openPullRequest({ autoMergeEnabled: true })), @@ -174,6 +194,9 @@ suite('AgentHostPullRequestOperationContribution', () => { unresolved: undefined, merged: undefined, draft: ['pr-mark-ready', 'pr-enable-auto-merge'], + agentMergeDraftWaiting: ['pr-mark-ready-with-agent-merge'], + agentMergeDraftUnknown: ['pr-mark-ready-with-agent-merge'], + agentMergeDraftReady: ['pr-mark-ready'], mergeable: ['pr-merge'], blocked: ['pr-enable-auto-merge'], autoMerging: ['pr-disable-auto-merge'], @@ -181,4 +204,14 @@ suite('AgentHostPullRequestOperationContribution', () => { }); }); + test('uses the same label for the Agent Merge Mark Ready operation', () => { + const operations = createContribution(openPullRequest({ draft: true, agentMergeReadyForReview: false }), undefined, Event.None, true, true) + .getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, gitHubState: pullRequestForBranch, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual(operations?.map(({ id, label }) => ({ id, label })), [{ + id: AgentHostPullRequestLifecycleOperationHandler.OPERATION_MARK_READY_WITH_AGENT_MERGE, + label: 'Mark Ready', + }]); + }); + }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts index 6d794cc64d4b81..587019287468e8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestStatusService.test.ts @@ -10,13 +10,15 @@ import { observableValue } from '../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import type { GitHubCredential, GitHubCredentialInvalidation, IGitHubCredentials } from '../../../github/common/githubCredentialService.js'; -import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription } from '../../../github/common/githubPullRequestService.js'; +import type { PullRequestRef, PullRequestSnapshot, PullRequestSubscription, PullRequestSubscriptionOptions } from '../../../github/common/githubPullRequestService.js'; import type { IGitHubService } from '../../../github/common/githubService.js'; import type { IPullRequestResources } from '../../../github/common/pullRequestResourceService.js'; import { mock } from '../../../../base/test/common/mock.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { readSessionGitHubState, SessionStatus, withSessionGitHubState, withSessionGitState, type ISessionGitHubState, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { ActionType } from '../../common/state/sessionActions.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostPullRequestStatusService } from '../../node/agentHostPullRequestStatusService.js'; @@ -77,10 +79,33 @@ function snapshot(ref: PullRequestRef, overrides?: { readonly draft?: boolean; r } as PullRequestSnapshot; } +function agentMergeReadySnapshot(ref: PullRequestRef): PullRequestSnapshot { + const result = snapshot(ref, { draft: true }); + const ready = { status: 'ready', complete: true } as const; + return { + ...result, + topLevelComments: { ...ready, value: [] }, + submittedReviews: { ...ready, value: [] }, + reviewThreads: { ...ready, headSha: 'sha1', value: [] }, + checks: { + ...ready, + headSha: 'sha1', + value: { + headSha: 'sha1', + requirednessComplete: true, + expectedSuites: [], + expectedSuitesComplete: true, + checks: [], + }, + }, + }; +} + /** Records subscription lifecycle so tests can assert nothing is leaked. */ class TestPullRequestResources implements IPullRequestResources { readonly subscribed: PullRequestRef[] = []; + readonly subscriptionOptions: PullRequestSubscriptionOptions[] = []; disposedCount = 0; private _snapshot = observableValue('snapshot', undefined); private _nextSubscriptionSnapshot: PullRequestSnapshot | undefined; @@ -88,13 +113,14 @@ class TestPullRequestResources implements IPullRequestResources { get liveSubscriptions(): number { return this.subscribed.length - this.disposedCount; } - subscribePullRequest(ref: PullRequestRef): PullRequestSubscription { + subscribePullRequest(ref: PullRequestRef, options: PullRequestSubscriptionOptions): PullRequestSubscription { this.subscribed.push(ref); + this.subscriptionOptions.push(options); this._snapshot.set(this._nextSubscriptionSnapshot ?? snapshot(ref), undefined); this._nextSubscriptionSnapshot = undefined; return { resource: { ref, snapshot: this._snapshot as never }, - update: () => { }, + update: next => this.subscriptionOptions.push(next), refresh: async () => this.refreshHandler?.(), dispose: () => { this.disposedCount++; }, } as PullRequestSubscription; @@ -251,6 +277,70 @@ suite('AgentHostPullRequestStatusService', () => { }); }); + test('tracks review readiness in the host only while Agent Merge is enabled', async () => { + const { service, stateManager, subscriptions, resources, session } = createHarness(); + stateManager.setSessionConfig(session, { + schema: { type: 'object', properties: {} }, + values: { + [SessionConfigKey.AgentMerge]: { enabled: true }, + [SessionConfigKey.AgentMergeController]: { + target: { + branchName: 'feature', + pullRequestUrl, + enabledAt: new Date(1).toISOString(), + commentWatermark: '', + }, + }, + }, + }); + resources.setNextSubscriptionSnapshot(agentMergeReadySnapshot({ ...account, owner: 'octo', repo: 'repo', number: 7 })); + subscriptions.addSubscription(session, `${session}/changes`); + await waitForWatch(resources); + const enabled = { + options: resources.subscriptionOptions.at(-1), + readyForReview: service.getPullRequestStatus(session)?.agentMergeReadyForReview, + }; + + stateManager.dispatchServerAction(session, { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.AgentMerge]: { enabled: false } }, + replace: true, + }); + await pump(); + + assert.deepStrictEqual({ + enabled, + disabled: { + options: resources.subscriptionOptions.at(-1), + readyForReview: service.getPullRequestStatus(session)?.agentMergeReadyForReview, + }, + }, { + enabled: { + options: { + priority: 'visible', + core: true, + mergeability: true, + conversation: { + topLevelComments: true, + submittedReviews: true, + reviewThreads: true, + includeBodies: true, + }, + checks: { required: true }, + }, + readyForReview: true, + }, + disabled: { + options: { + priority: 'visible', + core: true, + mergeability: true, + }, + readyForReview: undefined, + }, + }); + }); + test('optimistically records a successful merge in host pull request state', async () => { const { service, subscriptions, resources, gitHubStates, session } = createHarness(); subscriptions.addSubscription(session, `${session}/changes`); diff --git a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts index 215e77e6d4dbf1..02c2bffdef5d75 100644 --- a/src/vs/platform/agentHost/test/node/agentMergeController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentMergeController.test.ts @@ -17,7 +17,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSystemNotificationKind } from '../../common/meta/agentSystemNotificationMeta.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; +import { SessionStatus, buildDefaultChatUri, MessageKind, withSessionGitHubState, withSessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { IGitHubService } from '../../../github/common/githubService.js'; import { PullRequestSnapshot } from '../../../github/common/githubPullRequestService.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -435,6 +435,7 @@ suite('AgentMergeController', () => { const gitStateService = new class extends mock() { override readonly onDidRefreshSessionGitState = Event.None; override readonly onDidChangeSessionGitHubState = Event.None; + override async attachSessionGitHubPullRequest(): Promise { } }(); const endpointService = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); const notices: { kind: AgentSystemNotificationKind; content: string }[] = []; @@ -500,7 +501,13 @@ suite('AgentMergeController', () => { schema: platformSessionSchema.toProtocol(), values: {}, }); - stateManager.setSessionMeta(session, withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' })); + stateManager.setSessionMeta(session, withSessionGitHubState( + withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' }), + { + pullRequestUrls: ['https://github.com/octo/repo/pull/1'], + pullRequestBranchName: 'other', + }, + )); const captured = new Promise(resolve => { disposables.add(stateManager.onDidChangeSessionConfig(event => { if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { @@ -533,12 +540,57 @@ suite('AgentMergeController', () => { kind: AgentSystemNotificationKind.AgentMergeEnabled, content: agentMergeEnabledNotice({ branchName: 'feature' }, defaultAgentMergeConfiguration), }, - { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because the checked-out branch changed from `feature` to `main`.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled because the checked-out branch changed from `feature` to `main`.' }, ], enabled: false, }); }); + test('announces a known pull request when it captures the Agent Merge target', async () => { + const { stateManager, configurationService, session, notices } = createControllerHarness(disposables); + const pullRequestUrl = 'https://github.com/octo/repo/pull/1'; + stateManager.setSessionMeta(session, withSessionGitHubState( + withSessionGitState(undefined, { branchName: 'feature', baseBranchName: 'main' }), + { + pullRequestUrls: [pullRequestUrl], + pullRequestBranchName: 'feature', + }, + )); + const captured = new Promise(resolve => { + disposables.add(stateManager.onDidChangeSessionConfig(event => { + if (event.session.toString() === session && readAgentMergeSessionState(event.current?.values)?.target) { + resolve(); + } + })); + }); + + configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: true } }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + await captured; + const target = readAgentMergeSessionState(configurationService.getSessionConfigValues(session))?.target; + + assert.deepStrictEqual({ + target: target ? { + branchName: target.branchName, + pullRequestUrl: target.pullRequestUrl, + hasEnabledAt: target.enabledAt.length > 0, + watermarkMatchesEnablement: target.commentWatermark === target.enabledAt, + } : undefined, + notices, + }, { + target: { + branchName: 'feature', + pullRequestUrl, + hasEnabledAt: true, + watermarkMatchesEnablement: true, + }, + notices: [{ + kind: AgentSystemNotificationKind.AgentMergeEnabled, + content: agentMergeEnabledNotice({ branchName: 'feature', pullRequestUrl }, defaultAgentMergeConfiguration), + }], + }); + }); + test('announces effective session and global configuration changes while monitoring', () => { const { stateManager, configurationService, session, notices } = createControllerHarness(disposables); const target = { @@ -622,10 +674,10 @@ suite('AgentMergeController', () => { configurationService.updateSessionConfig(session, { [SessionConfigKey.AgentMerge]: { enabled: false } }); assert.deepStrictEqual({ afterSelfDisable, notices }, { - afterSelfDisable: [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because this session was archived.' }], + afterSelfDisable: [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled because this session was archived.' }], notices: [ - { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off because this session was archived.' }, - { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was turned off for this session.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled because this session was archived.' }, + { kind: AgentSystemNotificationKind.AgentMergeDisabled, content: 'Agent Merge was disabled for this session.' }, ], }); }); @@ -657,7 +709,7 @@ suite('AgentMergeController', () => { assert.deepStrictEqual(notices, [{ kind: AgentSystemNotificationKind.AgentMergeDisabled, - content: 'Agent Merge was turned off for this session.', + content: 'Agent Merge was disabled for this session.', }, { kind: AgentSystemNotificationKind.AgentMergeEnabled, content: agentMergeEnabledNotice({ branchName: 'feature' }, defaultAgentMergeConfiguration), diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5c920384c1963c..57b83d6f59e4ea 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -15128,7 +15128,7 @@ suite('AgentService (node dispatcher)', () => { state: TurnState.Complete, responseParts: [{ kind: ResponsePartKind.SystemNotification, - content: 'Agent Merge was turned off for this session.', + content: 'Agent Merge was disabled for this session.', _meta: { kind: 'agentMergeDisabled' }, }], sentToAgent: 0, @@ -15211,7 +15211,7 @@ suite('AgentService (node dispatcher)', () => { afterTurn: { responseParts: [{ kind: ResponsePartKind.SystemNotification, - content: 'Agent Merge was turned off for this session.', + content: 'Agent Merge was disabled for this session.', _meta: { kind: 'agentMergeDisabled' }, }], anchoredTo: ['agent-turn'], diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts index c59a143d263cbc..6e2d6378122d81 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentMergeActions.ts @@ -55,9 +55,10 @@ const agentMergeHasPullRequest = ContextKeyExpr.or( const agentMergeMenuPrecondition = ContextKeyExpr.and(agentMergeCommandPrecondition, agentMergeHasPullRequest); /** - * Agent Merge owns the primary button only when neither marking the pull - * request ready nor merging it applies — the states the user is otherwise left - * waiting in, including a blocked pull request that offers no operation at all. + * Agent Merge owns the primary button while an enabled draft is still waiting + * for CI or review comments. The host advertises a distinct Mark Ready + * operation in that state so it remains available in the dropdown; once the + * pull request is ready, the normal Mark Ready operation takes over. * * The auto-merge states are included because Agent Merge replaces them on the * button: it subsumes "let this merge on its own once it is ready", and the @@ -68,6 +69,10 @@ const agentMergeMenuPrecondition = ContextKeyExpr.and(agentMergeCommandPrecondit const agentMergeOwnsPrimaryButton = ContextKeyExpr.or( ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, AgentHostPullRequestOperationId.EnableAutoMerge), ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, AgentHostPullRequestOperationId.DisableAutoMerge), + ContextKeyExpr.and( + ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, AgentHostPullRequestOperationId.MarkReadyWithAgentMerge), + SessionAgentMergeEnabledContext, + ), ContextKeyExpr.and(SessionHasOpenPullRequestContext, ContextKeyExpr.equals(SessionPrimaryPullRequestOperationContext.key, '')), ); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts index 121cf5307e9e7a..82c2956d4881d2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentMergeActions.test.ts @@ -59,6 +59,18 @@ suite('Agent Merge Actions', () => { }); }); + test('Agent Merge owns a draft pull request until CI and review comments are ready', () => { + assert.deepStrictEqual({ + agentMergeMarkReady: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.MarkReadyWithAgentMerge, agentMergeEnabled: true }), + staleAgentMergeMarkReady: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.MarkReadyWithAgentMerge, agentMergeEnabled: false }), + normalMarkReady: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.MarkReady, agentMergeEnabled: true }), + }, { + agentMergeMarkReady: true, + staleAgentMergeMarkReady: false, + normalMarkReady: false, + }); + }); + test('Agent Merge leaves the primary button to the operation that moves the pull request along', () => { assert.deepStrictEqual({ markReady: ownsPrimaryButton({ primaryOperation: AgentHostPullRequestOperationId.MarkReady, agentMergeEnabled: true }), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 3b6200e65fdd0e..44d02bf689e634 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -490,14 +490,16 @@ export function systemNotificationToChatPart(content: StringOrMarkdown | undefin return { kind: 'systemNotification', content: markdown, icon: Codicon.circleSlash, collapsible: true }; case AgentSystemNotificationKind.AutomaticApprovalReviewInterrupted: return { kind: 'systemNotification', content: markdown, icon: Codicon.warning }; - // Agent Merge reports a state change rather than a completed step, so the - // default check would misdescribe both of these. + // Agent Merge state changes use icons that describe the transition rather + // than the default completed-step check. case AgentSystemNotificationKind.AgentMergeEnabled: - return { kind: 'systemNotification', content: markdown, icon: Codicon.gitMerge, collapsible: true }; + return { kind: 'systemNotification', content: markdown, icon: Codicon.gitMerge, collapsible: true, renderInlineTiming: true }; case AgentSystemNotificationKind.AgentMergeConfigurationChanged: - return { kind: 'systemNotification', content: markdown, icon: Codicon.settingsGear, collapsible: true }; + return { kind: 'systemNotification', content: markdown, icon: Codicon.settingsGear, collapsible: true, renderInlineTiming: true }; case AgentSystemNotificationKind.AgentMergeDisabled: - return { kind: 'systemNotification', content: markdown, icon: Codicon.circleSlash }; + return { kind: 'systemNotification', content: markdown, icon: Codicon.circleSlash, renderInlineTiming: true }; + case AgentSystemNotificationKind.AgentMergePullRequestMerged: + return { kind: 'systemNotification', content: markdown, icon: Codicon.gitMerge, renderInlineTiming: true }; default: return { kind: 'systemNotification', content: markdown }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts index 7ed591689cded6..fec0c170d5f2c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts @@ -35,6 +35,7 @@ const transparentButtonStyles: IButtonStyles = { export class ChatSystemNotificationContentPart extends Disposable implements IChatContentPart { readonly domNode: HTMLElement; + readonly inlineTimingContainer: HTMLElement | undefined; constructor( private readonly notification: IChatSystemNotificationPart, @@ -43,16 +44,32 @@ export class ChatSystemNotificationContentPart extends Disposable implements ICh ) { super(); + let notificationNode: HTMLElement; if (notification.collapsible) { const firstLineBreak = notification.content.value.indexOf('\n'); const detailsValue = firstLineBreak === -1 ? '' : notification.content.value.slice(firstLineBreak).trim(); if (detailsValue) { - this.domNode = this._renderCollapsibleNotification(notification, renderer, firstLineBreak, detailsValue); - return; + notificationNode = this._renderCollapsibleNotification(notification, renderer, firstLineBreak, detailsValue); + } else { + notificationNode = this._renderNotification(notification, renderer, instantiationService); } + } else { + notificationNode = this._renderNotification(notification, renderer, instantiationService); } + + if (notification.renderInlineTiming) { + this.domNode = dom.$('.chat-system-notification-layout'); + this.domNode.appendChild(notificationNode); + this.inlineTimingContainer = dom.append(this.domNode, dom.$('span.chat-system-notification-timing')); + } else { + this.domNode = notificationNode; + this.inlineTimingContainer = undefined; + } + } + + private _renderNotification(notification: IChatSystemNotificationPart, renderer: IMarkdownRenderer, instantiationService: IInstantiationService): HTMLElement { const rendered = this._register(renderer.render(notification.content)); - this.domNode = this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, notification.icon ?? Codicon.check, undefined)).domNode; + return this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, notification.icon ?? Codicon.check, undefined)).domNode; } private _renderCollapsibleNotification(notification: IChatSystemNotificationPart, renderer: IMarkdownRenderer, firstLineBreak: number, detailsValue: string): HTMLElement { @@ -101,6 +118,7 @@ export class ChatSystemNotificationContentPart extends Disposable implements ICh return other.kind === 'systemNotification' && other.content.value === this.notification.content.value && ThemeIcon.isEqual(other.icon ?? Codicon.check, this.notification.icon ?? Codicon.check) - && !!other.collapsible === !!this.notification.collapsible; + && !!other.collapsible === !!this.notification.collapsible + && !!other.renderInlineTiming === !!this.notification.renderInlineTiming; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSystemNotificationContentPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSystemNotificationContentPart.css index 811e8ed97f159a..92178273304792 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSystemNotificationContentPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSystemNotificationContentPart.css @@ -60,3 +60,47 @@ .chat-system-notification-disclosure.collapsed > .chat-system-notification-disclosure-body { display: none; } + +.chat-system-notification-layout { + display: flex; + align-items: last baseline; + gap: var(--vscode-spacing-size80); + margin-bottom: var(--vscode-spacing-size160); +} + +.chat-system-notification-layout > .progress-container, +.chat-system-notification-layout > .chat-system-notification-disclosure { + flex: 1; + min-width: 0; + margin-bottom: 0; +} + +.chat-system-notification-timing { + display: flex; + flex-shrink: 0; + opacity: 0; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-xs); + line-height: 16px; + transition: opacity 0.1s ease-in-out; +} + +.chat-system-notification-timing.hidden { + display: none; +} + +.interactive-item-container.chat-system-notification-response.group-hovered .chat-system-notification-timing, +.interactive-item-container.chat-system-notification-response:focus-within .chat-system-notification-timing { + opacity: 0.7; +} + +.chat-system-notification-timing:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +@media (prefers-reduced-motion: reduce) { + .chat-system-notification-timing { + transition: none; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 2674e66f786ac9..b43b6c624c57b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1342,7 +1342,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0 + && element.response.value.every(part => part.kind === 'systemNotification' && part.renderInlineTiming); const responseTimingListeners = templateData.elementDisposables.add(new MutableDisposable()); const updateResponseDetails = () => { - const details = isResponseVM(element) ? element.result?.details : undefined; + const inlineTimingContainer = rendersInlineSystemNotificationTiming + ? templateData.renderedParts?.findLast(part => part instanceof ChatSystemNotificationContentPart)?.inlineTimingContainer + : undefined; + const responseDetailsContainer = inlineTimingContainer ?? templateData.footerDetailsContainer; + if (rendersInlineSystemNotificationTiming) { + renderChatResponseDetails(templateData.footerDetailsContainer, undefined, undefined, undefined, false); + } + const details = isResponseVM(element) && !rendersInlineSystemNotificationTiming ? element.result?.details : undefined; // Providers report usage asynchronously, often after the footer has already // rendered, so the breakdown is recomputed on every render pass. Sessions // whose provider reports no totals get no hover rather than an empty one. const tokenStats = isResponseVM(element) + && !rendersInlineSystemNotificationTiming ? formatResponseTokenStats(element.model.usage?.modelTotals, element.model.completionTimestamp) : undefined; const completedAtElement = renderChatResponseDetails( - templateData.footerDetailsContainer, + responseDetailsContainer, details, isResponseVM(element) ? element.model.completionTimestamp : undefined, - isResponseVM(element) ? element.model.elapsedMs : undefined, + isResponseVM(element) && !rendersInlineSystemNotificationTiming ? element.model.elapsedMs : undefined, isResponseVM(element) && this.configService.getValue(ChatConfiguration.Verbose), tokenStats?.footerAriaLabel, ); @@ -1422,29 +1433,28 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { const bounds = completedAtElement.getBoundingClientRect(); responseTimingBounds = bounds; - templateData.footerDetailsContainer.classList.add('chat-response-flip-reset'); - templateData.footerDetailsContainer.classList.remove('chat-response-flip-active'); - templateData.footerDetailsContainer.classList.toggle('chat-response-flip-down', e.clientY < bounds.top + bounds.height / 2); - void templateData.footerDetailsContainer.offsetWidth; - templateData.footerDetailsContainer.classList.remove('chat-response-flip-reset'); - void templateData.footerDetailsContainer.offsetWidth; - templateData.footerDetailsContainer.classList.add('chat-response-flip-active'); + responseDetailsContainer.classList.add('chat-response-flip-reset'); + responseDetailsContainer.classList.remove('chat-response-flip-active'); + responseDetailsContainer.classList.toggle('chat-response-flip-down', e.clientY < bounds.top + bounds.height / 2); + void responseDetailsContainer.offsetWidth; + responseDetailsContainer.classList.remove('chat-response-flip-reset'); + void responseDetailsContainer.offsetWidth; + responseDetailsContainer.classList.add('chat-response-flip-active'); })); - listeners.add(dom.addDisposableListener(templateData.footerDetailsContainer, dom.EventType.MOUSE_MOVE, e => { + listeners.add(dom.addDisposableListener(responseDetailsContainer, dom.EventType.MOUSE_MOVE, e => { if (responseTimingBounds && (e.clientX < responseTimingBounds.left || e.clientX > responseTimingBounds.right || e.clientY < responseTimingBounds.top || e.clientY > responseTimingBounds.bottom)) { responseTimingBounds = undefined; - templateData.footerDetailsContainer.classList.remove('chat-response-flip-active'); + responseDetailsContainer.classList.remove('chat-response-flip-active'); } })); - listeners.add(dom.addDisposableListener(templateData.footerDetailsContainer, dom.EventType.MOUSE_LEAVE, () => { + listeners.add(dom.addDisposableListener(responseDetailsContainer, dom.EventType.MOUSE_LEAVE, () => { responseTimingBounds = undefined; - templateData.footerDetailsContainer.classList.remove('chat-response-flip-active'); + responseDetailsContainer.classList.remove('chat-response-flip-active'); })); - listeners.add(dom.addDisposableListener(templateData.footerDetailsContainer, dom.EventType.FOCUS, () => { - templateData.footerDetailsContainer.classList.remove('chat-response-flip-active', 'chat-response-flip-down'); + listeners.add(dom.addDisposableListener(responseDetailsContainer, dom.EventType.FOCUS, () => { + responseDetailsContainer.classList.remove('chat-response-flip-active', 'chat-response-flip-down'); })); }; - updateResponseDetails(); ChatContextKeys.responseHasError.bindTo(templateData.contextKeyService).set(isResponseVM(element) && !!element.errorDetails); const isFiltered = !!(isResponseVM(element) && element.errorDetails?.responseIsFiltered); @@ -1454,6 +1464,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { enabled: notice(AgentSystemNotificationKind.AgentMergeEnabled), configurationChanged: notice(AgentSystemNotificationKind.AgentMergeConfigurationChanged), disabled: notice(AgentSystemNotificationKind.AgentMergeDisabled), + pullRequestMerged: notice(AgentSystemNotificationKind.AgentMergePullRequestMerged), // An unrecognized kind must still render, using the default check. unknown: activeTurnToProgress(URI.file('/'), createActiveTurnState([{ kind: ResponsePartKind.SystemNotification, @@ -2571,9 +2572,10 @@ suite('stateToProgressAdapter', () => { _meta: { kind: 'somethingNewer' }, }]), undefined)[0], }, { - enabled: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.gitMerge, collapsible: true }, - configurationChanged: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.settingsGear, collapsible: true }, - disabled: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.circleSlash }, + enabled: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.gitMerge, collapsible: true, renderInlineTiming: true }, + configurationChanged: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.settingsGear, collapsible: true, renderInlineTiming: true }, + disabled: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.circleSlash, renderInlineTiming: true }, + pullRequestMerged: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state'), icon: Codicon.gitMerge, renderInlineTiming: true }, unknown: { kind: 'systemNotification', content: new MarkdownString('Agent Merge changed state') }, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts index c20fb6f415ef90..58b92a5adc9f15 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts @@ -34,17 +34,36 @@ suite('ChatSystemNotificationContentPart', () => { { kind: 'systemNotification', content: new MarkdownString('Background command completed') }, renderer, )); + const inlineTimingPart = disposables.add(instantiationService.createInstance( + ChatSystemNotificationContentPart, + { kind: 'systemNotification', content: new MarkdownString('Agent Merge started'), icon: Codicon.gitMerge, renderInlineTiming: true }, + renderer, + )); assert.deepStrictEqual({ text: part.domNode.textContent, hasCheck: !!part.domNode.querySelector('.codicon-check-compact'), sameContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') }), differentContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Different') }), + inlineTiming: { + isLayout: inlineTimingPart.domNode.classList.contains('chat-system-notification-layout'), + hasMergeIcon: !!inlineTimingPart.domNode.querySelector('.codicon-git-merge'), + hasTimingContainer: inlineTimingPart.inlineTimingContainer?.classList.contains('chat-system-notification-timing'), + sameContent: inlineTimingPart.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Agent Merge started'), icon: Codicon.gitMerge, renderInlineTiming: true }), + differentPresentation: inlineTimingPart.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Agent Merge started'), icon: Codicon.gitMerge }), + }, }, { text: 'Background command completed', hasCheck: true, sameContent: true, differentContent: false, + inlineTiming: { + isLayout: true, + hasMergeIcon: true, + hasTimingContainer: true, + sameContent: true, + differentPresentation: false, + }, }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeFlow.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeFlow.fixture.ts new file mode 100644 index 00000000000000..42fbc211234650 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeFlow.fixture.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { agentMergeDisableReasons, agentMergeEnabledNotice, defaultAgentMergeConfiguration } from '../../../../../platform/agentHost/common/agentMerge.js'; +import { buildAgentMergePrompt } from '../../../../../platform/agentHost/common/agentMergePrompt.js'; +import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; +import { systemNotificationToChatPart } from '../../../../contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { IChatSystemNotificationPart } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { IFixtureMessage, renderChatWidget } from './chatWidget.fixture.js'; + +const pullRequestUrl = 'https://github.com/microsoft/vscode/pull/333964'; + +function agentMergeNotification(content: string, kind: AgentSystemNotificationKind): IChatSystemNotificationPart { + const notification = systemNotificationToChatPart(content, 'fixture', toAgentSystemNotificationMeta({ kind })); + if (notification?.kind !== 'systemNotification') { + throw new Error('Expected an Agent Merge system notification'); + } + return notification; +} + +const agentMergePrompt = buildAgentMergePrompt(['fixCI'], { + pullRequestUrl, + title: 'agentHost: add draft PR Agent Merge operation', + headSha: '9665aca22f3e3147ee87449bf3cb0592a7345847', + headRef: 'sessions/draft-pr-agent-merge', + baseRef: 'main', + reviewThreads: [], + reviewSummaries: [], + newComments: [], + failedChecks: ['Linux Unit Tests'], + behind: false, + conflicting: false, + commentWatermark: '2026-09-02T12:00:00.000Z', +}); + +const agentMergeFlow: IFixtureMessage[] = [ + { + user: 'Create a draft pull request and enable Agent Merge.', + assistant: [{ + kind: 'markdown', + text: `Created draft pull request [#333964](${pullRequestUrl}) and enabled Agent Merge.`, + }], + details: 'GPT-5.6 Sol - 2 credits', + }, + { + user: 'Agent Merge enabled', + requestHidden: true, + assistant: [{ + kind: 'systemNotification', + notification: agentMergeNotification( + agentMergeEnabledNotice({ + branchName: 'sessions/draft-pr-agent-merge', + pullRequestUrl, + }, defaultAgentMergeConfiguration), + AgentSystemNotificationKind.AgentMergeEnabled, + ), + }], + }, + { + user: agentMergePrompt, + isSystemInitiated: true, + assistant: [{ + kind: 'markdown', + text: 'Fixed the failing Linux unit test and pushed the updated commit. Agent Merge will wait for the new CI results.', + }], + details: 'GPT-5.6 Sol - 1 credit', + }, + { + user: 'Agent Merge completed', + requestHidden: true, + assistant: [{ + kind: 'systemNotification', + notification: agentMergeNotification( + agentMergeDisableReasons.pullRequestMerged(333964, pullRequestUrl).notice, + AgentSystemNotificationKind.AgentMergePullRequestMerged, + ), + }], + }, +]; + +async function renderAgentMergeFlow(context: ComponentFixtureContext, hoverMergedNotice: boolean): Promise { + await renderChatWidget(context, { + messages: agentMergeFlow, + width: 720, + height: 760, + listHeight: 740, + inputVisible: false, + responseFooterAction: true, + verbose: true, + }); + + const targetWindow = dom.getWindow(context.container); + const nextFrame = () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); + await nextFrame(); + await nextFrame(); + + const notificationRows = [...context.container.querySelectorAll('.chat-system-notification-response')]; + if (notificationRows.length !== 2) { + throw new Error(`Expected two Agent Merge notification rows, got ${notificationRows.length}`); + } + for (const row of notificationRows) { + const footer = row.querySelector('.chat-footer-toolbar'); + if (!footer || dom.getWindow(row).getComputedStyle(footer).display !== 'none') { + throw new Error('Agent Merge notification footer toolbar is visible'); + } + if (!row.querySelector('.chat-system-notification-timing .chat-response-timing')) { + throw new Error('Agent Merge notification is missing inline response timing'); + } + const layout = row.querySelector('.chat-system-notification-layout')!; + const timing = row.querySelector('.chat-system-notification-timing')!; + if (targetWindow.getComputedStyle(layout).alignItems !== 'last baseline') { + throw new Error('Agent Merge notification timing is not baseline-aligned'); + } + if (Math.abs(layout.getBoundingClientRect().right - timing.getBoundingClientRect().right) > 1) { + throw new Error('Agent Merge notification timing is not right-aligned'); + } + } + + if (hoverMergedNotice) { + notificationRows.at(-1)?.querySelector(':scope > .value')?.dispatchEvent(new targetWindow.MouseEvent('mouseenter')); + await nextFrame(); + const timing = notificationRows.at(-1)?.querySelector('.chat-system-notification-timing'); + if (!timing || Number(targetWindow.getComputedStyle(timing).opacity) === 0) { + throw new Error('Agent Merge notification timing did not appear on hover'); + } + } +} + +export default defineThemedFixtureGroup({ path: 'chat/' }, { + FullConversation: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAgentMergeFlow(context, false), + }), + FullConversationMergedNoticeHovered: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAgentMergeFlow(context, true), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts index e7abe242cdb112..21179107b38768 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentMergeNotice.fixture.ts @@ -118,12 +118,12 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { ), }), - DisabledByMerge: defineComponentFixture({ + PullRequestMerged: defineComponentFixture({ labels: { kind: 'screenshot' }, render: (ctx) => renderNotice( ctx, - agentMergeDisableReasons.pullRequestMerged().notice, - AgentSystemNotificationKind.AgentMergeDisabled, + agentMergeDisableReasons.pullRequestMerged(123, 'https://github.com/microsoft/vscode/pull/123').notice, + AgentSystemNotificationKind.AgentMergePullRequestMerged, ), }), diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index 9de3e57d918467..a239e0019c9171 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -22,7 +22,7 @@ import { chatFloatingPersistentContentClass, chatPersistentContentHeightVariable import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../contrib/chat/browser/widget/input/chatInputPart.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; -import { ElicitationState, IChatQuestion, IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ElicitationState, IChatQuestion, IChatService, IChatSystemNotificationPart } from '../../../../contrib/chat/common/chatService/chatService.js'; import { ChatElicitationRequestPart } from '../../../../contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatToolInvocation } from '../../../../contrib/chat/common/model/chatProgressTypes/chatToolInvocation.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; @@ -58,12 +58,17 @@ export interface IFixtureMessage { readonly assistant?: ReadonlyArray< | { kind: 'markdown'; text: string } | { kind: 'progress'; text: string } + | { kind: 'systemNotification'; notification: IChatSystemNotificationPart } | { kind: 'questionCarousel'; questions: IChatQuestion[]; message?: string; allowSkip?: boolean } | { kind: 'terminalConfirmation'; command: string; title?: string; disclaimer?: string; requestUnsandboxedExecution?: boolean; requestUnsandboxedExecutionReason?: string; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string } } | { kind: 'elicitation'; title: string; message: string; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string }; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean } >; readonly details?: string; readonly responseComplete?: boolean; + /** Whether the request is a host-initiated turn rendered with its specialized presentation. */ + readonly isSystemInitiated?: boolean; + /** Whether the request half of the turn stays out of the transcript. */ + readonly requestHidden?: boolean; /** * Per-turn file changes surfaced via {@link IChatResponseFileChangesService}, * used by the turn changes summary. Requires `turnStatusPills` on the fixture @@ -254,7 +259,29 @@ export async function renderChatWidget(context: ComponentFixtureContext, options chatService.addSession(model); for (const message of options.messages) { - const request = model.addRequest(makeUserMessage(message.user), { variables: [] }, 0); + const request = model.addRequest( + makeUserMessage(message.user), + { variables: [] }, + 0, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + message.isSystemInitiated, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + message.requestHidden, + ); const response = request.response!; if (message.fileChanges) { const fileEdits = message.fileChanges.map(makeFileDiff); @@ -266,6 +293,8 @@ export async function renderChatWidget(context: ComponentFixtureContext, options model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(part.text) }); } else if (part.kind === 'progress') { model.acceptResponseProgress(request, { kind: 'progressMessage', content: new MarkdownString(part.text) }); + } else if (part.kind === 'systemNotification') { + model.acceptResponseProgress(request, part.notification); } else if (part.kind === 'questionCarousel') { model.acceptResponseProgress(request, { kind: 'questionCarousel', From 3f9532fa92d4761a169a1b25aa50d2149ac2ac7d Mon Sep 17 00:00:00 2001 From: Jessie Houghton <46505805+houghj16@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:21:22 -0700 Subject: [PATCH 23/23] chat: Improve customization migration flow (#333889) * chat: Avoid duplicate customization migration picker Reuse an explicitly selected harness destination across compatible customization types, and add a seeded launch profile for manually testing migration flows. Fixes #333559 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: Stabilize customization migration page Keep the migration list mounted when retained originals trigger prompt-service updates, and align its scrollbar with other customization pages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: Address customization migration feedback Serialize migration writes, use provider-defined destination groups, accept the intentional screenshot updates, and remove the local-only migration profile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilotCLICustomizationProvider.ts | 6 +- .../api/browser/mainThreadChatAgents2.ts | 1 + .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatAgents2.ts | 1 + .../agentCustomizationItemProvider.ts | 1 + .../aiCustomizationManagementEditor.ts | 166 +++++++++----- .../media/aiCustomizationManagement.css | 6 +- ...promptsServiceCustomizationItemProvider.ts | 3 +- .../common/customizationHarnessService.ts | 2 + .../aiCustomizationManagementEditor.test.ts | 209 ++++++++++++++++++ ...aiCustomizationManagementEditor.fixture.ts | 2 + ...osed.chatSessionCustomizationProvider.d.ts | 5 + .../blocks-ci-screenshots.md | 8 +- 13 files changed, 342 insertions(+), 69 deletions(-) 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 f2fcb50255496d..b58c056c6fffe3 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -93,7 +93,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod folders.push({ uri: URI.joinPath(folder, ...root.path), label: root.path[0], - source: 'local' + source: 'local', + destinationGroupId: URI.joinPath(folder, root.path[0]).toString(), }); } } @@ -103,7 +104,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod folders.push({ uri: URI.joinPath(this.envService.userHome, ...root.path), label: `~/${root.path[0]}`, - source: 'user' + source: 'user', + destinationGroupId: URI.joinPath(this.envService.userHome, root.path[0]).toString(), }); } } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a8262bc807ae98..3eb9222a1c6777 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -812,6 +812,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA uri: URI.revive(folder.uri), label: folder.label, source: folder.source, + destinationGroupId: folder.destinationGroupId, })); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7f06743cc9d092..f0ccbf4316925a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1833,6 +1833,7 @@ export interface IChatSessionCustomizationSourceFolderDto { readonly uri: UriComponents; readonly label: string; readonly source: IChatResourceSourceDto; + readonly destinationGroupId?: string; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 6b03bf70318d35..de2cdb35ebfc24 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -887,6 +887,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS uri: folder.uri, label: folder.label, source: folder.source, + destinationGroupId: folder.destinationGroupId, } satisfies IChatSessionCustomizationSourceFolderDto)); } catch (err) { return undefined; 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 c0c6dc804d0ef3..eabc546178fc08 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -206,6 +206,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto uri: this.toRemoteUri(customization.uri), label: customization.name, source, + destinationGroupId: dirname(this.toRemoteUri(customization.uri)).toString(), }); } return folders; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index e5874fbaf1428b..90e17c1db674bb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -102,7 +102,7 @@ import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js' import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage, type ICustomizationMigrationCategorySummary } from './aiCustomizationWelcomePage.js'; -import { type CustomizationMigrationTargetFolders, migrateCustomizations } from './customizationMigration.js'; +import { type CustomizationMigrationTargetFolders, type IMigratedCustomizationsResult, migrateCustomizations } from './customizationMigration.js'; import { CUSTOMIZATION_MIGRATION_CATEGORIES, CustomizationMigrationCategoryId, getCustomizationMigrationCategory, type ICustomizationMigrationBanner, type ICustomizationMigrationCategory } from './customizationMigrationCategories.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -386,6 +386,8 @@ export class AICustomizationManagementEditor extends EditorPane { private customizationMigrationRefreshSequence = 0; private customizationMigrationLoading = false; private customizationMigrationLoadError: string | undefined; + private customizationMigrationInProgress = false; + private customizationMigrationWritesInProgress = false; private readonly editorDisposables = this._register(new DisposableStore()); private _editorContentChanged = false; @@ -934,9 +936,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.promptsService.onDidChangeCustomAgents, this.promptsService.onDidChangeInstructions, this.promptsService.onDidChangeAgentInstructions, - )(() => { - void this.refreshCustomizationMigrationInfo(); - })); + )(() => this.refreshCustomizationMigrationInfoFromPromptChange())); this.registerCustomizationMigrationSessionRefresh(); // Container for prompts-based content (Agents, Skills, Instructions, Prompts) @@ -1111,6 +1111,12 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + private refreshCustomizationMigrationInfoFromPromptChange(): void { + if (!this.customizationMigrationWritesInProgress) { + void this.refreshCustomizationMigrationInfo(); + } + } + private async refreshCustomizationMigrationInfo(): Promise { const activeHarnessId = this.harnessService.activeHarness.get(); const activeSessionResource = this.harnessService.activeSessionResource.get(); @@ -1291,67 +1297,89 @@ export class AICustomizationManagementEditor extends EditorPane { } private async migrateSelectedCustomizations(category: ICustomizationMigrationCategory, customizations: readonly MigratableConfiguration[]): Promise { - if (customizations.length === 0 || !this.isMigrationCategoryEnabled(category)) { + if (this.customizationMigrationInProgress || customizations.length === 0 || !this.isMigrationCategoryEnabled(category)) { return; } - const sessionResource = this.harnessService.activeSessionResource.get(); - const targetFolders = await this.resolveCustomizationMigrationTargetFolders(customizations, this.customizationMigrationTargetFoldersByType, sessionResource); - if (!targetFolders || !this.isCustomizationMigrationSessionActive(sessionResource)) { - return; - } + this.customizationMigrationInProgress = true; + this.updateCustomizationMigrationActionState(); + try { + const sessionResource = this.harnessService.activeSessionResource.get(); + const targetFolders = await this.resolveCustomizationMigrationTargetFolders(customizations, this.customizationMigrationTargetFoldersByType, sessionResource); + if (!targetFolders || !this.isCustomizationMigrationSessionActive(sessionResource)) { + return; + } - const confirmation = category.getConfirmation( - customizations, - this.getActiveHarnessLabel(), - this.getCustomizationMigrationDestinationLabel( - [...targetFolders.values()].flatMap(foldersByStorage => [...foldersByStorage.values()]), - ), - ); - const confirmResult = await this.dialogService.confirm({ - type: 'question', - message: confirmation.message, - detail: confirmation.detail, - checkbox: { - label: confirmation.deleteOriginalsLabel, - checked: true, - }, - primaryButton: confirmation.primaryButton, - }); - if (!confirmResult.confirmed || !this.isCustomizationMigrationSessionActive(sessionResource)) { - return; - } + const confirmation = category.getConfirmation( + customizations, + this.getActiveHarnessLabel(), + this.getCustomizationMigrationDestinationLabel( + [...targetFolders.values()].flatMap(foldersByStorage => [...foldersByStorage.values()]), + ), + ); + const confirmResult = await this.dialogService.confirm({ + type: 'question', + message: confirmation.message, + detail: confirmation.detail, + checkbox: { + label: confirmation.deleteOriginalsLabel, + checked: true, + }, + primaryButton: confirmation.primaryButton, + }); + if (!confirmResult.confirmed || !this.isCustomizationMigrationSessionActive(sessionResource)) { + return; + } - const migrationResult = await migrateCustomizations( - customizations, - targetFolders, - this.fileService, - onUnexpectedError, - { deleteOriginalFiles: confirmResult.checkboxChecked !== false }, - ); - const { migratedCount, failedCustomizationFileNames, unsupportedHeaderKeys, migratedCustomizations } = migrationResult; + const deleteOriginalFiles = confirmResult.checkboxChecked !== false; + const migrationResult = await this.runCustomizationMigration(customizations, targetFolders, deleteOriginalFiles); + const { migratedCount, failedCustomizationFileNames, unsupportedHeaderKeys, migratedCustomizations } = migrationResult; - if (failedCustomizationFileNames.length > 0) { - const displayedFileNames = failedCustomizationFileNames.slice(0, 3); - const hiddenFileCount = failedCustomizationFileNames.length - displayedFileNames.length; - this.notificationService.error(category.getFailedMessage(displayedFileNames, hiddenFileCount)); - } + if (failedCustomizationFileNames.length > 0) { + const displayedFileNames = failedCustomizationFileNames.slice(0, 3); + const hiddenFileCount = failedCustomizationFileNames.length - displayedFileNames.length; + this.notificationService.error(category.getFailedMessage(displayedFileNames, hiddenFileCount)); + } - if (migratedCount === 0) { - if (failedCustomizationFileNames.length === 0) { - this.notificationService.warn(category.noFilesMigratedMessage); + if (migratedCount === 0) { + if (failedCustomizationFileNames.length === 0) { + this.notificationService.warn(category.noFilesMigratedMessage); + } + return; + } + + if (deleteOriginalFiles) { + await this.refreshCustomizationMigrationInfo(); } - return; - } - await this.refreshCustomizationMigrationInfo(); + const unsupportedKeysLabel = unsupportedHeaderKeys.join(', '); + this.notificationService.info(unsupportedKeysLabel.length > 0 && category.getMigratedWithReviewMessage + ? category.getMigratedWithReviewMessage(migratedCount, unsupportedKeysLabel) + : category.getMigratedMessage(migratedCount)); - const unsupportedKeysLabel = unsupportedHeaderKeys.join(', '); - this.notificationService.info(unsupportedKeysLabel.length > 0 && category.getMigratedWithReviewMessage - ? category.getMigratedWithReviewMessage(migratedCount, unsupportedKeysLabel) - : category.getMigratedMessage(migratedCount)); + if (deleteOriginalFiles) { + void this.revealMigratedCustomizations(migratedCustomizations); + } + } finally { + this.customizationMigrationInProgress = false; + this.updateCustomizationMigrationActionState(); + } + } - void this.revealMigratedCustomizations(migratedCustomizations); + private async runCustomizationMigration(customizations: readonly MigratableConfiguration[], targetFolders: CustomizationMigrationTargetFolders, deleteOriginalFiles: boolean): Promise { + this.customizationMigrationWritesInProgress = true; + try { + return await migrateCustomizations( + customizations, + targetFolders, + this.fileService, + onUnexpectedError, + { deleteOriginalFiles }, + ); + } finally { + await timeout(0); + this.customizationMigrationWritesInProgress = false; + } } private renderCustomizationMigrationPage(): void { @@ -1628,7 +1656,7 @@ export class AICustomizationManagementEditor extends EditorPane { } const category = this.getActiveMigrationCategory() ?? CUSTOMIZATION_MIGRATION_CATEGORIES[0]; const selectedCount = this.getMigrationCandidates(category).filter(customization => this.isCustomizationSelectedForMigration(customization)).length; - this.migrationMigrateButton.enabled = selectedCount > 0; + this.migrationMigrateButton.enabled = selectedCount > 0 && !this.customizationMigrationInProgress; if (this.migrationSelectedCountElement) { this.migrationSelectedCountElement.textContent = selectedCount === 1 ? localize('customizationMigrationOneSelected', "1 selected") @@ -1696,6 +1724,7 @@ export class AICustomizationManagementEditor extends EditorPane { } const targetFolders = new Map>(); + const selectedDestinationGroupIds = new Map(); for (const [targetType, requiredStorages] of requiredStorageByTargetType) { const availableFolders = availableSourceFolders.get(targetType) ?? []; if (!this.isCustomizationMigrationSessionActive(sessionResource)) { @@ -1709,9 +1738,21 @@ export class AICustomizationManagementEditor extends EditorPane { return undefined; } - const targetFolder = matchingFolders.length === 1 - ? matchingFolders[0] - : await this.pickCustomizationMigrationTargetFolder(matchingFolders, targetType); + const selectedDestinationGroupId = selectedDestinationGroupIds.get(storage); + const foldersAtSelectedDestination = selectedDestinationGroupId + ? matchingFolders.filter(folder => folder.destinationGroupId === selectedDestinationGroupId) + : []; + let targetFolder: ICustomizationSourceFolder | undefined; + if (foldersAtSelectedDestination.length === 1) { + targetFolder = foldersAtSelectedDestination[0]; + } else if (matchingFolders.length === 1) { + targetFolder = matchingFolders[0]; + } else { + targetFolder = await this.pickCustomizationMigrationTargetFolder(matchingFolders, targetType, requiredStorageByTargetType.size > 1); + if (targetFolder?.destinationGroupId) { + selectedDestinationGroupIds.set(storage, targetFolder.destinationGroupId); + } + } if (!targetFolder || !this.isCustomizationMigrationSessionActive(sessionResource)) { return undefined; } @@ -1747,7 +1788,7 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private async pickCustomizationMigrationTargetFolder(sourceFolders: readonly ICustomizationSourceFolder[], targetType: PromptsType): Promise { + private async pickCustomizationMigrationTargetFolder(sourceFolders: readonly ICustomizationSourceFolder[], targetType: PromptsType, selectsMultipleTypes: boolean): Promise { const picks: IMigrationTargetQuickPickItem[] = sourceFolders.map(folder => ({ label: folder.label, description: this.labelService.getUriLabel(folder.uri, { relative: true }), @@ -1756,13 +1797,16 @@ export class AICustomizationManagementEditor extends EditorPane { const selected = await this.quickInputService.pick(picks, { canPickMany: false, - placeHolder: this.getMigrationTargetFolderPlaceholder(targetType), + placeHolder: this.getMigrationTargetFolderPlaceholder(targetType, selectsMultipleTypes), matchOnDescription: true, }); return selected?.folder; } - private getMigrationTargetFolderPlaceholder(targetType: PromptsType): string { + private getMigrationTargetFolderPlaceholder(targetType: PromptsType, selectsMultipleTypes: boolean): string { + if (selectsMultipleTypes) { + return localize('migrationPickCustomizationFolder', "Select a destination for the migrated customizations"); + } switch (targetType) { case PromptsType.skill: return localize('migrationPickSkillFolder', "Select a destination folder for migrated skills"); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 8b5104713385df..766e6d6603db51 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -961,6 +961,9 @@ per-word capitalization does not survive translation. */ position: relative; flex: 1; min-height: 0; + width: 100%; + max-width: calc(840px + var(--vscode-spacing-size160)); + margin-inline: auto; } .ai-customization-management-editor .prompt-migration-footer { @@ -1068,7 +1071,8 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; overflow: hidden; - padding-bottom: var(--vscode-spacing-size60); + padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size60) 0; + box-sizing: border-box; } .ai-customization-management-editor .prompt-migration-group { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 57c594184b4f19..bf704c8e9dd397 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -71,7 +71,8 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt // folders like ~/.copilot/skills read naturally. Only folders that // carry a source (currently skills) use this; others fall back. label: (folder.source !== undefined ? getSourceDescription(folder.source) : undefined) ?? this.promptsService.getPromptLocationLabel(folder), - source: folder.storage + source: folder.storage, + destinationGroupId: dirname(folder.uri).toString(), })); } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 73c6596cf30f95..094f53011de39f 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -261,6 +261,8 @@ export interface ICustomizationSourceFolder { readonly label: string; /** Customization source for this folder (typically 'local' or 'user' for writable creation locations). */ readonly source: AICustomizationSource; + /** Opaque provider-defined identity shared by folders that belong to the same destination. */ + readonly destinationGroupId?: string; } /** 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 3ec5fb6c8ef758..22599bf3cde971 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 @@ -57,6 +57,8 @@ suite('aiCustomizationManagementEditor', () => { currentEditingReadOnly: boolean; customizationsByMigrationCategory: Map; customizationMigrationTargetFoldersByType: Map; + customizationMigrationInProgress: boolean; + customizationMigrationWritesInProgress: boolean; activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; editorDisplayMode: 'preview' | 'raw'; editorPreviewFrontMatterContainer: HTMLElement | undefined; @@ -77,6 +79,10 @@ suite('aiCustomizationManagementEditor', () => { selectedCustomizationMigrationItems: ResourceMap>; migrationPageDisposables: DisposableStore; labelService: { getUriLabel(uri: URI, options?: { relative?: boolean }): string }; + quickInputService: { + pick(items: readonly { folder: ICustomizationSourceFolder }[]): Promise<{ folder: ICustomizationSourceFolder } | undefined>; + }; + notificationService: { error(message: string): void }; showEmbeddedEditor(...args: unknown[]): Promise; getActiveHarnessLabel(): string; welcomePage: { setMigrationCategories(categories: readonly unknown[]): void } | undefined; @@ -87,12 +93,19 @@ suite('aiCustomizationManagementEditor', () => { renderPreviewAttribute(attribute: IHeaderAttribute, promptType: PromptsType, target: Target): void; onStructuredPreviewSettingChanged(): void; refreshCustomizationMigrationUi(): void; + refreshCustomizationMigrationInfoFromPromptChange(): void; refreshCustomizationMigrationInfo(): Promise; registerCustomizationMigrationSessionRefresh(): void; renderCustomizationMigrationPage(): void; + updateCustomizationMigrationActionState(): void; setCustomizationsToMigrate(candidates: Map, targetFoldersByType: Map): void; isCustomizationSelectedForMigration(customization: MigratableConfiguration): boolean; setCustomizationSelectedForMigration(customization: MigratableConfiguration, selected: boolean): void; + resolveCustomizationMigrationTargetFolders( + customizations: readonly MigratableConfiguration[], + availableSourceFolders: ReadonlyMap, + sessionResource: URI, + ): Promise> | undefined>; updateContentVisibility(): void; setVisible(visible: boolean): void; }; @@ -116,6 +129,8 @@ suite('aiCustomizationManagementEditor', () => { editor.currentEditingReadOnly = false; editor.customizationsByMigrationCategory = new Map(); editor.customizationMigrationTargetFoldersByType = new Map(); + editor.customizationMigrationInProgress = false; + editor.customizationMigrationWritesInProgress = false; editor.activeMigrationCategoryId = undefined; editor.editorDisplayMode = 'preview'; editor.editorPreviewFrontMatterContainer = document.createElement('div'); @@ -144,6 +159,12 @@ suite('aiCustomizationManagementEditor', () => { editor.labelService = { getUriLabel: uri => uri.path, }; + editor.quickInputService = { + pick: async items => items[0], + }; + editor.notificationService = { + error: () => { }, + }; editor.showEmbeddedEditor = async () => { }; editor.getActiveHarnessLabel = () => 'Copilot'; editor.welcomePage = undefined; @@ -383,6 +404,43 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); }); + test('suppresses prompt change refreshes only while migration writes are in progress', () => { + const editor = createTestEditor(); + let refreshCount = 0; + editor.refreshCustomizationMigrationInfo = async () => { + refreshCount++; + }; + + editor.customizationMigrationInProgress = true; + editor.refreshCustomizationMigrationInfoFromPromptChange(); + editor.customizationMigrationWritesInProgress = true; + editor.refreshCustomizationMigrationInfoFromPromptChange(); + editor.customizationMigrationWritesInProgress = false; + editor.refreshCustomizationMigrationInfoFromPromptChange(); + + assert.strictEqual(refreshCount, 2); + editor.editorPreviewDisposables.dispose(); + }); + + test('disables migration while another migration is in progress', () => { + const editor = createTestEditor(); + const customization: MigratableConfiguration = { + uri: URI.file('/user-data/prompts/reviewer.agent.md'), + storage: PromptsStorage.user, + type: PromptsType.agent, + source: PromptFileSource.UserData, + }; + editor.migrationMigrateButton = { enabled: true, label: '' }; + editor.setCustomizationsToMigrate(new Map([[CustomizationMigrationCategoryId.UserData, [customization]]]), new Map()); + editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.UserData; + + editor.customizationMigrationInProgress = true; + editor.updateCustomizationMigrationActionState(); + + assert.strictEqual(editor.migrationMigrateButton.enabled, false); + editor.editorPreviewDisposables.dispose(); + }); + test('migration banners include destination consequences when applicable', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsUserDataMigrationEnabled]: true, @@ -742,4 +800,155 @@ suite('aiCustomizationManagementEditor', () => { editor.editorPreviewDisposables.dispose(); } }); + + test('mixed user data migration chooses one destination root', async () => { + const editor = createTestEditor(); + const sessionResource = editor.harnessService.activeSessionResource.get(); + let pickerInvocationCount = 0; + const pickedFolders: ICustomizationSourceFolder[] = []; + editor.quickInputService = { + pick: async items => { + pickerInvocationCount++; + pickedFolders.push(...items.map(item => item.folder)); + return items[0]; + }, + }; + const customizations = [ + { + uri: URI.file('/user-data/prompts/reviewer.agent.md'), + storage: PromptsStorage.user, + type: PromptsType.agent, + source: PromptFileSource.UserData, + }, + { + uri: URI.file('/user-data/prompts/review.instructions.md'), + storage: PromptsStorage.user, + type: PromptsType.instructions, + source: PromptFileSource.UserData, + }, + ] as const satisfies readonly MigratableConfiguration[]; + const availableSourceFolders = new Map([ + [PromptsType.agent, [ + { uri: URI.file('/home/test/.copilot/agents'), label: 'Copilot', source: PromptsStorage.user, destinationGroupId: 'copilot' }, + { uri: URI.file('/home/test/.claude/agents'), label: 'Claude', source: PromptsStorage.user, destinationGroupId: 'claude' }, + ]], + [PromptsType.instructions, [ + { uri: URI.file('/home/test/.copilot/instructions'), label: 'Copilot', source: PromptsStorage.user, destinationGroupId: 'copilot' }, + { uri: URI.file('/home/test/.claude/rules'), label: 'Claude', source: PromptsStorage.user, destinationGroupId: 'claude' }, + ]], + ]); + + try { + const targetFolders = await editor.resolveCustomizationMigrationTargetFolders(customizations, availableSourceFolders, sessionResource); + + assert.deepStrictEqual({ + pickerInvocationCount, + pickerFolders: pickedFolders.map(folder => folder.uri.path), + agentTarget: targetFolders?.get(PromptsType.agent)?.get(PromptsStorage.user)?.uri.path, + instructionsTarget: targetFolders?.get(PromptsType.instructions)?.get(PromptsStorage.user)?.uri.path, + }, { + pickerInvocationCount: 1, + pickerFolders: ['/home/test/.copilot/agents', '/home/test/.claude/agents'], + agentTarget: '/home/test/.copilot/agents', + instructionsTarget: '/home/test/.copilot/instructions', + }); + } finally { + editor.editorPreviewDisposables.dispose(); + } + }); + + test('automatic migration target does not constrain a later folder choice', async () => { + const editor = createTestEditor(); + const sessionResource = editor.harnessService.activeSessionResource.get(); + let pickerInvocationCount = 0; + editor.quickInputService = { + pick: async items => { + pickerInvocationCount++; + return items[1]; + }, + }; + const customizations = [ + { + uri: URI.file('/user-data/prompts/reviewer.agent.md'), + storage: PromptsStorage.user, + type: PromptsType.agent, + source: PromptFileSource.UserData, + }, + { + uri: URI.file('/user-data/prompts/review.instructions.md'), + storage: PromptsStorage.user, + type: PromptsType.instructions, + source: PromptFileSource.UserData, + }, + ] as const satisfies readonly MigratableConfiguration[]; + const availableSourceFolders = new Map([ + [PromptsType.agent, [ + { uri: URI.file('/home/test/.copilot/agents'), label: 'Copilot', source: PromptsStorage.user, destinationGroupId: 'copilot' }, + ]], + [PromptsType.instructions, [ + { uri: URI.file('/home/test/.copilot/instructions'), label: 'Copilot', source: PromptsStorage.user, destinationGroupId: 'copilot' }, + { uri: URI.file('/home/test/.claude/rules'), label: 'Claude', source: PromptsStorage.user, destinationGroupId: 'claude' }, + ]], + ]); + + try { + const targetFolders = await editor.resolveCustomizationMigrationTargetFolders(customizations, availableSourceFolders, sessionResource); + + assert.deepStrictEqual({ + pickerInvocationCount, + agentTarget: targetFolders?.get(PromptsType.agent)?.get(PromptsStorage.user)?.uri.path, + instructionsTarget: targetFolders?.get(PromptsType.instructions)?.get(PromptsStorage.user)?.uri.path, + }, { + pickerInvocationCount: 1, + agentTarget: '/home/test/.copilot/agents', + instructionsTarget: '/home/test/.claude/rules', + }); + } finally { + editor.editorPreviewDisposables.dispose(); + } + }); + + test('does not infer migration destination groups from folder parents', async () => { + const editor = createTestEditor(); + const sessionResource = editor.harnessService.activeSessionResource.get(); + let pickerInvocationCount = 0; + editor.quickInputService = { + pick: async items => { + pickerInvocationCount++; + return items[0]; + }, + }; + const customizations = [ + { + uri: URI.file('/user-data/prompts/reviewer.agent.md'), + storage: PromptsStorage.user, + type: PromptsType.agent, + source: PromptFileSource.UserData, + }, + { + uri: URI.file('/user-data/prompts/review.instructions.md'), + storage: PromptsStorage.user, + type: PromptsType.instructions, + source: PromptFileSource.UserData, + }, + ] as const satisfies readonly MigratableConfiguration[]; + const availableSourceFolders = new Map([ + [PromptsType.agent, [ + { uri: URI.file('/home/test/.copilot/agents'), label: 'Copilot', source: PromptsStorage.user }, + { uri: URI.file('/home/test/.claude/agents'), label: 'Claude', source: PromptsStorage.user }, + ]], + [PromptsType.instructions, [ + { uri: URI.file('/home/test/.copilot/instructions'), label: 'Copilot', source: PromptsStorage.user }, + { uri: URI.file('/home/test/.claude/rules'), label: 'Claude', source: PromptsStorage.user }, + ]], + ]); + + try { + await editor.resolveCustomizationMigrationTargetFolders(customizations, availableSourceFolders, sessionResource); + + assert.strictEqual(pickerInvocationCount, 2); + } finally { + editor.editorPreviewDisposables.dispose(); + } + }); }); 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 bf315b158554ae..e0cddf34f3fbb7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -215,11 +215,13 @@ function createFixtureAgentHostItemProvider(files: readonly IFixtureFile[], remo uri: URI.file(`/workspace/.github/${folderName}`), label: '.github', source: PromptsStorage.local, + destinationGroupId: 'workspace-github', }, { uri: URI.file(`/home/dev/.copilot/${folderName}`), label: '~/.copilot', source: PromptsStorage.user, + destinationGroupId: 'user-copilot', }, ]; }, diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 66a6e102411c3e..72500ee8059d29 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -203,6 +203,11 @@ declare module 'vscode' { readonly label: string; /** Source of the customization folder. */ readonly source: ChatSessionCustomizationSource; + /** + * Opaque identity shared by source folders that belong to the same + * customization destination. + */ + readonly destinationGroupId?: string; } // #endregion diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 2304943a2308ab..5beedb57d1be29 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -79,10 +79,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/f08f056b5a9843fc413b1105d5ede83a19961aa8d000158779068b94c72135d5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/1d6f85fdd57fe38f105b261d64f2b3f54acd3cadf29d05ed216885dc7aadf30d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/13f0fd52bed5191a9fafe05be00a7757e72a92f05162a2f323a31663b9ddc83c) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/a8929c0724d59231dc954fda808ef68226a5e78f09b66ea279e6150c3c39650c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/45a9ae5a6c6ce5f5d9184ae55d124e13f6625c805b72a6d52b17035545ab7278) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/afe68d55d548d3234536e2e69147f8c48de5dac0e1eb09b1ff656337c370d664) @@ -91,10 +91,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/4f4bf2ba517c50d456a557fb27ff423cce77e24adf626754af2cb055dd931761) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b6806a0c2bed06ad703fdc1f42eb2ca50d8cc3027d9878f05905b276999f3568) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5483d9af839d6e44f95c61701cb3e03bd578e6ba1465f4724d9231654d775549) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/aab2492aeded0d8da323ee4408514499e011c65de2844137af7b47f21b490e56) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7203d034fad4d4439b6f70ac49ed02f95d764a090f8067a06bb6bae7d4bd1107) #### chat/aiCustomizations/aiCustomizationManagementEditor/WelcomePage/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/483f8579ecd4518666859e405e43c08aec24e1a857a05f242517c1916ab21b9a)