From c72e8d2173ef791cb562408aa5804705e29f35b3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 16:59:30 -0600 Subject: [PATCH 01/24] Prioritize active sessions in sorting for improved user experience --- .../contrib/chat/browser/agentSessions/agentSessionsViewer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 67b46dcc345..3958daa410f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -852,7 +852,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const sorter = this.sorter; const sortedSessions = sorter instanceof AgentSessionsSorter - ? sessions.sort((a, b) => sorter.compare(a, b, isCapped /* special sorting for when results are capped to keep active ones top */)) + ? sessions.sort((a, b) => sorter.compare(a, b, true /* prioritize active sessions to keep in-progress/needs-input ones top within each group */)) : sessions.sort(sorter.compare.bind(sorter)); if (isCapped) { From ebe83b28f866b89ff2e3e31c12dcb852558a4300 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:11:31 -0700 Subject: [PATCH 02/24] Add "Show More" functionality for capped repository groups in agent sessions --- .../sessions/browser/sessionsViewPane.ts | 2 + .../agentSessions/agentSessionsControl.ts | 24 +++-- .../agentSessions/agentSessionsModel.ts | 14 +++ .../agentSessions/agentSessionsViewer.ts | 101 ++++++++++++++++-- .../media/agentsessionsviewer.css | 13 +++ .../agentSessionsDataSource.test.ts | 95 +++++++++++++++- 6 files changed, 235 insertions(+), 14 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 66f102e9d76..3af5a41ef2a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -23,6 +23,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; +import { AgentSessionsDataSource } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; @@ -157,6 +158,7 @@ export class AgenticSessionsViewPane extends ViewPane { overrideStyles: this.getLocationBasedColors().listOverrideStyles, disableHover: true, enableApprovalRow: true, + repositoryGroupLimit: AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1f073a38862..809502a5cec 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -12,8 +12,8 @@ import { $, append, EventHelper, addDisposableListener, EventType, hide, setVisi import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowMore } from './agentSessionsModel.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionShowMoreRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -49,6 +49,7 @@ export interface IAgentSessionsControlOptions { readonly source: string; readonly disableHover?: boolean; readonly enableApprovalRow?: boolean; + readonly repositoryGroupLimit?: number; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; @@ -79,6 +80,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private emptyFilterMessage: HTMLElement | undefined; private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + private sessionsDataSource: AgentSessionsDataSource | undefined; private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; private sessionsListFindIsOpen = false; @@ -260,7 +262,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); - const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); + const sessionDataSource = this.sessionsDataSource = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.options.repositoryGroupLimit)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, @@ -269,8 +271,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo [ sessionRenderer, this.instantiationService.createInstance(AgentSessionSectionRenderer), + new AgentSessionShowMoreRenderer(), ], - sessionFilter, + sessionDataSource, { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -295,10 +298,14 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); - this._register(sessionFilter.onDidGetChildren(count => { + this._register(sessionDataSource.onDidGetChildren(count => { this.updateEmpty(count === 0); })); + this._register(sessionDataSource.onDidExpandRepositoryGroup(() => { + this.update(); + })); + const model = this.agentSessionsService.model; this._register(this.options.filter.onDidChange(async () => { @@ -406,6 +413,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return; // Section headers are not openable } + if (isAgentSessionShowMore(element)) { + this.sessionsDataSource?.expandRepositoryGroup(element.sectionLabel); + return; + } + this.telemetryService.publicLog2('agentSessionOpened', { providerType: element.providerType, source: this.options.source @@ -423,7 +435,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } private async showContextMenu({ element, anchor, browserEvent }: ITreeContextMenuEvent): Promise { - if (!element) { + if (!element || isAgentSessionShowMore(element)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9115dea9a40..b98bfa148af 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -195,6 +195,20 @@ export function isAgentSessionSection(obj: unknown): obj is IAgentSessionSection return typeof candidate.section === 'string' && Array.isArray(candidate.sessions); } +/** + * A "Show N More..." item that appears as the last child + * of a capped repository group section. + */ +export interface IAgentSessionShowMore { + readonly showMore: true; + readonly sectionLabel: string; + readonly remainingCount: number; +} + +export function isAgentSessionShowMore(obj: unknown): obj is IAgentSessionShowMore { + return (obj as IAgentSessionShowMore)?.showMore === true; +} + export interface IMarshalledAgentSessionContext { readonly $mid: MarshalledId.AgentSessionContext; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 3958daa410f..9950399ce84 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -14,7 +14,7 @@ import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compres import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; +import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionShowMore, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionShowMore, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -50,7 +50,7 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; -export type AgentSessionListItem = IAgentSession | IAgentSessionSection; +export type AgentSessionListItem = IAgentSession | IAgentSessionSection | IAgentSessionShowMore; //#region Agent Session Renderer @@ -656,6 +656,55 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { + + static readonly TEMPLATE_ID = 'agent-session-show-more'; + static readonly HEIGHT = 26; + + readonly templateId = AgentSessionShowMoreRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): IAgentSessionShowMoreTemplate { + const disposables = new DisposableStore(); + + const elements = h( + 'div.agent-session-show-more@container', + [h('span.agent-session-show-more-label@label')] + ); + + container.appendChild(elements.container); + + return { + container: elements.container, + label: elements.label, + disposables, + }; + } + + renderElement(element: ITreeNode, _index: number, template: IAgentSessionShowMoreTemplate): void { + template.label.textContent = localize('agentSessions.showMore', "Show {0} More...", element.element.remainingCount); + } + + renderCompressedElements(): void { + throw new Error('Should never happen since show-more is incompressible'); + } + + disposeElement(): void { } + + disposeTemplate(templateData: IAgentSessionShowMoreTemplate): void { + templateData.disposables.dispose(); + } +} + +//#endregion + export class AgentSessionsListDelegate implements IListVirtualDelegate { static readonly ITEM_HEIGHT = 54; @@ -668,6 +717,10 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate { private static readonly CAPPED_SESSIONS_LIMIT = 3; + static readonly REPOSITORY_GROUP_LIMIT = 5; private readonly _onDidGetChildren = this._register(new Emitter()); readonly onDidGetChildren: Event = this._onDidGetChildren.event; + private readonly _onDidExpandRepositoryGroup = this._register(new Emitter()); + readonly onDidExpandRepositoryGroup: Event = this._onDidExpandRepositoryGroup.event; + + private readonly expandedRepositoryGroups = new Set(); + constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, + private readonly repositoryGroupLimit?: number, ) { super(); } + expandRepositoryGroup(sectionLabel: string): void { + this.expandedRepositoryGroups.add(sectionLabel); + this._onDidExpandRepositoryGroup.fire(); + } + hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean { // Sessions model @@ -798,7 +871,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou return element.sessions.length > 0; } - // Session element + // Session element or show more else { return false; } @@ -838,10 +911,16 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou // Sessions section else if (isAgentSessionSection(element)) { + const limit = this.repositoryGroupLimit; + if (limit && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > limit) { + const visible = element.sessions.slice(0, limit); + const remainingCount = element.sessions.length - limit; + return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; + } return element.sessions; } - // Session element + // Session element or show more else { return []; } @@ -1232,6 +1311,10 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider { }); }); + suite('repositoryGroupLimit', () => { + + test('caps repo group children at limit and appends show-more item', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + assert.ok(section); + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 6); // 5 sessions + 1 show-more + const showMore = children[5]; + assert.ok(isAgentSessionShowMore(showMore)); + assert.strictEqual(showMore.remainingCount, 3); + assert.strictEqual(showMore.sectionLabel, 'vscode'); + }); + + test('does not cap when group has fewer items than limit', () => { + const now = Date.now(); + const sessions = Array.from({ length: 3 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 3); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('expanding a group removes the cap', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + dataSource.expandRepositoryGroup('vscode'); + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap non-repository sections', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const todaySection = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Today) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(todaySection)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + + test('does not cap when repositoryGroupLimit is not set', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter())); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); + }); + suite('getRepositoryName', () => { test('returns metadata.name when owner and name are present', () => { From bb2dcc7750f27c68933f6d0d3dc839a2906834de Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:37:37 -0700 Subject: [PATCH 03/24] Implement repository group capping with "Show More" functionality in agent sessions --- .../agentSessions/agentSessionsFilter.ts | 27 +++++++++++++++++++ .../agentSessions/agentSessionsViewer.ts | 18 ++++++++++--- .../agentSessionsDataSource.test.ts | 20 +++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index eba6abdd656..4a33a06daca 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -56,6 +56,7 @@ const DEFAULT_EXCLUDES: IAgentSessionsFilterExcludes = Object.freeze({ states: [] as const, archived: true as const /* archived are never excluded but toggle between expanded and collapsed */, read: false as const, + repositoryGroupCapped: true as const /* when true, repo groups are capped at a limit with a "show more" item */, }); export class AgentSessionsFilter extends Disposable implements Required { @@ -143,6 +144,7 @@ export class AgentSessionsFilter extends Disposable implements Required { + // Clear expanded state when capping is re-enabled + if (this.filter?.getExcludes().repositoryGroupCapped) { + this.expandedRepositoryGroups.clear(); + } + })); + } } expandRepositoryGroup(sectionLabel: string): void { @@ -911,10 +921,10 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou // Sessions section else if (isAgentSessionSection(element)) { - const limit = this.repositoryGroupLimit; - if (limit && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > limit) { - const visible = element.sessions.slice(0, limit); - const remainingCount = element.sessions.length - limit; + const isCappingEnabled = this.repositoryGroupLimit && this.filter?.getExcludes().repositoryGroupCapped; + if (isCappingEnabled && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > this.repositoryGroupLimit) { + const visible = element.sessions.slice(0, this.repositoryGroupLimit); + const remainingCount = element.sessions.length - this.repositoryGroupLimit; return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; } return element.sessions; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 1124363eb90..9177b5307f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -137,12 +137,13 @@ suite('AgentSessionsDataSource', () => { groupBy?: AgentSessionsGrouping; exclude?: (session: IAgentSession) => boolean; excludeRead?: boolean; + repositoryGroupCapped?: boolean; }): IAgentSessionsFilter { return { onDidChange: Event.None, groupResults: () => options.groupBy, exclude: options.exclude ?? (() => false), - getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }), + getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false, repositoryGroupCapped: options.repositoryGroupCapped ?? true }), isDefault: () => true, reset: () => { }, }; @@ -1075,6 +1076,23 @@ suite('AgentSessionsDataSource', () => { assert.strictEqual(children.length, 8); assert.ok(!children.some(isAgentSessionShowMore)); }); + + test('does not cap when repositoryGroupCapped filter is disabled', () => { + const now = Date.now(); + const sessions = Array.from({ length: 8 }, (_, i) => + createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) + ); + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository, repositoryGroupCapped: false }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter(), 5)); + const model = createMockModel(sessions); + const topLevel = Array.from(dataSource.getChildren(model)); + const section = topLevel.find(item => isAgentSessionSection(item) && item.section === AgentSessionSection.Repository) as IAgentSessionSection; + + const children = Array.from(dataSource.getChildren(section)); + assert.strictEqual(children.length, 8); + assert.ok(!children.some(isAgentSessionShowMore)); + }); }); suite('getRepositoryName', () => { From 424f656151e296e595022ddad84df12c52b1cf44 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:40:18 -0700 Subject: [PATCH 04/24] Update agent session item styling for improved alignment and color --- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 1b8bc367320..094e1fd5984 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -325,8 +325,9 @@ display: flex; align-items: center; font-size: 12px; - color: var(--vscode-textLink-foreground); - padding: 0 12px; + color: rgb(191, 191, 191); + /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ + padding: 0 6px 0 28px; cursor: pointer; &:hover { From 70389077ba6e2ddc3608217adbe9f847edd1bd3c Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:54:37 -0700 Subject: [PATCH 05/24] Enhance "Show More" label styling with hover effect for better visibility --- .../browser/agentSessions/media/agentsessionsviewer.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 094e1fd5984..6eba1eeec41 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -329,8 +329,13 @@ /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ padding: 0 6px 0 28px; cursor: pointer; + text-decoration: none; - &:hover { + .agent-session-show-more-label { + text-decoration: none; + } + + &:hover .agent-session-show-more-label { text-decoration: underline; } } From 59c4a85bf885360dc31b2d6aaecd43bf36335daa Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 14:58:17 -0700 Subject: [PATCH 06/24] Add repository group capping functionality with toggle actions in sessions view --- .../sessions/browser/sessionsViewPane.ts | 61 ++++++++++++++++++- .../agentSessions/agentSessionsFilter.ts | 29 ++------- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 3af5a41ef2a..623b2e5a831 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -45,6 +45,7 @@ const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); const SessionsViewFilterOptionsSubMenu = new MenuId('AgentSessionsViewFilterOptionsSubMenu'); const SessionsViewGroupingContext = new RawContextKey('sessionsView.grouping', AgentSessionsGrouping.Repository); const SessionsViewSortingContext = new RawContextKey('sessionsView.sorting', AgentSessionsSorting.Created); +const IsRepositoryGroupCappedContext = new RawContextKey('sessionsView.isRepositoryGroupCapped', true); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; const SORTING_STORAGE_KEY = 'agentSessions.sorting'; @@ -53,10 +54,12 @@ export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; + private sessionsFilter: AgentSessionsFilter | undefined; private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; private groupingContextKey: IContextKey | undefined; private sortingContextKey: IContextKey | undefined; + private isRepositoryGroupCappedContextKey: IContextKey | undefined; constructor( options: IViewPaneOptions, @@ -93,6 +96,7 @@ export class AgenticSessionsViewPane extends ViewPane { this.groupingContextKey.set(this.currentGrouping); this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); this.sortingContextKey.set(this.currentSorting); + this.isRepositoryGroupCappedContextKey = IsRepositoryGroupCappedContext.bindTo(contextKeyService); } protected override renderBody(parent: HTMLElement): void { @@ -120,7 +124,7 @@ export class AgenticSessionsViewPane extends ViewPane { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); // Sessions Filter (actions go to the nested filter submenu) - const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + const sessionsFilter = this.sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { filterMenuId: SessionsViewFilterOptionsSubMenu, groupResults: () => this.currentGrouping, sortResults: () => this.currentSorting, @@ -131,6 +135,12 @@ export class AgenticSessionsViewPane extends ViewPane { ]), })); + // Sync context key with filter state + this._register(sessionsFilter.onDidChange(() => { + this.isRepositoryGroupCappedContextKey?.set(sessionsFilter.getExcludes().repositoryGroupCapped); + })); + this.isRepositoryGroupCappedContextKey?.set(sessionsFilter.getExcludes().repositoryGroupCapped); + // Sessions section (top, fills available space) const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); @@ -268,6 +278,10 @@ export class AgenticSessionsViewPane extends ViewPane { this.sortingContextKey?.set(this.currentSorting); this.sessionsControl?.update(); } + + toggleRepositoryGroupCapped(): void { + this.sessionsFilter?.setRepositoryGroupCapped(!this.sessionsFilter.getExcludes().repositoryGroupCapped); + } } // Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window @@ -413,6 +427,51 @@ registerAction2(class GroupByTimeAction extends Action2 { } }); +// Toggle between showing top 5 or all sessions per repo group +registerAction2(class ShowTopSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.showTopSessions', + title: localize2('showTopSessions', "Show Top {0} Sessions", AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT), + category: SessionsCategories.Sessions, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '4_cap', + order: 0, + when: IsRepositoryGroupCappedContext.negate(), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.toggleRepositoryGroupCapped(); + } +}); + +registerAction2(class ShowAllSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.showAllSessions', + title: localize2('showAllSessions', "Show All Sessions"), + category: SessionsCategories.Sessions, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '4_cap', + order: 0, + when: IsRepositoryGroupCappedContext, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.toggleRepositoryGroupCapped(); + } +}); + registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 4a33a06daca..5f0e8a74545 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -144,7 +144,6 @@ export class AgentSessionsFilter extends Disposable implements Required Date: Mon, 23 Mar 2026 15:01:58 -0700 Subject: [PATCH 07/24] Refactor repository group capping methods for clarity and consistency in sessions view --- .../contrib/sessions/browser/sessionsViewPane.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 623b2e5a831..d3ee4f4fd39 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -279,8 +279,8 @@ export class AgenticSessionsViewPane extends ViewPane { this.sessionsControl?.update(); } - toggleRepositoryGroupCapped(): void { - this.sessionsFilter?.setRepositoryGroupCapped(!this.sessionsFilter.getExcludes().repositoryGroupCapped); + setRepositoryGroupCapped(capped: boolean): void { + this.sessionsFilter?.setRepositoryGroupCapped(capped); } } @@ -427,18 +427,18 @@ registerAction2(class GroupByTimeAction extends Action2 { } }); -// Toggle between showing top 5 or all sessions per repo group +// Show top N or all sessions per repo group (radio pattern) registerAction2(class ShowTopSessionsAction extends Action2 { constructor() { super({ id: 'sessionsView.showTopSessions', title: localize2('showTopSessions', "Show Top {0} Sessions", AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT), category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext, menu: [{ id: SessionsViewFilterSubMenu, group: '4_cap', order: 0, - when: IsRepositoryGroupCappedContext.negate(), }] }); } @@ -446,7 +446,7 @@ registerAction2(class ShowTopSessionsAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleRepositoryGroupCapped(); + view?.setRepositoryGroupCapped(true); } }); @@ -456,11 +456,11 @@ registerAction2(class ShowAllSessionsAction extends Action2 { id: 'sessionsView.showAllSessions', title: localize2('showAllSessions', "Show All Sessions"), category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext.negate(), menu: [{ id: SessionsViewFilterSubMenu, group: '4_cap', - order: 0, - when: IsRepositoryGroupCappedContext, + order: 1, }] }); } @@ -468,7 +468,7 @@ registerAction2(class ShowAllSessionsAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleRepositoryGroupCapped(); + view?.setRepositoryGroupCapped(false); } }); From 28f5880a55327242092b06eca60961bfcdb97022 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 15:02:25 -0700 Subject: [PATCH 08/24] Enhance "Show More" label styling with !important rule for consistent text decoration --- .../browser/agentSessions/media/agentsessionsviewer.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 6eba1eeec41..828b12262e3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -329,14 +329,14 @@ /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ padding: 0 6px 0 28px; cursor: pointer; - text-decoration: none; + text-decoration: none !important; .agent-session-show-more-label { - text-decoration: none; + text-decoration: none !important; } &:hover .agent-session-show-more-label { - text-decoration: underline; + text-decoration: underline !important; } } From 02633fa5d91f257d0fe6e98063a04dce698236b0 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 15:05:00 -0700 Subject: [PATCH 09/24] Remove redundant text decoration rules for "Show More" label in agent sessions viewer --- .../browser/agentSessions/media/agentsessionsviewer.css | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 828b12262e3..fa0075cac33 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -330,14 +330,6 @@ padding: 0 6px 0 28px; cursor: pointer; text-decoration: none !important; - - .agent-session-show-more-label { - text-decoration: none !important; - } - - &:hover .agent-session-show-more-label { - text-decoration: underline !important; - } } .agent-session-section { From 29359eff779965205e489587261e5ff3c632d43b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:18:42 +0100 Subject: [PATCH 10/24] Accessible View: include file paths for inline references in chat responses (#301565) * Initial plan * Add inline reference handling with file paths to Accessible View When the Accessible View renders chat responses, inline file references (kind: 'inlineReference') were previously skipped entirely. This adds handling to include the file name and full path (and line number for locations) so screen reader users get the same context as the visual UI. - Handle URI references: show "name (path)" - Handle Location references: show "name (path:line)" - Handle workspace symbol references: show "name (path:line)" - Add 3 new tests verifying inline reference rendering Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> * Revert unintended monaco.d.ts changes from gulp compile Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> * Rename ambiguous variable in test (location -> fileLocation) Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> Co-authored-by: Megan Rogge --- .../chatResponseAccessibleView.ts | 26 ++- .../chatResponseAccessibleView.test.ts | 159 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index f20aabab996..e183775db5c 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { stripIcons } from '../../../../../base/common/iconLabels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js'; @@ -21,7 +22,7 @@ import { IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatPullRe import { isResponseVM } from '../../common/model/chatViewModel.js'; import { IToolResultInputOutputDetails, IToolResultOutputDetails, isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js'; import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js'; -import { Location } from '../../../../../editor/common/languages.js'; +import { isLocation, Location } from '../../../../../editor/common/languages.js'; export class ChatResponseAccessibleView implements IAccessibleViewImplementation { readonly priority = 100; @@ -299,6 +300,29 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi } break; } + case 'inlineReference': { + const ref = part.inlineReference; + let text: string; + if (URI.isUri(ref)) { + const name = part.name || basename(ref); + const isFileUri = ref.scheme === 'file'; + const path = isFileUri ? (ref.fsPath || ref.path) : ref.toString(true); + text = name !== path ? `${name} (${path})` : path; + } else if (isLocation(ref)) { + const name = part.name || basename(ref.uri); + const isFileUri = ref.uri.scheme === 'file'; + const basePath = isFileUri ? (ref.uri.fsPath || ref.uri.path) : ref.uri.toString(true); + const location = `${basePath}:${ref.range.startLineNumber}`; + text = `${name} (${location})`; + } else { + // IWorkspaceSymbol + const isFileUri = ref.location.uri.scheme === 'file'; + const basePath = isFileUri ? (ref.location.uri.fsPath || ref.location.uri.path) : ref.location.uri.toString(true); + text = `${ref.name} (${basePath}:${ref.location.range.startLineNumber})`; + } + contentParts.push(text); + break; + } case 'elicitation2': case 'elicitationSerialized': { const title = part.title; diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index ba7821dd358..b4ad1e4bf36 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -486,5 +486,164 @@ suite('ChatResponseAccessibleView', () => { assert.ok(content.includes('Response content')); assert.ok(content.includes('Thinking: Reasoning')); }); + + test('includes file path for URI inline references', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + + const inlineReferenceUri = URI.file('/path/to/index.ts'); + const responseItem = { + response: { + value: [ + { kind: 'markdownContent', content: new MarkdownString('See file ') }, + { kind: 'inlineReference', inlineReference: inlineReferenceUri, name: 'index.ts' }, + { kind: 'markdownContent', content: new MarkdownString(' for details') } + ] + }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + const expectedPath = inlineReferenceUri.fsPath || inlineReferenceUri.path; + assert.ok(content.includes('index.ts')); + assert.ok(content.includes(expectedPath)); + assert.ok(content.includes('See file')); + assert.ok(content.includes('for details')); + }); + + test('includes file path and line number for Location inline references', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + + const fileLocation: Location = { + uri: URI.file('/src/app/main.ts'), + range: new Range(42, 1, 42, 20) + }; + + const responseItem = { + response: { + value: [ + { kind: 'markdownContent', content: new MarkdownString('Error at ') }, + { kind: 'inlineReference', inlineReference: fileLocation, name: 'main.ts' } + ] + }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + assert.ok(content.includes('main.ts')); + assert.ok(content.includes('/src/app/main.ts:42')); + }); + + test('uses basename as name for URI inline references without explicit name', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + + const responseItem = { + response: { + value: [ + { kind: 'inlineReference', inlineReference: URI.file('/workspace/src/utils.ts') } + ] + }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + assert.ok(content.includes('utils.ts')); + assert.ok(content.includes('/workspace/src/utils.ts')); + }); }); }); From 897836653118bd2946c655d70040d37ce4954ea6 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:22:35 -0700 Subject: [PATCH 11/24] Move .claude creation for testing agent harness to postinstall (#304283) * Move .claude creation for testing agent harness to postinstall title says it all * rest --- .claude/CLAUDE.md | 1 - .claude/skills/launch | 1 - .gitignore | 7 +------ build/npm/postinstall.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 8 deletions(-) delete mode 120000 .claude/CLAUDE.md delete mode 120000 .claude/skills/launch diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 120000 index ff807266877..00000000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -../.github/copilot-instructions.md \ No newline at end of file diff --git a/.claude/skills/launch b/.claude/skills/launch deleted file mode 120000 index b41e2b420ad..00000000000 --- a/.claude/skills/launch +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/launch \ No newline at end of file diff --git a/.gitignore b/.gitignore index e5ca4dd32cc..65cb1937168 100644 --- a/.gitignore +++ b/.gitignore @@ -30,21 +30,16 @@ test/componentFixtures/.screenshots/* !test/componentFixtures/.screenshots/baseline/ dist .playwright-cli +.claude/ .agents/agents/*.local.md -.claude/agents/*.local.md .github/agents/*.local.md .agents/agents/*.local.agent.md -.claude/agents/*.local.agent.md .github/agents/*.local.agent.md .agents/hooks/*.local.json -.claude/hooks/*.local.json .github/hooks/*.local.json .agents/instructions/*.local.instructions.md -.claude/instructions/*.local.instructions.md .github/instructions/*.local.instructions.md .agents/prompts/*.local.prompt.md -.claude/prompts/*.local.prompt.md .github/prompts/*.local.prompt.md .agents/skills/.local/ -.claude/skills/.local/ .github/skills/.local/ diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index ae2651cd188..5ab9d682857 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -288,6 +288,37 @@ async function main() { fs.writeFileSync(stateFile, JSON.stringify(_state)); fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); + + // Symlink .claude/ files to their canonical locations to test Claude agent harness + const claudeDir = path.join(root, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + const claudeMdLink = path.join(claudeDir, 'CLAUDE.md'); + if (!fs.existsSync(claudeMdLink)) { + fs.symlinkSync(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink); + log('.', 'Symlinked .claude/CLAUDE.md -> .github/copilot-instructions.md'); + } + + const claudeSkillsLink = path.join(claudeDir, 'skills'); + if (!fs.existsSync(claudeSkillsLink)) { + fs.symlinkSync(path.join('..', '.agents', 'skills'), claudeSkillsLink); + log('.', 'Symlinked .claude/skills -> .agents/skills'); + } + + // Temporary: patch @github/copilot-sdk session.js to fix ESM import + // (missing .js extension on vscode-jsonrpc/node). Fixed upstream in v0.1.32. + // TODO: Remove once @github/copilot-sdk is updated to >=0.1.32 + for (const dir of ['', 'remote']) { + const sessionFile = path.join(root, dir, 'node_modules', '@github', 'copilot-sdk', 'dist', 'session.js'); + if (fs.existsSync(sessionFile)) { + const content = fs.readFileSync(sessionFile, 'utf8'); + const patched = content.replace(/from "vscode-jsonrpc\/node"/g, 'from "vscode-jsonrpc/node.js"'); + if (content !== patched) { + fs.writeFileSync(sessionFile, patched); + log(dir || '.', 'Patched @github/copilot-sdk session.js (vscode-jsonrpc ESM import fix)'); + } + } + } } main().catch(err => { From 5785d325af3291f12b7aaa3c51514b87f57b6d5b Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:45:32 -0700 Subject: [PATCH 12/24] add debounce for working shimmer (#304285) * add debounce for working shimmer * Update src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * revert ccr --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chat/browser/widget/chatListRenderer.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index ea350214eac..1705b9da628 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -114,6 +114,7 @@ import { isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chat const $ = dom.$; const COPILOT_USERNAME = 'GitHub Copilot'; +const WORKING_CAUGHT_UP_DEBOUNCE_MS = 50; export interface IChatListItemTemplate { currentElement?: ChatTreeItem; @@ -1067,7 +1068,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || @@ -1081,6 +1082,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer= WORKING_CAUGHT_UP_DEBOUNCE_MS; + } + private getChatFileChangesSummaryPart(element: IChatResponseViewModel): IChatChangesSummaryPart | undefined { if (!this.shouldShowFileChangesSummary(element)) { From c3231e5f45a5c3500aa759d1870c8abb8c3cbe08 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:46:01 -0700 Subject: [PATCH 13/24] chat customizations: fix clipping (#304281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: re-layout list when toggling browse mode in MCP/plugin widgets When toggling browse mode, the back link appears/disappears but layout() was never re-called, causing the list height to not account for the changed chrome. This clipped the last entry. Cache the last layout dimensions and re-call layout() after toggling browse mode. Fixes https://github.com/microsoft/vscode/issues/304139 * fix: register IProductService in AI customization component fixtures The AICustomizationListWidget recently added a dependency on IProductService but the component fixtures were not updated, causing all AI customization fixtures to fail with 'depends on UNKNOWN service productService'. * fix: address PR review — remove manual layout() in fixtures, fix null! in ParsedPromptFile --- .../chat-customizations-editor/SKILL.md | 47 +++ .../browser/aiCustomization/mcpListWidget.ts | 9 + .../aiCustomization/pluginListWidget.ts | 9 + .../aiCustomizationListWidget.fixture.ts | 2 + ...aiCustomizationManagementEditor.fixture.ts | 394 +++++++++++++++++- 5 files changed, 448 insertions(+), 13 deletions(-) diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index b90fb5b46cf..16f1d82da3a 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -34,6 +34,53 @@ Principle: the UI widgets read everything from the descriptor — no harness-spe Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. +### Screenshotting specific tabs + +The management editor fixture supports a `selectedSection` option to render any tab. Each tab has Dark/Light variants auto-generated by `defineThemedFixtureGroup`. + +**Available fixture IDs** (use with `mcp_component-exp_screenshot`): + +| Fixture ID pattern | Tab shown | +|---|---| +| `chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/{Dark,Light}` | Agents | +| `chat/aiCustomizations/aiCustomizationManagementEditor/SkillsTab/{Dark,Light}` | Skills | +| `chat/aiCustomizations/aiCustomizationManagementEditor/InstructionsTab/{Dark,Light}` | Instructions | +| `chat/aiCustomizations/aiCustomizationManagementEditor/HooksTab/{Dark,Light}` | Hooks | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PromptsTab/{Dark,Light}` | Prompts | +| `chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/{Dark,Light}` | MCP Servers | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/{Dark,Light}` | Plugins | +| `chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/{Dark,Light}` | Default (Agents, Local harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/CliHarness/{Dark,Light}` | Default (Agents, CLI harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/ClaudeHarness/{Dark,Light}` | Default (Agents, Claude harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/Sessions/{Dark,Light}` | Sessions window variant | + +**Adding a new tab fixture:** Add a variant to the `defineThemedFixtureGroup` in `aiCustomizationManagementEditor.fixture.ts`: +```typescript +MyNewTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.MySection, + }), +}), +``` + +The `selectedSection` calls `editor.selectSectionById()` after `setInput`, which navigates to the specified tab and re-layouts. + +### Populating test data + +Each customization type requires its own mock path in `createMockPromptsService`: +- **Agents** — `getCustomAgents()` returns agent objects +- **Skills** — `findAgentSkills()` returns `IAgentSkill[]` +- **Prompts** — `getPromptSlashCommands()` returns `IChatPromptSlashCommand[]` +- **Instructions/Hooks** — `listPromptFiles()` filtered by `PromptsType` +- **MCP Servers** — `mcpWorkspaceServers`/`mcpUserServers` arrays passed to `IMcpWorkbenchService` mock +- **Plugins** — `IPluginMarketplaceService.installedPlugins` and `IAgentPluginService.plugins` observables + +All test data lives in `allFiles` (prompt-based items) and the `mcpWorkspace/UserServers` arrays. Add enough items per category (8+) to invoke scrolling. + +### Running unit tests + ```bash ./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" npm run compile-check-ts-native && npm run valid-layers-check diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 3ccfc1cc3ff..a4b197e51e1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -382,6 +382,8 @@ export class McpListWidget extends Disposable { private galleryServers: IWorkbenchMcpServer[] = []; private searchQuery: string = ''; private browseMode: boolean = false; + private lastHeight: number = 0; + private lastWidth: number = 0; private readonly collapsedGroups = new Set(); private galleryCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -608,6 +610,11 @@ export class McpListWidget extends Disposable { this.galleryServers = []; this.filterServers(); } + + // Re-layout to account for the back link height change + if (this.lastHeight > 0) { + this.layout(this.lastHeight, this.lastWidth); + } } private async queryGallery(): Promise { @@ -882,6 +889,8 @@ export class McpListWidget extends Disposable { * Layouts the widget. */ layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index b5be4d09f9e..a51a9f6fb0e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -298,6 +298,8 @@ export class PluginListWidget extends Disposable { private marketplaceItems: IMarketplacePluginItem[] = []; private searchQuery: string = ''; private browseMode: boolean = false; + private lastHeight: number = 0; + private lastWidth: number = 0; private readonly collapsedGroups = new Set(); private marketplaceCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -528,6 +530,11 @@ export class PluginListWidget extends Disposable { this.marketplaceItems = []; this.filterPlugins(); } + + // Re-layout to account for the back link height change + if (this.lastHeight > 0) { + this.layout(this.lastHeight, this.lastWidth); + } } private async queryMarketplace(): Promise { @@ -701,6 +708,8 @@ export class PluginListWidget extends Disposable { } layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index 4fa628e23b8..914726763fd 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -19,6 +19,7 @@ import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTyp import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; import { ParsedPromptFile, PromptHeader } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; @@ -155,6 +156,7 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; }()); + reg.defineInstance(IProductService, new class extends mock() { }()); reg.defineInstance(IPathService, new class extends mock() { override readonly defaultUriScheme = 'file'; override userHome(): URI; diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts index f57c42abd49..465246d562f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -14,7 +14,7 @@ import { constObservable, observableValue } from '../../../../base/common/observ import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; @@ -23,22 +23,28 @@ import { IMarkdownRendererService } from '../../../../platform/markdown/browser/ import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IAgentSkill, IChatPromptSlashCommand } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; -import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; -import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { IAgentPluginService, IAgentPlugin } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginSourceKind } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { MarketplaceReferenceKind } from '../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js'; import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { ContributionEnablementState } from '../../../contrib/chat/common/enablement.js'; import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js'; import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { McpListWidget } from '../../../contrib/chat/browser/aiCustomization/mcpListWidget.js'; +import { PluginListWidget } from '../../../contrib/chat/browser/aiCustomization/pluginListWidget.js'; +import { IIterativePager } from '../../../../base/common/paging.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; // Ensure theme colors & widget CSS are loaded @@ -98,8 +104,33 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IRes return new ParsedPromptFile(uri, header as never); } override async getSourceFolders() { return [] as never[]; } - override async findAgentSkills() { return [] as never[]; } - override async getPromptSlashCommands() { return [] as never[]; } + override async findAgentSkills(): Promise { + return files.filter(f => f.type === PromptsType.skill).map(f => ({ + uri: f.uri, + storage: f.storage, + name: f.name ?? 'skill', + description: f.description, + disableModelInvocation: false, + userInvocable: true, + when: undefined, + })); + } + override async getPromptSlashCommands(): Promise { + const promptFiles = files.filter(f => f.type === PromptsType.prompt); + const commands = await Promise.all(promptFiles.map(async f => { + const promptPath = { uri: f.uri, storage: f.storage, type: f.type }; + const parsedPromptFile = await this.parseNew(f.uri, CancellationToken.None); + return { + name: f.name ?? 'prompt', + description: f.description, + argumentHint: undefined, + promptPath: promptPath as IChatPromptSlashCommand['promptPath'], + parsedPromptFile, + when: undefined, + }; + })); + return commands; + } }(); } @@ -138,24 +169,71 @@ function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScop // ============================================================================ const allFiles: IFixtureFile[] = [ - // Copilot instructions + // Instructions — workspace { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/workspace/.github/instructions/security.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { uri: URI.file('/workspace/.github/instructions/accessibility.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Accessibility', description: 'WCAG compliance guidelines', applyTo: '**/*.tsx' }, + { uri: URI.file('/workspace/.github/instructions/api-design.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'API Design', description: 'REST API design conventions' }, + { uri: URI.file('/workspace/.github/instructions/performance.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Performance', description: 'Performance optimization rules', applyTo: 'src/core/**' }, + { uri: URI.file('/workspace/.github/instructions/error-handling.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Error Handling', description: 'Error handling patterns' }, + { uri: URI.file('/workspace/.github/instructions/database.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Database', description: 'Database migration and query patterns', applyTo: 'src/db/**' }, + // Instructions — user { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, - // Claude rules + { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'Strict TypeScript conventions' }, + { uri: URI.file('/home/dev/.copilot/instructions/commit-messages.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Commit Messages', description: 'Conventional commit format' }, + // Instructions — Claude rules { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, - // Agents + // Agents — workspace { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, - { uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, - // Skills + { uri: URI.file('/workspace/.github/agents/tester.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Tester', description: 'Test generation and validation' }, + { uri: URI.file('/workspace/.github/agents/refactorer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Refactorer', description: 'Code refactoring specialist' }, + { uri: URI.file('/workspace/.github/agents/security-auditor.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Security Auditor', description: 'Security vulnerability scanner' }, + { uri: URI.file('/workspace/.github/agents/api-designer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'API Designer', description: 'REST and GraphQL API design' }, + { uri: URI.file('/workspace/.github/agents/performance-tuner.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Performance Tuner', description: 'Performance profiling and optimization' }, + // Agents — user + { uri: URI.file('/home/dev/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, + { uri: URI.file('/home/dev/.copilot/agents/debugger.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Debugger', description: 'Interactive debugging assistant' }, + { uri: URI.file('/home/dev/.copilot/agents/nls-helper.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'NLS Helper', description: 'Natural language searching code for clarity' }, + // Skills — workspace { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, - // Prompts + { uri: URI.file('/workspace/.github/skills/unit-tests/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Unit Tests', description: 'Test generation and runner integration' }, + { uri: URI.file('/workspace/.github/skills/ci-fix/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'CI Fix', description: 'Diagnose and fix CI failures' }, + { uri: URI.file('/workspace/.github/skills/migration/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Migration', description: 'Database migration generation' }, + { uri: URI.file('/workspace/.github/skills/accessibility/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Accessibility', description: 'ARIA labels and keyboard navigation' }, + { uri: URI.file('/workspace/.github/skills/docker/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Docker', description: 'Dockerfile and compose generation' }, + { uri: URI.file('/workspace/.github/skills/api-docs/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'API Docs', description: 'OpenAPI spec generation' }, + // Skills — user + { uri: URI.file('/home/dev/.copilot/skills/git-workflow/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Git Workflow', description: 'Branch and PR workflows' }, + { uri: URI.file('/home/dev/.copilot/skills/code-review/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Code Review', description: 'Structured code review checklist' }, + // Prompts — workspace { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, + { uri: URI.file('/workspace/.github/prompts/fix-bug.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Fix Bug', description: 'Diagnose and fix a bug from issue' }, + { uri: URI.file('/workspace/.github/prompts/write-tests.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Write Tests', description: 'Generate unit tests for selection' }, + { uri: URI.file('/workspace/.github/prompts/add-docs.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Add Docs', description: 'Add JSDoc comments to functions' }, + { uri: URI.file('/workspace/.github/prompts/optimize.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Optimize', description: 'Optimize code for performance' }, + { uri: URI.file('/workspace/.github/prompts/convert-to-ts.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Convert to TS', description: 'Convert JavaScript to TypeScript' }, + { uri: URI.file('/workspace/.github/prompts/summarize-pr.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Summarize PR', description: 'Generate PR description from diff' }, + // Prompts — user + { uri: URI.file('/home/dev/.copilot/prompts/translate.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Translate', description: 'Translate strings for i18n' }, + { uri: URI.file('/home/dev/.copilot/prompts/commit-msg.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Commit Message', description: 'Generate conventional commit' }, + // Hooks — workspace + { uri: URI.file('/workspace/.github/hooks/pre-commit.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Commit Lint', description: 'Run linting before commit' }, + { uri: URI.file('/workspace/.github/hooks/post-save.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Save Format', description: 'Auto-format on save' }, + { uri: URI.file('/workspace/.github/hooks/on-test-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Test Failure', description: 'Suggest fix when tests fail' }, + { uri: URI.file('/workspace/.github/hooks/pre-push.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Push Check', description: 'Run type-check before push' }, + { uri: URI.file('/workspace/.github/hooks/post-create.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Create', description: 'Initialize boilerplate for new files' }, + { uri: URI.file('/workspace/.github/hooks/on-error.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Error', description: 'Log and report unhandled errors' }, + { uri: URI.file('/workspace/.github/hooks/post-tool-call.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post Tool Call', description: 'Echo confirmation after each tool call' }, + { uri: URI.file('/workspace/.github/hooks/on-build-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Build Failure', description: 'Auto-diagnose build errors' }, + // Hooks — user + { uri: URI.file('/home/dev/.copilot/hooks/daily-summary.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Daily Summary', description: 'Generate daily work summary' }, + { uri: URI.file('/home/dev/.copilot/hooks/backup-changes.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Backup Changes', description: 'Auto-stash uncommitted changes' }, ]; const agentInstructions: IResolvedAgentFile[] = [ @@ -167,9 +245,17 @@ const agentInstructions: IResolvedAgentFile[] = [ const mcpWorkspaceServers = [ makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), + makeLocalMcpServer('mcp-redis', 'Redis', LocalMcpServerScope.Workspace, 'In-memory data store'), + makeLocalMcpServer('mcp-docker', 'Docker', LocalMcpServerScope.Workspace, 'Container management'), + makeLocalMcpServer('mcp-slack', 'Slack', LocalMcpServerScope.Workspace, 'Team messaging'), + makeLocalMcpServer('mcp-jira', 'Jira', LocalMcpServerScope.Workspace, 'Issue tracking'), + makeLocalMcpServer('mcp-aws', 'AWS', LocalMcpServerScope.Workspace, 'Amazon Web Services'), + makeLocalMcpServer('mcp-graphql', 'GraphQL', LocalMcpServerScope.Workspace, 'GraphQL API gateway'), ]; const mcpUserServers = [ makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), + makeLocalMcpServer('mcp-filesystem', 'Filesystem', LocalMcpServerScope.User, 'Local file operations'), + makeLocalMcpServer('mcp-puppeteer', 'Puppeteer', LocalMcpServerScope.User, 'Browser automation'), ]; const mcpRuntimeServers = [ { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, @@ -180,6 +266,7 @@ interface IRenderEditorOptions { readonly isSessionsWindow?: boolean; readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; + readonly selectedSection?: AICustomizationManagementSection; } // ============================================================================ @@ -277,7 +364,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeInputs = Event.None; }()); reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable([]); + override readonly plugins = constObservable(installedPlugins); override readonly enablementModel = undefined as never; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { @@ -285,6 +372,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeMarketplaces = Event.None; }()); reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + reg.defineInstance(IProductService, new class extends mock() { }()); }, }); @@ -300,6 +388,210 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } catch { // Expected in fixture — some services are partially mocked } + + if (options.selectedSection) { + editor.selectSectionById(options.selectedSection); + editor.layout(new Dimension(width, height)); + } +} + +// ============================================================================ +// MCP Browse Mode — standalone widget with gallery results +// ============================================================================ + +function makeGalleryServer(id: string, label: string, description: string, publisher: string): IWorkbenchMcpServer { + const galleryStub = new class extends mock>() { }(); + return new class extends mock() { + override readonly id = id; + override readonly name = id; + override readonly label = label; + override readonly description = description; + override readonly publisherDisplayName = publisher; + override readonly installState = McpServerInstallState.Uninstalled; + override readonly gallery = galleryStub; + override readonly local = undefined; + }(); +} + +const galleryServers = [ + makeGalleryServer('gallery-postgres', 'PostgreSQL', 'Access PostgreSQL databases with schema inspection and query tools', 'Microsoft'), + makeGalleryServer('gallery-github', 'GitHub', 'Repository management, issues, pull requests, and code search', 'GitHub'), + makeGalleryServer('gallery-slack', 'Slack', 'Send messages, manage channels, and search workspace history', 'Slack Technologies'), + makeGalleryServer('gallery-docker', 'Docker', 'Container lifecycle management and image operations', 'Docker Inc'), + makeGalleryServer('gallery-filesystem', 'Filesystem', 'Read, write, and navigate local files and directories', 'Microsoft'), + makeGalleryServer('gallery-brave', 'Brave Search', 'Web and local search powered by the Brave Search API', 'Brave Software'), + makeGalleryServer('gallery-puppeteer', 'Puppeteer', 'Browser automation with screenshots, navigation, and form filling', 'Google'), + makeGalleryServer('gallery-memory', 'Memory', 'Knowledge graph for persistent memory across conversations', 'Microsoft'), + makeGalleryServer('gallery-fetch', 'Fetch', 'Retrieve and convert web content to markdown for analysis', 'Microsoft'), + makeGalleryServer('gallery-sentry', 'Sentry', 'Error monitoring, issue tracking, and performance tracing', 'Sentry'), + makeGalleryServer('gallery-sqlite', 'SQLite', 'Query and manage SQLite databases with schema exploration', 'Community'), + makeGalleryServer('gallery-redis', 'Redis', 'In-memory data store operations and key management', 'Redis Ltd'), +]; + +async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise { + const width = 650; + const height = 500; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local: IWorkbenchMcpServer[] = []; + override async queryLocal() { return []; } + override canInstall() { return true as const; } + override async queryGallery(): Promise> { + return { + firstPage: { items: galleryServers, hasMore: false }, + async getNextPage() { return { items: [], hasMore: false }; }, + }; + } + }()); + reg.defineInstance(IMcpService, new class extends mock() { + override readonly servers = constObservable([] as never[]); + }()); + reg.defineInstance(IMcpRegistry, new class extends mock() { + override readonly collections = constObservable([]); + override readonly delegates = constObservable([]); + override readonly onDidChangeInputs = Event.None; + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([]); + }()); + reg.defineInstance(IDialogService, new class extends mock() { }()); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter() { + return { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; + } + }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension]); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(McpListWidget) + ); + ctx.container.appendChild(widget.element); + widget.layout(height, width); + + // Click the Browse Marketplace button to enter browse mode + const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; + browseButton?.click(); + + // Wait for the gallery query to resolve + await new Promise(resolve => setTimeout(resolve, 50)); +} + +// ============================================================================ +// Plugin Browse Mode — standalone widget with marketplace results +// ============================================================================ + +function makeInstalledPlugin(name: string, uri: URI, enabled: boolean): IAgentPlugin { + return new class extends mock() { + override readonly uri = uri; + override readonly label = name; + override readonly enablement = constObservable(enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); + override readonly hooks = constObservable([]); + override readonly commands = constObservable([]); + override readonly skills = constObservable([]); + override readonly agents = constObservable([]); + override readonly instructions = constObservable([]); + override readonly mcpServerDefinitions = constObservable([]); + override remove() { } + }(); +} + +const installedPlugins: IAgentPlugin[] = [ + makeInstalledPlugin('Linear', URI.file('/workspace/.copilot/plugins/linear'), true), + makeInstalledPlugin('Sentry', URI.file('/workspace/.copilot/plugins/sentry'), true), + makeInstalledPlugin('Datadog', URI.file('/workspace/.copilot/plugins/datadog'), true), + makeInstalledPlugin('Notion', URI.file('/workspace/.copilot/plugins/notion'), true), + makeInstalledPlugin('Confluence', URI.file('/workspace/.copilot/plugins/confluence'), true), + makeInstalledPlugin('PagerDuty', URI.file('/workspace/.copilot/plugins/pagerduty'), false), + makeInstalledPlugin('LaunchDarkly', URI.file('/workspace/.copilot/plugins/launchdarkly'), true), + makeInstalledPlugin('CircleCI', URI.file('/workspace/.copilot/plugins/circleci'), true), + makeInstalledPlugin('Vercel', URI.file('/workspace/.copilot/plugins/vercel'), false), + makeInstalledPlugin('Supabase', URI.file('/workspace/.copilot/plugins/supabase'), true), +]; + +function makeMarketplacePlugin(name: string, description: string, repo: string): IMarketplacePlugin { + return { + name, + description, + version: '1.0.0', + source: repo, + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: `example/${repo}` }, + marketplace: 'copilot', + marketplaceReference: { rawValue: `example/${repo}`, displayLabel: repo, cloneUrl: `https://github.com/example/${repo}.git`, canonicalId: `github:example/${repo}`, cacheSegments: ['example', repo], kind: MarketplaceReferenceKind.GitHubShorthand }, + marketplaceType: MarketplaceType.Copilot, + }; +} + +const marketplacePlugins: IMarketplacePlugin[] = [ + makeMarketplacePlugin('Linear', 'Issue tracking and project management integration', 'linear-plugin'), + makeMarketplacePlugin('Sentry', 'Error monitoring and performance tracing', 'sentry-plugin'), + makeMarketplacePlugin('Datadog', 'Observability and monitoring dashboards', 'datadog-plugin'), + makeMarketplacePlugin('Notion', 'Knowledge base and documentation management', 'notion-plugin'), + makeMarketplacePlugin('Figma', 'Design system inspection and asset export', 'figma-plugin'), + makeMarketplacePlugin('Stripe', 'Payment processing and billing management', 'stripe-plugin'), + makeMarketplacePlugin('Twilio', 'Communication APIs for SMS and voice', 'twilio-plugin'), + makeMarketplacePlugin('Auth0', 'Identity and access management', 'auth0-plugin'), + makeMarketplacePlugin('Algolia', 'Search and discovery API integration', 'algolia-plugin'), + makeMarketplacePlugin('LaunchDarkly', 'Feature flag management and experimentation', 'launchdarkly-plugin'), + makeMarketplacePlugin('PlanetScale', 'Serverless MySQL database management', 'planetscale-plugin'), + makeMarketplacePlugin('Vercel', 'Deployment and preview environments', 'vercel-plugin'), +]; + +async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise { + const width = 650; + const height = 500; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([] as readonly IAgentPlugin[]); + override readonly enablementModel = undefined!; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + override async fetchMarketplacePlugins() { return marketplacePlugins; } + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { + override getPluginInstallUri() { return URI.file('/dev/null'); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(PluginListWidget) + ); + ctx.container.appendChild(widget.element); + widget.layout(height, width); + + // Click the Browse Marketplace button to enter browse mode + const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; + browseButton?.click(); + + // Wait for the marketplace query to resolve + await new Promise(resolve => setTimeout(resolve, 50)); } // ============================================================================ @@ -350,4 +642,80 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { ], }), }), + + // MCP Servers tab with many servers to verify scrollable list layout + McpServersTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.McpServers, + }), + }), + + // Agents tab — workspace and user agents, scrollable + AgentsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Agents, + }), + }), + + // Skills tab — workspace and user skills, scrollable + SkillsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Skills, + }), + }), + + // Instructions tab — many instructions with applyTo patterns, scrollable + InstructionsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Instructions, + }), + }), + + // Hooks tab — workspace and user hooks, scrollable + HooksTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Hooks, + }), + }), + + // Prompts tab — workspace and user prompts, scrollable + PromptsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Prompts, + }), + }), + + // Plugins tab + PluginsTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Plugins, + }), + }), + + // MCP browse/marketplace mode — standalone widget with gallery results, scrollable + // Verifies fix for https://github.com/microsoft/vscode/issues/304139 + McpBrowseMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderMcpBrowseMode, + }), + + // Plugin browse/marketplace mode — standalone widget with marketplace results, scrollable + PluginBrowseMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: renderPluginBrowseMode, + }), }); From 5a791c66df14380b290733af553722f3497c95d2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 23 Mar 2026 18:57:03 -0400 Subject: [PATCH 14/24] Use bracketed paste mode for `run_in_terminal` tool (#304268) fixes #296955 --- src/vs/workbench/contrib/terminal/browser/terminal.ts | 2 +- .../contrib/terminal/browser/terminalInstance.ts | 11 ++++++----- .../browser/executeStrategy/basicExecuteStrategy.ts | 2 +- .../browser/executeStrategy/noneExecuteStrategy.ts | 2 +- .../browser/executeStrategy/richExecuteStrategy.ts | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index b54f86b3ad3..8bfd7b0d910 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1154,7 +1154,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { */ sendPath(originalPath: string | URI, shouldExecute: boolean): Promise; - runCommand(command: string, shouldExecute?: boolean, commandId?: string): Promise; + runCommand(command: string, shouldExecute?: boolean, commandId?: string, bracketedPasteMode?: boolean): Promise; /** * Takes a path and returns the properly escaped path to send to a given shell. On Windows, this diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index fb793b76ba8..ca7d1e3edeb 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -974,7 +974,7 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { }); } - async runCommand(commandLine: string, shouldExecute: boolean, commandId?: string): Promise { + async runCommand(commandLine: string, shouldExecute: boolean, commandId?: string, forceBracketedPasteMode?: boolean): Promise { let commandDetection = this.capabilities.get(TerminalCapability.CommandDetection); const siInjectionEnabled = this._configurationService.getValue(TerminalSettingId.ShellIntegrationEnabled) === true; const timeoutMs = getShellIntegrationTimeout( @@ -1020,8 +1020,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { // is being evaluated await timeout(100); } - // Use bracketed paste mode only when not running the command - await this.sendText(commandLine, shouldExecute, !shouldExecute); + // By default, use bracketed paste mode only when not running the command; callers can override + // this by explicitly enabling it via the bracketedPasteMode argument. + await this.sendText(commandLine, shouldExecute, !shouldExecute || forceBracketedPasteMode); } detachFromElement(): void { @@ -1344,10 +1345,10 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.focus(force); } - async sendText(text: string, shouldExecute: boolean, bracketedPasteMode?: boolean): Promise { + async sendText(text: string, shouldExecute: boolean, forceBracketedPasteMode?: boolean): Promise { // Apply bracketed paste sequences if the terminal has the mode enabled, this will prevent // the text from triggering keybindings and ensure new lines are handled properly - if (bracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { + if (forceBracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { text = `\x1b[200~${text}\x1b[201~`; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index 6de862cb380..c99f166ad62 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -129,7 +129,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // occurs. this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); - this._instance.sendText(commandLine, true); + this._instance.sendText(commandLine, true, true); // Wait for the next end execution event - note that this may not correspond to the actual // execution requested diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index f72379411ca..c1f1b210253 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -84,7 +84,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); const startLine = this._startMarker.value?.line; - this._instance.sendText(commandLine, true); + this._instance.sendText(commandLine, true, true); // Wait for the cursor to move past the command line before // starting idle detection. Without this, the idle poll may diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index 8d97c97f859..cd8ac8edaf2 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -85,7 +85,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // Execute the command this._log(`Executing command line \`${commandLine}\``); markerRecreation.dispose(); - this._instance.runCommand(commandLine, true, commandId); + this._instance.runCommand(commandLine, true, commandId, true); // Wait for the terminal to idle this._log('Waiting for done event'); From 3d843ae9b6bbc1b0fa3bb510f3c502f742eba063 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Tue, 24 Mar 2026 00:16:23 +0100 Subject: [PATCH 15/24] Flatten sandbox network settings (#304287) Flatten sandbox network settings (#304270) Split the nested `chat.tools.terminal.sandbox.network` object setting into three flat settings: - `chat.tools.terminal.sandbox.network.allowedDomains` - `chat.tools.terminal.sandbox.network.deniedDomains` - `chat.tools.terminal.sandbox.network.allowTrustedDomains` Add configuration migration to automatically migrate existing values. The old setting is preserved as deprecated with a pointer to the new ones. Fixes #304232 --- .../terminal/common/terminalConfiguration.ts | 19 ++++++ .../terminal/terminalContribExports.ts | 4 ++ .../terminal.chatAgentTools.contribution.ts | 4 +- .../browser/tools/sandboxOutputAnalyzer.ts | 2 +- .../terminalChatAgentToolsConfiguration.ts | 64 +++++++++++-------- .../chatAgentTools/common/terminalSandbox.ts | 11 ---- .../common/terminalSandboxService.ts | 24 ++++--- .../browser/terminalSandboxService.test.ts | 64 +++++++------------ .../runInTerminalTool.test.ts | 4 +- 9 files changed, 103 insertions(+), 93 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 2d1dd15ce52..be681d6ef06 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -734,3 +734,22 @@ Registry.as(WorkbenchExtensions.ConfigurationMi return configurationKeyValuePairs; } }]); + +Registry.as(WorkbenchExtensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: TerminalContribSettingId.DeprecatedTerminalSandboxNetwork, + migrateFn: (value: { allowedDomains?: string[]; deniedDomains?: string[]; allowTrustedDomains?: boolean }, valueAccessor) => { + const configurationKeyValuePairs: ConfigurationKeyValuePairs = []; + if (value?.allowedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkAllowedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkAllowedDomains, { value: value.allowedDomains }]); + } + if (value?.deniedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkDeniedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkDeniedDomains, { value: value.deniedDomains }]); + } + if (value?.allowTrustedDomains !== undefined && valueAccessor(TerminalContribSettingId.TerminalSandboxNetworkAllowTrustedDomains) === undefined) { + configurationKeyValuePairs.push([TerminalContribSettingId.TerminalSandboxNetworkAllowTrustedDomains, { value: value.allowTrustedDomains }]); + } + configurationKeyValuePairs.push([TerminalContribSettingId.DeprecatedTerminalSandboxNetwork, { value: undefined }]); + return configurationKeyValuePairs; + } + }]); diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index a24b204a899..babbac632fc 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -46,6 +46,10 @@ export const enum TerminalContribSettingId { EnableAutoApprove = TerminalChatAgentToolsSettingId.EnableAutoApprove, ShellIntegrationTimeout = TerminalChatAgentToolsSettingId.ShellIntegrationTimeout, OutputLocation = TerminalChatAgentToolsSettingId.OutputLocation, + DeprecatedTerminalSandboxNetwork = TerminalChatAgentToolsSettingId.DeprecatedTerminalSandboxNetwork, + TerminalSandboxNetworkAllowedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, + TerminalSandboxNetworkDeniedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, + TerminalSandboxNetworkAllowTrustedDomains = TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, } // HACK: Export some context key strings from `terminalContrib/` that are depended upon elsewhere. diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index 5d1e90193ab..e8bd2742546 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -142,7 +142,9 @@ export class ChatAgentToolsContribution extends Disposable implements IWorkbench this._register(this._configurationService.onDidChangeConfiguration(e => { if ( e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled) || - e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ) { this._registerRunInTerminalTool(); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 8b1687f1d35..684fcf2051e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -37,7 +37,7 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer ? 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:' : 'Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:'; return `${prefix} -- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. +- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains}. - Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. Here is the output of the command:\n`; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e55ce632fd1..221f2e29d20 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -21,7 +21,9 @@ export const enum TerminalChatAgentToolsSettingId { AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts', OutputLocation = 'chat.tools.terminal.outputLocation', TerminalSandboxEnabled = 'chat.tools.terminal.sandbox.enabled', - TerminalSandboxNetwork = 'chat.tools.terminal.sandbox.network', + TerminalSandboxNetworkAllowedDomains = 'chat.tools.terminal.sandbox.network.allowedDomains', + TerminalSandboxNetworkDeniedDomains = 'chat.tools.terminal.sandbox.network.deniedDomains', + TerminalSandboxNetworkAllowTrustedDomains = 'chat.tools.terminal.sandbox.network.allowTrustedDomains', TerminalSandboxLinuxFileSystem = 'chat.tools.terminal.sandbox.linuxFileSystem', TerminalSandboxMacFileSystem = 'chat.tools.terminal.sandbox.macFileSystem', PreventShellHistory = 'chat.tools.terminal.preventShellHistory', @@ -32,6 +34,7 @@ export const enum TerminalChatAgentToolsSettingId { TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', TerminalProfileWindows = 'chat.tools.terminal.terminalProfile.windows', + DeprecatedTerminalSandboxNetwork = 'chat.tools.terminal.sandbox.network', DeprecatedAutoApproveCompatible = 'chat.agent.terminal.autoApprove', DeprecatedAutoApprove1 = 'chat.agent.terminal.allowList', DeprecatedAutoApprove2 = 'chat.agent.terminal.denyList', @@ -531,33 +534,26 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + const allowedDomainsSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) ?? []; + const deniedDomainsSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) ?? []; + const allowTrustedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ?? false; const linuxFileSystemSetting = this._os === OperatingSystem.Linux ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem) ?? {} : {}; @@ -201,15 +204,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const linuxAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite); const macAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); - let allowedDomains = networkSetting.allowedDomains ?? []; - if (networkSetting.allowTrustedDomains) { + let allowedDomains = allowedDomainsSetting; + if (allowTrustedDomains) { allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } const sandboxSettings = { network: { allowedDomains, - deniedDomains: networkSetting.deniedDomains ?? [] + deniedDomains: deniedDomainsSetting }, filesystem: { denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, @@ -279,14 +282,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } public getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { - const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; - let allowedDomains = networkSetting.allowedDomains ?? []; - if (networkSetting.allowTrustedDomains) { + let allowedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains) ?? []; + const deniedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains) ?? []; + const allowTrustedDomains = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains) ?? false; + if (allowTrustedDomains) { allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } return { allowedDomains, - deniedDomains: networkSetting.deniedDomains ?? [] + deniedDomains }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 33d74053c2b..a85db3988f2 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -167,11 +167,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { // Setup default configuration configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: false - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, false); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); @@ -191,11 +189,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should filter out sole wildcard (*) from trusted domains', async () => { // Setup: Enable allowTrustedDomains and add * to trusted domains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -211,11 +207,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should allow wildcards with domains like *.github.com', async () => { // Setup: Enable allowTrustedDomains and add *.github.com - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*.github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -232,11 +226,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should combine trusted domains with configured allowedDomains, filtering out *', async () => { // Setup: Enable allowTrustedDomains with multiple domains including * - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*', '*.github.com', 'microsoft.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -256,11 +248,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should not include trusted domains when allowTrustedDomains is false', async () => { // Setup: Disable allowTrustedDomains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: false - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, false); trustedDomainService.trustedDomains = ['*', '*.github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -277,11 +267,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should deduplicate domains when combining sources', async () => { // Setup: Same domain in both sources - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['github.com', '*.github.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['github.com', '*.github.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*.github.com', 'github.com']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -299,11 +287,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should handle empty trusted domains list', async () => { // Setup: Empty trusted domains - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: ['example.com'], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, ['example.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = []; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); @@ -320,11 +306,9 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { test('should handle only * in trusted domains', async () => { // Setup: Only * in trusted domains (edge case) - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { - allowedDomains: [], - deniedDomains: [], - allowTrustedDomains: true - }); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkDeniedDomains, []); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowTrustedDomains, true); trustedDomainService.trustedDomains = ['*']; const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 6aab2ceb354..607bf6a7925 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -1977,8 +1977,8 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { // Fire network config change configurationService.onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, - affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetwork]), + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetworkAllowedDomains]), source: ConfigurationTarget.USER, change: null!, }); From 184c7f1631f7d5e415f7d7b04bc4ce7e6d407fc7 Mon Sep 17 00:00:00 2001 From: Tay Date: Mon, 23 Mar 2026 16:17:29 -0700 Subject: [PATCH 16/24] fix: destroy read streams to prevent file descriptor leaks (#303395) * fix: destroy read streams to prevent file descriptor leaks Two stream leak fixes found via static analysis of the control flow graph: 1. crypto.ts: createReadStream() used for checksum computation was never explicitly destroyed. The stream was piped to a hash and event listeners were removed in the done() callback, but the underlying fd was left to GC. Added input.destroy() to the done() callback to ensure immediate cleanup on both success and error paths. 2. webClientServer.ts: createReadStream().pipe(res) leaked the file descriptor when the HTTP response was closed prematurely (e.g. client disconnect). The pipe does not automatically destroy the source stream when the destination closes. Added res.on('close') handler to destroy the file stream. * fix: address review feedback on stream error handling webClientServer.ts: - Add fileStream.on('error') handler for async read errors that occur after the try/catch (e.g. ENOENT race, permission change mid-read) - Handle both pre-header and post-header error cases - Use res.once('close') instead of res.on('close') for cleanup intent crypto.test.ts: - Replace try/catch with assert.rejects() and regex matcher - Spy on fs.createReadStream to verify destroy() is actually called - Restore original createReadStream in finally block * fix(test): remove ESM-incompatible fs mock from crypto test The test was monkey-patching `fs.createReadStream` on the ESM module namespace, which is read-only at runtime (TypeError). Also removes the unused `Readable` import that caused TS6133. Simplified the test to verify the observable contract (mismatch rejection) without attempting to spy on stream internals. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: defer response headers until read stream opens Create the read stream and wait for its 'open' event before calling res.writeHead(200). Previously, headers were sent before the stream was created, making res.headersSent always true in the error handler and the 404 branch unreachable. A TOCTOU race between stat() and createReadStream() would result in a 200 being aborted instead of a proper 404. Now, pre-header errors reject into the existing catch block (which sends 404), and post-header errors correctly call res.destroy(). --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Alexandru Dima Co-authored-by: Alex Dima --- src/vs/base/node/crypto.ts | 1 + src/vs/base/test/node/crypto.test.ts | 11 +++++++++++ src/vs/server/node/webClientServer.ts | 27 +++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/vs/base/node/crypto.ts b/src/vs/base/node/crypto.ts index f1637f4057f..dee5f05fb3f 100644 --- a/src/vs/base/node/crypto.ts +++ b/src/vs/base/node/crypto.ts @@ -16,6 +16,7 @@ export async function checksum(path: string, sha256hash: string | undefined): Pr const done = createSingleCallFunction((err?: Error, result?: string) => { input.removeAllListeners(); hash.removeAllListeners(); + input.destroy(); if (err) { reject(err); diff --git a/src/vs/base/test/node/crypto.test.ts b/src/vs/base/test/node/crypto.test.ts index ed66c2c4b0d..37204dc6317 100644 --- a/src/vs/base/test/node/crypto.test.ts +++ b/src/vs/base/test/node/crypto.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; import { join } from '../../common/path.js'; @@ -33,4 +34,14 @@ flakySuite('Crypto', () => { await checksum(testFile, 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'); }); + + test('checksum mismatch rejects', async () => { + const testFile = join(testDir, 'checksum-mismatch.txt'); + await Promises.writeFile(testFile, 'Hello World'); + + await assert.rejects( + () => checksum(testFile, 'wrong-hash'), + /Hash mismatch/ + ); + }); }); diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 028a056717b..c920340ed13 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -73,10 +73,29 @@ export async function serveFile(filePath: string, cacheControl: CacheControl, lo responseHeaders['Content-Type'] = textMimeType[extname(filePath)] || getMediaMime(filePath) || 'text/plain'; - res.writeHead(200, responseHeaders); - - // Data - createReadStream(filePath).pipe(res); + // Create the stream first and wait for it to open before sending + // headers so that errors (e.g. ENOENT race) can still produce a + // proper 404 response instead of aborting a half-sent 200. + const fileStream = createReadStream(filePath); + await new Promise((resolve, reject) => { + fileStream.on('error', reject); + fileStream.on('open', () => { + // File opened successfully - send headers and pipe + res.writeHead(200, responseHeaders); + fileStream.pipe(res); + // Destroy the read stream if the response is closed prematurely + // (e.g. client disconnect) to avoid leaking the file descriptor. + res.once('close', () => fileStream.destroy()); + fileStream.on('end', resolve); + // Replace the initial error handler now that headers are sent + fileStream.removeAllListeners('error'); + fileStream.on('error', error => { + logService.error(error); + console.error(error.toString()); + res.destroy(); + }); + }); + }); } catch (error) { if (error.code !== 'ENOENT') { logService.error(error); From 60f0814b324ac04d2a05f855bef8e996a921681f Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:27:17 -0700 Subject: [PATCH 17/24] update distro to 6c98cfe8 (#304298) update distro to https://github.com/microsoft/vscode-distro/commit/6c98cfe8dd3b4c159d8c9c331006a2d7c41872f0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35d2ad047be..5f398d4a7e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.114.0", - "distro": "a981e7362565b4058f3bfd7604f8ab8c4f85101b", + "distro": "6c98cfe8dd3b4c159d8c9c331006a2d7c41872f0", "author": { "name": "Microsoft Corporation" }, From 69fd6c626e6693f1194952233e6b33e96f9c45b3 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Tue, 24 Mar 2026 00:38:47 +0100 Subject: [PATCH 18/24] Show unsandboxed execution reason in terminal tool confirmation (#304302) Display the model-provided reason for requesting sandbox bypass in the terminal command confirmation widget. Fixes #304205 --- .../chatTerminalToolConfirmationSubPart.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 2386b799b6c..f3acc772b0f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -211,6 +211,15 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS }, )); + if (terminalData.requestUnsandboxedExecution) { + const reasonText = (terminalData.requestUnsandboxedExecutionReason && terminalData.requestUnsandboxedExecutionReason.trim()) + || localize('chat.terminal.unsandboxedExecution.defaultReason', "The model did not provide a reason for requesting unsandboxed execution."); + const unsandboxedReasonMarkdown = new MarkdownString(undefined, { supportThemeIcons: true }); + unsandboxedReasonMarkdown.appendMarkdown(`$(${Codicon.info.id}) `); + unsandboxedReasonMarkdown.appendText(reasonText); + this._appendMarkdownPart(elements.disclaimer, unsandboxedReasonMarkdown, codeBlockRenderOptions); + } + if (disclaimer) { this._appendMarkdownPart(elements.disclaimer, disclaimer, codeBlockRenderOptions); } From 762894cd1f8cb530bd39952e3c02117d8f9b5e4e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 17:09:04 -0700 Subject: [PATCH 19/24] Rename "Show Top 5 Sessions" to "Show Recent Sessions" in filter menu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index d3ee4f4fd39..1e6bfe58c41 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -432,7 +432,7 @@ registerAction2(class ShowTopSessionsAction extends Action2 { constructor() { super({ id: 'sessionsView.showTopSessions', - title: localize2('showTopSessions', "Show Top {0} Sessions", AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT), + title: localize2('showRecentSessions', "Show Recent Sessions"), category: SessionsCategories.Sessions, toggled: IsRepositoryGroupCappedContext, menu: [{ From e288f0d14856f2632c5067e30eb69a83f9d67bc3 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 17:15:47 -0700 Subject: [PATCH 20/24] Address PR review feedback: scope cap actions, fix expanded state, make show-more focusable, use theme color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only show "Show Recent/All Sessions" actions when grouped by repository - Only clear expanded repo groups on capped transition (false→true) - Make show-more items selectable for keyboard activation - Replace hardcoded rgb(191,191,191) with --vscode-descriptionForeground Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/sessions/browser/sessionsViewPane.ts | 2 ++ .../chat/browser/agentSessions/agentSessionsViewer.ts | 9 ++++++--- .../browser/agentSessions/media/agentsessionsviewer.css | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 1e6bfe58c41..880b52eae3b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -439,6 +439,7 @@ registerAction2(class ShowTopSessionsAction extends Action2 { id: SessionsViewFilterSubMenu, group: '4_cap', order: 0, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), }] }); } @@ -461,6 +462,7 @@ registerAction2(class ShowAllSessionsAction extends Action2 { id: SessionsViewFilterSubMenu, group: '4_cap', order: 1, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), }] }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 1971e6a3773..914abbc894e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -855,11 +855,14 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou super(); if (this.filter) { + let previousCapped = this.filter.getExcludes().repositoryGroupCapped; this._register(this.filter.onDidChange(() => { - // Clear expanded state when capping is re-enabled - if (this.filter?.getExcludes().repositoryGroupCapped) { + const currentCapped = this.filter!.getExcludes().repositoryGroupCapped; + // Only clear expanded state when capping transitions from off to on + if (currentCapped && !previousCapped) { this.expandedRepositoryGroups.clear(); } + previousCapped = currentCapped; })); } } @@ -1333,7 +1336,7 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider Date: Mon, 23 Mar 2026 17:26:19 -0700 Subject: [PATCH 21/24] padding --- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 57991d16714..42aaf118f9f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -327,7 +327,7 @@ font-size: 12px; color: var(--vscode-descriptionForeground); /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ - padding: 0 6px 0 28px; + padding: 0 6px 0 24px; cursor: pointer; text-decoration: none !important; } From 970e788816f892794940b30483967b12eb0aac83 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Mon, 23 Mar 2026 17:29:02 -0700 Subject: [PATCH 22/24] color change --- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 42aaf118f9f..f52fe9f03c4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -325,7 +325,7 @@ display: flex; align-items: center; font-size: 12px; - color: var(--vscode-descriptionForeground); + color: var(--vscode-foreground); /* align with session item text: 6px item padding + 16px icon + 6px main-col padding */ padding: 0 6px 0 24px; cursor: pointer; From c19696babb1ed61cd394682d24ad34dd73d71664 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 17:42:34 -0700 Subject: [PATCH 23/24] Fix loading copilot-sdk 0.2 (#304310) * Fix loading copilot-sdk 0.2 * Comment Co-authored-by: Copilot * comment --------- Co-authored-by: Copilot --- src/bootstrap-import.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/bootstrap-import.ts b/src/bootstrap-import.ts index ec09ff88ba0..8ccabe764d1 100644 --- a/src/bootstrap-import.ts +++ b/src/bootstrap-import.ts @@ -30,14 +30,29 @@ export async function initialize(injectPath: string): Promise { const path = join(injectPackageJSONPath, `../node_modules/${name}/package.json`); const pkgJson = JSON.parse(String(await promises.readFile(path))); - // Determine the entry point: prefer exports["."].import for ESM, then main + // Determine the entry point: prefer exports["."].import for ESM, then main. + // Handle conditional export targets where exports["."].import/default + // can be a string or an object with a string `default` field. + // (Added for copilot-sdk) let main: string | undefined; if (pkgJson.exports?.['.']) { const dotExport = pkgJson.exports['.']; if (typeof dotExport === 'string') { main = dotExport; } else if (typeof dotExport === 'object' && dotExport !== null) { - main = dotExport.import ?? dotExport.default; + const resolveCondition = (v: unknown): string | undefined => { + if (typeof v === 'string') { + return v; + } + if (typeof v === 'object' && v !== null) { + const d = (v as { default?: unknown }).default; + if (typeof d === 'string') { + return d; + } + } + return undefined; + }; + main = resolveCondition(dotExport.import) ?? resolveCondition(dotExport.default); } } if (typeof main !== 'string') { From a2d7169e3b62e472db34236bc9c7fcd6c0b9b96d Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 17:43:19 -0700 Subject: [PATCH 24/24] Keep agent host alive while there are active websocket connections (#304271) * Keep agent host alive while there are active websocket connections Instead of shutting it down whenever there are no agents running Co-authored-by: Copilot * Fix disposable handling --------- Co-authored-by: Copilot --- src/vs/platform/agentHost/architecture.md | 962 ++++++++++++++---- .../platform/agentHost/common/agentService.ts | 2 + .../platform/agentHost/node/agentHostMain.ts | 18 +- .../agentHost/node/protocolServerHandler.ts | 11 +- .../test/node/protocolServerHandler.test.ts | 44 +- src/vs/server/node/serverAgentHostManager.ts | 58 +- .../test/node/serverAgentHostManager.test.ts | 205 ++++ 7 files changed, 1070 insertions(+), 230 deletions(-) create mode 100644 src/vs/server/test/node/serverAgentHostManager.test.ts diff --git a/src/vs/platform/agentHost/architecture.md b/src/vs/platform/agentHost/architecture.md index e10015a7c5b..f5bfec457e9 100644 --- a/src/vs/platform/agentHost/architecture.md +++ b/src/vs/platform/agentHost/architecture.md @@ -1,224 +1,762 @@ -# Agent host process architecture +# Remote Agent Host - Architecture Reference -> **Keep this document in sync with the code.** If you change the IPC contract, add new event types, modify the process lifecycle, or restructure files, update this document as part of the same change. +This file describes the key types in the remote agent host system, from the +agent host process itself up through the sessions app integration layer. -For design decisions, see [design.md](design.md). For the client-server state protocol, see [protocol.md](protocol.md). For chat session wiring, see [sessions.md](sessions.md). +The system has four layers: -## Overview +1. **Agent host process** (`platform/agentHost/node/`) + The utility process that hosts agent backends (e.g. Copilot SDK). + Owns the authoritative state tree and dispatches to IAgent providers. -The agent host runs as either an Electron **utility process** (desktop) or a **standalone WebSocket server** (headless / development). It hosts agent backends (CopilotAgent, MockAgent) and exposes session state to clients through two communication layers: +2. **Platform services** (`platform/agentHost/common/`, `electron-browser/`) + Service interfaces and IPC plumbing that expose the agent host to the + renderer. Local connections use MessagePort; remote ones use WebSocket. -1. **MessagePort / ProxyChannel** (desktop only) -- the renderer connects directly to the utility process via MessagePort. `AgentHostServiceClient` proxies `IAgentService` methods and forwards action/notification events. -2. **WebSocket / JSON-RPC protocol** (standalone server) -- multiple clients connect over WebSocket. Session state is synchronized via actions, subscriptions, and write-ahead reconciliation. See [protocol.md](protocol.md) for the full specification. +3. **Workbench contributions** (`workbench/contrib/chat/browser/agentSessions/agentHost/`) + Shared UI adapters that bridge the agent host protocol with the chat UI: + session handlers, session list controllers, language model providers, and + state-to-progress adapters. Used by both local and remote agent hosts. -In both modes, the server holds an authoritative state tree (`SessionStateManager`) mutated by actions flowing through pure reducers. Raw `IAgentProgressEvent`s from agent backends are mapped to state actions via `agentEventMapper.ts`. - -The entire feature is gated behind the `chat.agentHost.enabled` setting (default `false`). When disabled, the process is not spawned and no agents are registered. - -## Process Model +4. **Sessions app orchestrator** (`sessions/contrib/remoteAgentHost/`) + The contribution that discovers remote agent hosts, dynamically registers + them as chat session types, and provides the remote filesystem provider. ``` -+--------------------------------------------------------------+ -| Renderer Window (Desktop) | -| | -| AgentHostContribution (discovers agents via listAgents()) | -| +-- per agent: SessionHandler, ListCtrl, LMProvider | -| +-- SessionClientState (write-ahead reconciliation) | -| +-- stateToProgressAdapter (state -> IChatProgress[]) | -| | -| AgentHostServiceClient (IAgentHostService singleton) | -| +-- ProxyChannel over delayed MessagePort | -| (revive() applied to event payloads) | -+---------------- MessagePort (direct) -------------------------+ -| Agent Host Utility Process (agentHostMain.ts) | -| -- or -- | -| Standalone Server (agentHostServerMain.ts) | -| | -| SessionStateManager (server-authoritative state tree) | -| +-- rootReducer / sessionReducer | -| +-- action envelope sequencing | -| | -| ProtocolServerHandler (JSON-RPC routing, broadcasts) | -| +-- per-client subscriptions, replay buffer | -| | -| Agent registry (Map) | -| +-- CopilotAgent (id='copilot') | -| | +-- CopilotClient (@github/copilot-sdk) | -| +-- ScriptedMockAgent (id='mock', opt-in via flag) | -| | -| agentEventMapper.ts | -| +-- IAgentProgressEvent -> ISessionAction mapping | -+---------------- UtilityProcess lifecycle ---------------------+ -| Main Process (Desktop only) | -| | -| ElectronAgentHostStarter (IAgentHostStarter) | -| +-- Spawns utility process, brokers MessagePort to windows | -| AgentHostProcessManager | -| +-- Lazy start on first window connection, crash recovery | -+---------------------------------------------------------------+ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Sessions App (Layer 4) │ +│ RemoteAgentHostContribution │ +│ per-connection → SessionClientState + agent registrations │ +│ AgentHostFileSystemProvider (agenthost:// scheme) │ +├──────────────────────────────────────┬────────────────────────────────────┤ +│ Workbench Contributions (3) │ Workbench Contributions (3) │ +│ AgentHostContribution (local) │ (shared adapters) │ +│ SessionClientState │ AgentHostSessionHandler │ +│ per-agent registrations │ AgentHostSessionListController │ +│ │ AgentHostLanguageModelProvider │ +├──────────────────────────────────────┴────────────────────────────────────┤ +│ Platform Services (Layer 2) │ +│ IAgentHostService (local, MessagePort) │ +│ IRemoteAgentHostService (remote, WebSocket) │ +│ └─ both implement IAgentConnection │ +├───────────────────────────────────────────────────────────────────────────┤ +│ Agent Host Process (Layer 1) │ +│ AgentService → SessionStateManager → IAgent (Copilot SDK) │ +│ ProtocolServerHandler (WebSocket protocol bridge) │ +│ AgentSideEffects (action dispatch + progress event routing) │ +└───────────────────────────────────────────────────────────────────────────┘ ``` -## File Layout +```typescript +// ============================================================================= +// LAYER 1: Agent Host Process (platform/agentHost/node/) +// ============================================================================= + +/** + * Implemented by each agent backend (e.g. the Copilot SDK wrapper). + * The agent host process can host multiple providers, though currently + * only `copilot` is supported. + * + * Registered with {@link AgentService.registerProvider}. Provider progress + * events are wired to the state manager through {@link AgentSideEffects}. + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgent { + /** Unique provider identifier (e.g. `'copilot'`). */ + readonly id: AgentProvider; + /** Fires when the provider streams progress for a session. */ + readonly onDidSessionProgress: Event; + createSession(config?: IAgentCreateSessionConfig): Promise; + sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise; + getSessionMessages(session: URI): Promise; + disposeSession(session: URI): Promise; + abortSession(session: URI): Promise; + changeModel(session: URI, model: string): Promise; + respondToPermissionRequest(requestId: string, approved: boolean): void; + getDescriptor(): IAgentDescriptor; + listModels(): Promise; + listSessions(): Promise; + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + authenticate(resource: string, token: string): Promise; + shutdown(): Promise; + dispose(): void; +} + +/** + * The agent service implementation that runs inside the agent host utility + * process. Dispatches to registered {@link IAgent} providers based on the + * provider identifier in the session URI scheme. + * + * Owns the {@link SessionStateManager} (authoritative state tree) and + * {@link AgentSideEffects} (action routing + progress event mapping). + * + * When `VSCODE_AGENT_HOST_PORT` is set, the process also starts a + * {@link ProtocolServerHandler} over WebSocket for external clients. + * + * File: `platform/agentHost/node/agentService.ts` + */ +interface AgentService extends IAgentService { + /** Exposes the state manager for co-hosting a WebSocket protocol server. */ + readonly stateManager: SessionStateManager; + /** Register a new agent backend provider. */ + registerProvider(provider: IAgent): void; +} + +/** + * Server-side authoritative state manager for the sessions process protocol. + * + * Maintains the root state (agent list + active session count) and per-session + * state trees. Applies actions through pure reducers, assigns monotonic + * sequence numbers, and emits {@link IActionEnvelope}s for subscribed clients. + * + * Consumed by both the IPC proxy (for local clients) and + * {@link ProtocolServerHandler} (for WebSocket clients). Both paths share + * the same state, so local and remote clients see identical state. + * + * File: `platform/agentHost/node/sessionStateManager.ts` + */ +interface SessionStateManager { + readonly rootState: IRootState; + readonly serverSeq: number; + readonly onDidEmitEnvelope: Event; + readonly onDidEmitNotification: Event; + getSessionState(session: string): ISessionState | undefined; + getSnapshot(resource: string): IStateSnapshot | undefined; + createSession(summary: ISessionSummary): ISessionState; + removeSession(session: string): void; + applyAction(action: IStateAction, origin: IActionOrigin): void; +} + +/** + * Shared side-effect handler that routes client-dispatched actions to the + * correct {@link IAgent} backend, handles session create/dispose/list + * operations, and wires agent progress events to the state manager. + * + * Also implements {@link IProtocolSideEffectHandler} so the WebSocket + * {@link ProtocolServerHandler} can delegate side effects to the same logic. + * + * File: `platform/agentHost/node/agentSideEffects.ts` + */ +interface AgentSideEffects extends IProtocolSideEffectHandler { + /** Connects an IAgent's progress events to the state manager. */ + registerProgressListener(provider: IAgent): IDisposable; +} + +/** + * Server-side protocol handler for WebSocket clients. Routes JSON-RPC + * messages to the {@link SessionStateManager}, manages client subscriptions, + * and broadcasts action envelopes to subscribed clients. + * + * Handles the initialize/reconnect handshake, subscribe/unsubscribe, + * dispatchAction, createSession, disposeSession, and browseDirectory commands. + * + * Exposes {@link onDidChangeConnectionCount} so the server process can + * track how many external clients are connected (used by + * {@link ServerAgentHostManager} for lifetime management). + * + * File: `platform/agentHost/node/protocolServerHandler.ts` + */ +interface ProtocolServerHandler { + /** Fires with the current client count when a client connects or disconnects. */ + readonly onDidChangeConnectionCount: Event; +} + +/** + * Side-effect handler interface for protocol commands that require + * business logic beyond pure state management. Implemented by + * {@link AgentSideEffects} and consumed by {@link ProtocolServerHandler}. + * + * File: `platform/agentHost/node/protocolServerHandler.ts` + */ +interface IProtocolSideEffectHandler { + handleAction(action: ISessionAction): void; + handleCreateSession(command: ICreateSessionParams): Promise; + handleDisposeSession(session: string): void; + handleListSessions(): Promise; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; + handleBrowseDirectory(uri: string): Promise; + getDefaultDirectory(): string; +} + +/** + * Main-process service that manages the agent host utility process lifecycle: + * lazy start on first connection request, crash recovery (up to 5 restarts), + * and logger channel forwarding. + * + * The renderer communicates with the utility process directly via MessagePort; + * this class does not relay any agent service calls. + * + * File: `platform/agentHost/node/agentHostService.ts` + */ +interface AgentHostProcessManager { + // Internal lifecycle management - start, restart, logger forwarding. +} + +/** + * Server-specific agent host manager. Eagerly starts the agent host process, + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when: + * - there are active agent sessions (turns in progress), OR + * - there are WebSocket clients connected to the agent host + * + * The token is released (allowing server auto-shutdown) only when both + * active sessions = 0 AND connected clients = 0. + * + * Session count comes from `root/activeSessionsChanged` actions via + * {@link IAgentService.onDidAction}. Client connection count comes from + * a separate IPC channel ({@link AgentHostIpcChannels.ConnectionTracker}) + * that is not part of the agent host protocol -- it is a server-only + * process-management concern. + * + * File: `server/node/serverAgentHostManager.ts` + */ +interface ServerAgentHostManager { + // Tracks _hasActiveSessions + _connectionCount, updates lifetime token + // when either changes. +} + +/** + * Abstracts the utility process creation so the same lifecycle management + * works for both Electron utility processes and Node child processes. + * + * File: `platform/agentHost/common/agent.ts` + */ +interface IAgentHostStarter extends IDisposable { + readonly onRequestConnection?: Event; + readonly onWillShutdown?: Event; + /** Creates the agent host process and connects to it. */ + start(): IAgentHostConnection; +} + +/** + * The connection returned by {@link IAgentHostStarter.start}. Provides + * an IPC channel client and process exit events. + * + * File: `platform/agentHost/common/agent.ts` + */ +interface IAgentHostConnection { + readonly client: IChannelClient; + readonly store: DisposableStore; + readonly onDidProcessExit: Event<{ code: number; signal: string }>; +} + +// ============================================================================= +// LAYER 2: Platform Services (platform/agentHost/common/ & electron-browser/) +// ============================================================================= + +/** + * Core protocol surface for communicating with an agent host. Methods are + * proxied across MessagePort (local) or implemented over WebSocket (remote). + * + * State synchronization uses the subscribe/unsubscribe/dispatchAction pattern. + * Clients observe root state (discovered agents, models) and session state + * via subscriptions, and mutate state by dispatching actions (e.g. + * `session/turnStarted`, `session/turnCancelled`). + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgentService { + listAgents(): Promise; + getResourceMetadata(): Promise; + authenticate(params: IAuthenticateParams): Promise; + refreshModels(): Promise; + listSessions(): Promise; + createSession(config?: IAgentCreateSessionConfig): Promise; + disposeSession(session: URI): Promise; + shutdown(): Promise; + + // ---- Protocol methods ---- + subscribe(resource: URI): Promise; + unsubscribe(resource: URI): void; + readonly onDidAction: Event; + readonly onDidNotification: Event; + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void; + browseDirectory(uri: URI): Promise; +} + +/** + * A concrete connection to an agent host - local utility process or remote + * WebSocket. Extends {@link IAgentService} with a `clientId` used for + * write-ahead reconciliation of optimistic actions. + * + * Both {@link IAgentHostService} (local) and per-connection objects from + * {@link IRemoteAgentHostService} (remote) satisfy this contract. The + * workbench contributions ({@link AgentHostSessionHandler}, etc.) program + * against this single interface. + * + * File: `platform/agentHost/common/agentService.ts` + */ +interface IAgentConnection extends IAgentService { + /** Unique client identifier, used as origin in action envelopes. */ + readonly clientId: string; +} + +/** + * The local agent host service - wraps the utility process connection and + * provides lifecycle events. The renderer talks to the utility process + * directly via MessagePort using ProxyChannel. + * + * Registered as a singleton service. Also implements {@link IAgentConnection} + * so it can be used interchangeably with remote connections. + * + * File: `platform/agentHost/common/agentService.ts` (interface) + * File: `platform/agentHost/electron-browser/agentHostService.ts` (implementation) + */ +interface IAgentHostService extends IAgentConnection { + readonly onAgentHostExit: Event; + readonly onAgentHostStart: Event; + restartAgentHost(): Promise; +} + +/** + * Manages connections to one or more remote agent host processes over + * WebSocket. Each connection is identified by its address string (from the + * `chat.remoteAgentHosts` setting) and exposed as an {@link IAgentConnection}. + * + * The implementation reads the setting, creates a + * {@link RemoteAgentHostProtocolClient} per address, reconnects when the + * setting changes, and fires `onDidChangeConnections` when connections are + * established or lost. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` (interface) + * File: `platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` (implementation) + */ +interface IRemoteAgentHostService { + readonly onDidChangeConnections: Event; + readonly connections: readonly IRemoteAgentHostConnectionInfo[]; + getConnection(address: string): IAgentConnection | undefined; +} + +/** + * Metadata about a single remote connection - address, friendly name, + * client ID from the handshake, and the remote machine's home directory. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` + */ +interface IRemoteAgentHostConnectionInfo { + readonly address: string; + readonly name: string; + readonly clientId: string; + readonly defaultDirectory?: string; +} + +/** + * An entry in the `chat.remoteAgentHosts` setting. + * + * File: `platform/agentHost/common/remoteAgentHostService.ts` + */ +interface IRemoteAgentHostEntry { + readonly address: string; + readonly name: string; + readonly connectionToken?: string; +} + +/** + * A protocol-level client for a single remote agent host connection. + * Manages the WebSocket transport, handshake (initialize command with + * protocol version exchange), subscriptions, action dispatch, and + * JSON-RPC request/response correlation. + * + * Implements {@link IAgentConnection} so consumers can program against + * a single interface regardless of whether the agent host is local or remote. + * + * File: `platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` + */ +interface RemoteAgentHostProtocolClient extends IAgentConnection { + readonly defaultDirectory: string | undefined; + readonly onDidClose: Event; + connect(): Promise; +} + +// ============================================================================= +// LAYER 2: State Protocol Types (platform/agentHost/common/state/) +// ============================================================================= + +/** + * Root state: the top-level state tree subscribed to at `agenthost:/root`. + * Contains the list of discovered agent backends and active session count. + * Mutated by `root/agentsChanged` and `root/activeSessionsChanged` actions. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface IRootState { + readonly agents: readonly IAgentInfo[]; + readonly activeSessions: number; +} + +/** + * Describes an agent backend discovered via root state subscription. + * Each agent exposes a provider name, display metadata, and available models. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface IAgentInfo { + readonly provider: string; + readonly displayName: string; + readonly description: string; + readonly models: readonly ISessionModelInfo[]; +} + +/** + * Per-session state tree. Contains the session summary, lifecycle, completed + * turns, active turn (if any), and server tools. Mutated by session actions + * like `session/turnStarted`, `session/delta`, `session/toolCallStart`, etc. + * + * File: `platform/agentHost/common/state/sessionState.ts` (re-exported from protocol) + */ +interface ISessionState { + readonly summary: ISessionSummary; + readonly lifecycle: SessionLifecycle; + readonly turns: readonly ITurn[]; + readonly activeTurn: IActiveTurn | undefined; +} + +/** + * An envelope wrapping a state action with origin metadata and a monotonic + * server sequence number. Clients use the origin to distinguish their own + * echoed actions from concurrent actions from other clients/the server. + * + * File: `platform/agentHost/common/state/sessionActions.ts` (re-exported from protocol) + */ +interface IActionEnvelope { + readonly action: IStateAction; + readonly origin: IActionOrigin; + readonly serverSeq: number; +} + +/** + * A state snapshot returned by the subscribe command. Contains the current + * state at the given resource URI and the server sequence number at + * snapshot time. The client should process subsequent envelopes with + * `serverSeq > fromSeq`. + * + * File: `platform/agentHost/common/state/sessionProtocol.ts` (re-exported from protocol) + */ +interface IStateSnapshot { + readonly resource: string; + readonly state: IRootState | ISessionState; + readonly fromSeq: number; +} + +/** + * Client-side state manager with write-ahead reconciliation. + * + * Maintains confirmed state (last server-acknowledged), a pending action + * queue (optimistically applied), and reconciles when the server echoes + * actions back (possibly interleaved with actions from other sources). + * Operates on two kinds of subscribable state: + * - Root state (agents + models) - server-only mutations, no write-ahead. + * - Session state - mixed: client-sendable actions get write-ahead, + * server-only actions are applied directly. + * + * Usage: + * 1. `handleSnapshot()` - apply initial state from subscribe response. + * 2. `applyOptimistic()` - optimistically apply a client action. + * 3. `receiveEnvelope()` - process a server action envelope. + * 4. `receiveNotification()` - process an ephemeral notification. + * + * File: `platform/agentHost/common/state/sessionClientState.ts` + */ +interface SessionClientState { + readonly clientId: string; + readonly rootState: IRootState | undefined; + readonly onDidChangeRootState: Event; + readonly onDidChangeSessionState: Event<{ session: string; state: ISessionState }>; + readonly onDidReceiveNotification: Event; + getSessionState(session: string): ISessionState | undefined; + handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void; + applyOptimistic(action: ISessionAction): number; + receiveEnvelope(envelope: IActionEnvelope): void; + receiveNotification(notification: INotification): void; + unsubscribe(resource: string): void; +} + +/** + * A bidirectional transport for protocol messages (JSON-RPC 2.0 framing). + * Implementations handle serialization, framing, and connection management. + * Concrete implementations: MessagePort (ProxyChannel), WebSocket, stdio. + * + * File: `platform/agentHost/common/state/sessionTransport.ts` + */ +interface IProtocolTransport extends IDisposable { + readonly onMessage: Event; + readonly onClose: Event; + send(message: IProtocolMessage): void; +} + +/** + * Server-side transport that accepts multiple client connections. + * Each connected client gets its own {@link IProtocolTransport}. + * + * File: `platform/agentHost/common/state/sessionTransport.ts` + */ +interface IProtocolServer extends IDisposable { + readonly onConnection: Event; + readonly address: string | undefined; +} + +// ============================================================================= +// LAYER 3: Workbench Contributions (workbench/contrib/chat/browser/agentSessions/agentHost/) +// ============================================================================= + +/** + * Renderer-side handler for a single agent host chat session type. + * Bridges the protocol state layer with the chat UI: + * + * - Subscribes to session state via {@link IAgentConnection} + * - Derives `IChatProgress[]` from immutable state changes in + * {@link SessionClientState} + * - Dispatches client actions (`turnStarted`, `permissionResolved`, + * `turnCancelled`) back to the server + * - Registers a dynamic chat agent via {@link IChatAgentService} + * + * Works with both local and remote connections via the {@link IAgentConnection} + * interface passed in the config. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` + */ +interface AgentHostSessionHandler extends IChatSessionContentProvider { + provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; +} + +/** + * Configuration for an {@link AgentHostSessionHandler} instance. + * Contains the agent identity, displayName, the connection to use, + * and optional callbacks for resolving working directories and + * interactive authentication. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` + */ +interface IAgentHostSessionHandlerConfig { + readonly provider: AgentProvider; + readonly agentId: string; + readonly sessionType: string; + readonly fullName: string; + readonly description: string; + readonly connection: IAgentConnection; + readonly extensionId?: string; + readonly extensionDisplayName?: string; + /** Resolve a working directory for a new session (e.g. from active session's repository URI). */ + readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** Trigger interactive authentication when the server rejects with auth-required. */ + readonly resolveAuthentication?: () => Promise; +} + +/** + * Provides session list items for the chat sessions sidebar by querying + * active sessions from an agent host connection. Listens to protocol + * notifications (`notify/sessionAdded`, `notify/sessionRemoved`) for + * incremental updates, and refreshes on `session/turnComplete` actions. + * + * Works with both local and remote agent host connections via + * {@link IAgentConnection}. + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` + */ +interface AgentHostSessionListController extends IChatSessionItemController { + readonly items: readonly IChatSessionItem[]; + readonly onDidChangeChatSessionItems: Event; + refresh(token: CancellationToken): Promise; +} + +/** + * Exposes models available from the agent host process as selectable + * language models in the chat model picker. Models come from root state + * (via {@link IAgentInfo.models}) and are published with IDs prefixed + * by the session type (e.g. `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts` + */ +interface AgentHostLanguageModelProvider extends ILanguageModelChatProvider { + /** Called when models change in root state to push updates to the model picker. */ + updateModels(models: readonly ISessionModelInfo[]): void; +} + +/** + * The local agent host contribution (for the workbench, not the sessions app). + * Discovers agents from the local agent host process and registers each one + * as a chat session type. Gated on the `chat.agentHost.enabled` setting. + * + * Uses the same shared adapters ({@link AgentHostSessionHandler}, etc.) + * but connects via {@link IAgentHostService} (MessagePort) instead of + * {@link IRemoteAgentHostService} (WebSocket). + * + * File: `workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts` + */ +interface AgentHostContribution extends IWorkbenchContribution { + // Registers per-agent: chat session contribution, session list controller, + // session handler, and language model provider - same 4 registrations + // as RemoteAgentHostContribution but for the local agent host. +} + +// ============================================================================= +// LAYER 4: Sessions App Orchestrator (sessions/contrib/remoteAgentHost/) +// ============================================================================= + +/** + * Central orchestrator for remote agent hosts in the sessions app. + * + * For each active remote connection: + * 1. Creates a {@link SessionClientState} for write-ahead reconciliation + * 2. Subscribes to `agenthost:/root` to discover available agents + * 3. For each discovered copilot agent, performs four registrations: + * - Chat session contribution (via {@link IChatSessionsService}) + * - Session list controller ({@link AgentHostSessionListController}) + * - Session content provider ({@link AgentHostSessionHandler}) + * - Language model provider ({@link AgentHostLanguageModelProvider}) + * 4. Registers authority→address mappings for the filesystem provider + * 5. Authenticates connections using RFC 9728 resource metadata + * + * Reconciles when connections change (added/removed/name changed) + * and when the default auth account or auth sessions change. + * + * File: `sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts` + */ +interface RemoteAgentHostContribution extends IWorkbenchContribution { + // Per-connection state tracked in a DisposableMap +} + +/** + * Read-only {@link IFileSystemProvider} registered under the `agenthost` + * scheme. Proxies `stat` and `readdir` calls through the agent host + * protocol's `browseDirectory` RPC. + * + * The URI authority identifies the remote connection (sanitized address), + * the URI path is the remote filesystem path. Authority-to-address mappings + * are registered by {@link RemoteAgentHostContribution} via + * `registerAuthority(authority, address)`. + * + * File: `sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts` + */ +interface AgentHostFileSystemProvider extends IFileSystemProvider { + /** Register a mapping from sanitized URI authority to remote address. */ + registerAuthority(authority: string, address: string): IDisposable; +} + +// ============================================================================= +// Naming Conventions & URI Schemes +// ============================================================================= + +/** + * Remote addresses are encoded into URI-safe authority strings: + * - `localhost:8081` → `localhost__8081` + * - `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` + * + * | Context | Scheme | Example | + * |--------------------------|------------------|-------------------------------------------------| + * | Session resource (UI) | `` | `remote-localhost__8081-copilot:/untitled-abc` | + * | Backend session (server) | `` | `copilot:/abc-123` | + * | Root state subscription | (string literal) | `agenthost:/root` | + * | Remote filesystem | `agenthost` | `agenthost://localhost__8081/home/user/project` | + * | Language model ID | - | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | + * + * Session type naming: `remote-${authority}-${provider}` for remote, + * `agent-host-${provider}` for local. + */ + +// ============================================================================= +// IPC & Auth Data Types (platform/agentHost/common/agentService.ts) +// ============================================================================= + +/** Metadata describing an agent backend, discovered over IPC or root state. */ +interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + /** @deprecated Use IResourceMetadata from getResourceMetadata() instead. */ + readonly requiresAuth: boolean; +} + +/** Serializable model information from the agent host. */ +interface IAgentModelInfo { + readonly provider: AgentProvider; + readonly id: string; + readonly name: string; + readonly maxContextWindow: number; + readonly supportsVision: boolean; + readonly supportsReasoningEffort: boolean; +} + +/** Configuration for creating a new session. */ +interface IAgentCreateSessionConfig { + readonly provider?: AgentProvider; + readonly model?: string; + readonly session?: URI; + readonly workingDirectory?: string; +} + +/** Metadata for an existing session (returned by listSessions). */ +interface IAgentSessionMetadata { + readonly session: URI; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly workingDirectory?: string; +} + +/** Serializable attachment passed alongside a message to the agent host. */ +interface IAgentAttachment { + readonly type: AttachmentType; + readonly path: string; + readonly displayName?: string; + readonly text?: string; + readonly selection?: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource (RFC 9728). + * Clients resolve tokens via the VS Code authentication service. + */ +interface IResourceMetadata { + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command (RFC 6750 bearer token delivery). + */ +interface IAuthenticateParams { + readonly resource: string; + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +interface IAuthenticateResult { + readonly authenticated: boolean; +} + +// ============================================================================= +// Progress Events (platform/agentHost/common/agentService.ts) +// ============================================================================= + +/** + * Discriminated union of progress events streamed from the agent host. + * The state-to-progress adapter ({@link stateToProgressAdapter.ts}) + * translates protocol state changes into `IChatProgress[]` for the chat UI, + * but these events are also used in the IPC path for the old event-based API. + * + * Types: `delta`, `message`, `idle`, `tool_start`, `tool_complete`, + * `title_changed`, `error`, `usage`, `permission_request`, `reasoning`. + */ +type IAgentProgressEvent = + | IAgentDeltaEvent + | IAgentMessageEvent + | IAgentIdleEvent + | IAgentToolStartEvent + | IAgentToolCompleteEvent + | IAgentTitleChangedEvent + | IAgentErrorEvent + | IAgentUsageEvent + | IAgentPermissionRequestEvent + | IAgentReasoningEvent; ``` -src/vs/platform/agentHost/ -+-- common/ -| +-- agent.ts # IAgentHostStarter, IAgentHostConnection (starter contract) -| +-- agentService.ts # IAgent, IAgentService, IAgentHostService interfaces, -| # IPC data types, IAgentProgressEvent union, -| # AgentSession namespace (URI helpers), -| # AgentHostEnabledSettingId -| +-- state/ -| +-- sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.) -| +-- sessionActions.ts # Action discriminated union + ActionEnvelope + Notifications -| +-- sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer) -| +-- sessionProtocol.ts # JSON-RPC message types, request params/results -| +-- sessionCapabilities.ts # Version constants + ProtocolCapabilities -| +-- sessionClientState.ts # Client-side state manager with write-ahead reconciliation -| +-- sessionTransport.ts # IProtocolTransport / IProtocolServer abstractions -| +-- versions/ -| +-- v1.ts # v1 wire format types (tip -- editable, compiler-enforced compat) -| +-- versionRegistry.ts # Compile-time compat checks + runtime action->version map -+-- electron-browser/ -| +-- agentHostService.ts # AgentHostServiceClient (renderer singleton, direct MessagePort) -+-- electron-main/ -| +-- electronAgentHostStarter.ts # Spawns utility process, brokers MessagePort connections -+-- node/ -| +-- agentHostMain.ts # Entry point inside the Electron utility process -| +-- agentHostServerMain.ts # Entry point for standalone WebSocket server -| +-- agentService.ts # AgentService: dispatches to registered IAgent providers -| +-- agentHostService.ts # AgentHostProcessManager: lifecycle, crash recovery -| +-- agentEventMapper.ts # Maps IAgentProgressEvent -> ISessionAction -| +-- sessionStateManager.ts # Server-authoritative state tree + reducer dispatch -| +-- protocolServerHandler.ts # JSON-RPC routing, client subscriptions, action broadcast -| +-- webSocketTransport.ts # WebSocket IProtocolTransport + IProtocolServer impl -| +-- nodeAgentHostStarter.ts # Node.js (non-Electron) starter -| +-- copilot/ -| +-- copilotAgent.ts # CopilotAgent: IAgent backed by Copilot SDK -| +-- copilotSessionWrapper.ts -| +-- copilotToolDisplay.ts # Copilot-specific tool name -> display string mapping -+-- test/ - +-- (test files) - -src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/ -+-- agentHostChatContribution.ts # AgentHostContribution: discovers agents, registers dynamically -+-- agentHostLanguageModelProvider.ts # ILanguageModelChatProvider for SDK models -+-- agentHostSessionHandler.ts # AgentHostSessionHandler: generic, config-driven -+-- agentHostSessionListController.ts # Lists persisted sessions from agent host -+-- stateToProgressAdapter.ts # Converts protocol state -> IChatProgress[] for chat UI - -src/vs/workbench/contrib/chat/electron-browser/ -+-- chat.contribution.ts # Desktop-only: registers AgentHostContribution -``` - -## Session URIs - -Sessions are identified by URIs where the **scheme is the provider name** and the **path is the raw session ID**: `copilot:/`. Helper functions in the `AgentSession` namespace: - -| Helper | Purpose | -|---|---| -| `AgentSession.uri(provider, rawId)` | Create a session URI | -| `AgentSession.id(session)` | Extract raw session ID from URI | -| `AgentSession.provider(session)` | Extract provider name from URI scheme | - -The renderer uses UI resource schemes (`agent-host-copilot`) for session resources. The `AgentHostSessionHandler` converts these to provider URIs before IPC calls. - -## Communication Layers - -### Layer 1: IAgent interface (internal) - -The `IAgent` interface in `agentService.ts` is what each agent backend implements. It fires `IAgentProgressEvent`s (raw SDK events) and exposes methods for session management: - -| Method | Description | -|---|---| -| `createSession(config?)` | Create a new session (returns session URI) | -| `sendMessage(session, prompt, attachments?)` | Send a user message | -| `abortSession(session)` | Abort the current turn | -| `respondToPermissionRequest(requestId, approved)` | Grant/deny a permission | -| `getDescriptor()` | Return agent metadata | -| `listModels()` | List available models | -| `listSessions()` | List persisted sessions | -| `setAuthToken(token)` | Set auth credentials | -| `changeModel?(session, model)` | Change model for a session | - -### Layer 2: Sessions state protocol (client-facing) - -The server maps raw `IAgentProgressEvent`s to state actions via `agentEventMapper.ts`, dispatches them through `SessionStateManager`, and broadcasts to subscribed clients. See [protocol.md](protocol.md) for the full JSON-RPC specification, action types, state model, and versioning. - -### Layer 3: MessagePort relay (desktop renderer) - -`AgentHostServiceClient` in `electron-browser/agentHostService.ts` connects to the utility process via MessagePort and proxies `IAgentService` methods. It also forwards action envelopes and notifications as events so the renderer can feed them into `SessionClientState`. - -## How It Works - -### Setting Gate - -The `chat.agentHost.enabled` setting (default `false`) controls the entire feature: -- **Main process** (`app.ts`): skips creating `ElectronAgentHostStarter` + `AgentHostProcessManager` -- **Renderer proxy** (`AgentHostServiceClient`): skips MessagePort connection -- **Contribution** (`AgentHostContribution`): returns early without discovering or registering agents - -### Startup (lazy) - -1. `ElectronAgentHostStarter` is created in `app.ts` (if setting enabled) and handed to `AgentHostProcessManager`. -2. The utility process is **not** spawned until the first window requests a MessagePort connection. -3. On start, the starter spawns the utility process with entry point `vs/platform/agent/node/agentHostMain`. -4. Each renderer window gets its own MessagePort via `acquirePort('vscode:createAgentHostMessageChannel', ...)`. - -### Standalone Server Mode - -The agent host can also run as a standalone WebSocket server (`agentHostServerMain.ts`): - -```bash -node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--enable-mock-agent] -``` - -This mode creates a `WebSocketProtocolServer` and `ProtocolServerHandler` directly without Electron. Useful for development and headless scenarios. - -### Dynamic Agent Discovery - -On startup (if the setting is enabled), `AgentHostContribution` calls `listAgents()` to discover available backends from the agent host process. Each returned `IAgentDescriptor` contains: - -| Field | Purpose | -|---|---| -| `provider` | Agent provider ID (`'copilot'`) | -| `displayName` | Human-readable name for UI | -| `description` | Description string | -| `requiresAuth` | Whether the renderer should push a GitHub auth token | - -For each descriptor, the contribution dynamically registers: -- Chat session contribution (type = `agent-host-{provider}`) -- `AgentHostSessionHandler` configured with the descriptor's metadata -- `AgentHostSessionListController` for the session sidebar -- `AgentHostLanguageModelProvider` for the model picker -- Auth token wiring (only if `requiresAuth` is true) - -### Auth Token Flow - -Only agents with `requiresAuth: true` (currently Copilot) get auth wiring: -1. On startup and on account/session changes, retrieves the GitHub OAuth token -2. Pushes it to the agent host via `IAgentHostService.setAuthToken(token)` -3. `CopilotAgent` passes it to `CopilotClient({ githubToken })` on next client creation - -### Crash Recovery - -`AgentHostProcessManager` monitors the utility process exit. On unexpected termination, it automatically restarts (up to 5 times). - -## Build / Packaging - -| File | Purpose | -|---|---| -| `build/next/index.ts` | Agent host entry point in esbuild config | -| `build/buildfile.ts` | Agent host entry point in legacy bundler config | -| `build/gulpfile.vscode.ts` | Strip wrong-arch copilot packages; ASAR unpack copilot binaries | -| `build/.moduleignore` | Strip unnecessary copilot prebuilds/ripgrep/clipboard | -| `build/darwin/create-universal-app.ts` | macOS universal binary support for copilot CLI | -| `build/darwin/verify-macho.ts` | Skip copilot binaries in Mach-O verification | - -## Closest Analogs - -| Component | Pattern | Key Difference | -|---|---|---| -| **Pty Host** | Singleton utility process, MessagePort, lazy start, crash recovery | Also has heartbeat monitoring and reconnect logic | -| **Shared Process** | Singleton utility process, MessagePort | Much heavier, hosts many services | -| **Extension Host** | Per-window utility process, custom `RPCProtocol` | Uses custom RPC, not standard channels | diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 94b2c6cb56d..35e4358c7e3 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -20,6 +20,8 @@ export const enum AgentHostIpcChannels { AgentHost = 'agentHost', /** Channel for log forwarding from the agent host process */ Logger = 'agentHostLogger', + /** Channel for WebSocket client connection count (server process management only) */ + ConnectionTracker = 'agentHostConnectionTracker', } /** Configuration key that controls whether the agent host process is spawned. */ diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 20df9b72167..4ccb5794017 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,6 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; +import { Emitter } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import * as os from 'os'; @@ -73,8 +74,18 @@ function startAgentHost(): void { const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); + // Expose the WebSocket client connection count to the parent process via IPC. + // This is NOT part of the agent host protocol -- it is only used by the + // server process to manage the agent host process lifetime. + const connectionCountEmitter = disposables.add(new Emitter()); + const connectionTrackerChannel = ProxyChannel.fromService( + { onDidChangeConnectionCount: connectionCountEmitter.event }, + disposables, + ); + server.registerChannel(AgentHostIpcChannels.ConnectionTracker, connectionTrackerChannel); + // Start WebSocket server for external clients if configured - startWebSocketServer(agentService, logService, disposables).catch(err => { + startWebSocketServer(agentService, logService, disposables, count => connectionCountEmitter.fire(count)).catch(err => { logService.error('Failed to start WebSocket server', err); }); @@ -91,7 +102,7 @@ function startAgentHost(): void { * This reuses the same {@link AgentService} and {@link SessionStateManager} * that the IPC channel uses, so both IPC and WebSocket clients share state. */ -async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore): Promise { +async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore, onConnectionCountChanged: (count: number) => void): Promise { const port = process.env['VSCODE_AGENT_HOST_PORT']; const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH']; @@ -163,7 +174,8 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog }, }; - disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + const protocolHandler = disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + disposables.add(protocolHandler.onDidChangeConnectionCount(onConnectionCountChanged)); const listenTarget = socketPath ?? `${host}:${port}`; logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a7e1ecc5b59..a8985739015 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; @@ -86,6 +87,11 @@ export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); private readonly _replayBuffer: IActionEnvelope[] = []; + private readonly _onDidChangeConnectionCount = this._register(new Emitter()); + + /** Fires with the current client count whenever a client connects or disconnects. */ + readonly onDidChangeConnectionCount = this._onDidChangeConnectionCount.event; + constructor( private readonly _stateManager: SessionStateManager, private readonly _server: IProtocolServer, @@ -171,9 +177,10 @@ export class ProtocolServerHandler extends Disposable { })); disposables.add(transport.onClose(() => { - if (client) { + if (client && this._clients.get(client.clientId) === client) { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`); this._clients.delete(client.clientId); + this._onDidChangeConnectionCount.fire(this._clients.size); } disposables.dispose(); })); @@ -205,6 +212,7 @@ export class ProtocolServerHandler extends Disposable { disposables, }; this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); const snapshots: IStateSnapshot[] = []; if (params.initialSubscriptions) { @@ -243,6 +251,7 @@ export class ProtocolServerHandler extends Disposable { disposables, }; this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; const canReplay = params.lastSeenServerSeq >= oldestBuffered; diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3eac4562547..d433bbdc60f 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -124,6 +124,7 @@ suite('ProtocolServerHandler', () => { let stateManager: SessionStateManager; let server: MockProtocolServer; let sideEffects: MockSideEffectHandler; + let handler: ProtocolServerHandler; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); @@ -154,7 +155,7 @@ suite('ProtocolServerHandler', () => { stateManager = disposables.add(new SessionStateManager(new NullLogService())); server = disposables.add(new MockProtocolServer()); sideEffects = new MockSideEffectHandler(); - disposables.add(new ProtocolServerHandler( + disposables.add(handler = new ProtocolServerHandler( stateManager, server, sideEffects, @@ -427,4 +428,45 @@ suite('ProtocolServerHandler', () => { sideEffects.handleAuthenticate = origHandler; }); + + // ---- Connection count event ----------------------------------------- + + test('onDidChangeConnectionCount fires on connect and disconnect', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + const transport = connectClient('client-count-1'); + connectClient('client-count-2'); + transport.simulateClose(); + + assert.deepStrictEqual(counts, [1, 2, 1]); + }); + + test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + // Connect + const transport1 = connectClient('client-rc'); + assert.deepStrictEqual(counts, [1]); + + // Reconnect with same clientId (new transport) + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-rc', + lastSeenServerSeq: 0, + subscriptions: [], + })); + // Count is unchanged because same clientId was overwritten + assert.deepStrictEqual(counts, [1, 1]); + + // Old transport closes - should NOT decrement since it's stale + transport1.simulateClose(); + assert.deepStrictEqual(counts, [1, 1]); + + // New transport closes - should decrement + transport2.simulateClose(); + assert.deepStrictEqual(counts, [1, 1, 0]); + }); }); diff --git a/src/vs/server/node/serverAgentHostManager.ts b/src/vs/server/node/serverAgentHostManager.ts index 1c3cc424e2f..dff86352208 100644 --- a/src/vs/server/node/serverAgentHostManager.ts +++ b/src/vs/server/node/serverAgentHostManager.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; import { IAgentHostConnection, IAgentHostStarter } from '../../platform/agentHost/common/agent.js'; @@ -16,14 +17,26 @@ export const IServerAgentHostManager = createDecorator( /** * Server-specific agent host manager. Eagerly starts the agent host process, - * handles crash recovery, and tracks active agent sessions via - * {@link IServerLifetimeService} to keep the server alive while work is - * in progress. + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when active sessions > 0 OR connected clients > 0. + * It is released only when both are zero. */ export interface IServerAgentHostManager { readonly _serviceBrand: undefined; } +/** + * Proxy interface for the connection tracker IPC channel exposed by the agent + * host process. This is NOT part of the agent host protocol -- it is a + * server-only process-management concern. + */ +interface IConnectionTrackerService { + readonly onDidChangeConnectionCount: Event; +} + enum Constants { MaxRestarts = 5, } @@ -33,9 +46,12 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo private _restartCount = 0; - /** Lifetime token for when agent sessions are active. */ + /** Lifetime token held while sessions are active or clients are connected. */ private readonly _lifetimeToken = this._register(new MutableDisposable()); + private _hasActiveSessions = false; + private _connectionCount = 0; + constructor( private readonly _starter: IAgentHostStarter, @ILogService private readonly _logService: ILogService, @@ -53,14 +69,17 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo this._logService.info('ServerAgentHostManager: agent host started'); // Connect logger channel so agent host logs appear in the output channel - this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); + connection.store.add(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); this._trackActiveSessions(connection); + this._trackClientConnections(connection); // Handle unexpected exit - this._register(connection.onDidProcessExit(e => { + connection.store.add(connection.onDidProcessExit(e => { if (!this._store.isDisposed) { - // Sessions are gone when the process exits + // Both signals are gone when the process exits + this._hasActiveSessions = false; + this._connectionCount = 0; this._lifetimeToken.clear(); if (this._restartCount <= Constants.MaxRestarts) { @@ -79,14 +98,27 @@ export class ServerAgentHostManager extends Disposable implements IServerAgentHo private _trackActiveSessions(connection: IAgentHostConnection): void { const agentService = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.AgentHost)); - this._register(agentService.onDidAction(envelope => { + connection.store.add(agentService.onDidAction(envelope => { if (envelope.action.type === 'root/activeSessionsChanged') { - if (envelope.action.activeSessions > 0) { - this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentSession'); - } else { - this._lifetimeToken.clear(); - } + this._hasActiveSessions = envelope.action.activeSessions > 0; + this._updateLifetimeToken(); } })); } + + private _trackClientConnections(connection: IAgentHostConnection): void { + const connectionTracker = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.ConnectionTracker)); + connection.store.add(connectionTracker.onDidChangeConnectionCount(count => { + this._connectionCount = count; + this._updateLifetimeToken(); + })); + } + + private _updateLifetimeToken(): void { + if (this._hasActiveSessions || this._connectionCount > 0) { + this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentHost'); + } else { + this._lifetimeToken.clear(); + } + } } diff --git a/src/vs/server/test/node/serverAgentHostManager.test.ts b/src/vs/server/test/node/serverAgentHostManager.test.ts new file mode 100644 index 00000000000..81ec4b695e6 --- /dev/null +++ b/src/vs/server/test/node/serverAgentHostManager.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { IChannel, IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../../../platform/agentHost/common/agent.js'; +import { AgentHostIpcChannels } from '../../../platform/agentHost/common/agentService.js'; +import { NullLogService, NullLoggerService } from '../../../platform/log/common/log.js'; +import { ServerAgentHostManager } from '../../node/serverAgentHostManager.js'; +import { IServerLifetimeService } from '../../node/serverLifetimeService.js'; + +// ---- Mock helpers ----------------------------------------------------------- + +class MockChannel implements IChannel { + private readonly _listeners = new Map>(); + private readonly _callResults = new Map(); + + getEmitter(event: string): Emitter { + let emitter = this._listeners.get(event); + if (!emitter) { + emitter = new Emitter(); + this._listeners.set(event, emitter); + } + return emitter; + } + + setCallResult(command: string, value: unknown): void { + this._callResults.set(command, value); + } + + call(command: string, _arg?: unknown): Promise { + return Promise.resolve((this._callResults.get(command) ?? undefined) as T); + } + + listen(event: string, _arg?: unknown): Event { + return this.getEmitter(event).event as Event; + } + + dispose(): void { + for (const emitter of this._listeners.values()) { + emitter.dispose(); + } + this._listeners.clear(); + } +} + +class MockAgentHostStarter implements IAgentHostStarter { + private readonly _onDidProcessExit = new Emitter<{ code: number; signal: string }>(); + + readonly agentHostChannel = new MockChannel(); + readonly loggerChannel: MockChannel; + readonly connectionTrackerChannel = new MockChannel(); + + constructor() { + this.loggerChannel = new MockChannel(); + this.loggerChannel.setCallResult('getRegisteredLoggers', []); + } + + start(): IAgentHostConnection { + const store = new DisposableStore(); + const client: IChannelClient = { + getChannel: (name: string): T => { + switch (name) { + case AgentHostIpcChannels.AgentHost: + return this.agentHostChannel as unknown as T; + case AgentHostIpcChannels.Logger: + return this.loggerChannel as unknown as T; + case AgentHostIpcChannels.ConnectionTracker: + return this.connectionTrackerChannel as unknown as T; + default: + throw new Error(`Unknown channel: ${name}`); + } + }, + }; + return { + client, + store, + onDidProcessExit: this._onDidProcessExit.event, + }; + } + + fireProcessExit(code: number): void { + this._onDidProcessExit.fire({ code, signal: '' }); + } + + dispose(): void { + this._onDidProcessExit.dispose(); + this.agentHostChannel.dispose(); + this.loggerChannel.dispose(); + this.connectionTrackerChannel.dispose(); + } +} + +class MockServerLifetimeService implements IServerLifetimeService { + declare readonly _serviceBrand: undefined; + + private _activeCount = 0; + + get hasActiveConsumers(): boolean { + return this._activeCount > 0; + } + + active(_consumer: string): IDisposable { + this._activeCount++; + return toDisposable(() => { this._activeCount--; }); + } + + delay(): void { } +} + +suite('ServerAgentHostManager', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + let starter: MockAgentHostStarter; + let lifetimeService: MockServerLifetimeService; + + setup(() => { + starter = new MockAgentHostStarter(); + lifetimeService = new MockServerLifetimeService(); + }); + + function createManager(): ServerAgentHostManager { + return ds.add(new ServerAgentHostManager( + starter, + new NullLogService(), + ds.add(new NullLoggerService()), + lifetimeService, + )); + } + + function fireActiveSessions(count: number): void { + starter.agentHostChannel.getEmitter('onDidAction').fire({ + action: { type: 'root/activeSessionsChanged', activeSessions: count }, + serverSeq: 1, + origin: undefined, + }); + } + + function fireConnectionCount(count: number): void { + starter.connectionTrackerChannel.getEmitter('onDidChangeConnectionCount').fire(count); + } + + test('no lifetime token initially', () => { + createManager(); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('acquires token when sessions become active', () => { + createManager(); + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('acquires token when clients connect (no active sessions)', () => { + createManager(); + fireConnectionCount(2); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('releases token only when both sessions and connections are zero', () => { + createManager(); + + // Sessions active, no connections + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections appear too + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Sessions go idle, but connections remain + fireActiveSessions(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections drop to zero -- now both are idle + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('releases token only when connections drop after sessions already idle', () => { + createManager(); + + fireConnectionCount(3); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('process exit resets both signals and clears token', () => { + createManager(); + + fireActiveSessions(2); + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + starter.fireProcessExit(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); +});