From b82a21abd21d2231eb0099ec04328ef5b9cb4bb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 19:03:20 +0000 Subject: [PATCH 01/18] Make onboarding agent cards non-focusable and rename section Co-authored-by: eli-w-king <201316543+eli-w-king@users.noreply.github.com> --- .../welcomeOnboarding/browser/onboardingVariationA.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 8b72fd6a0e939..3a604eba8761b 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1133,7 +1133,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Group 1: Chat modes — Plan / Agent const chatGroup = append(features, $('.onboarding-a-sessions-group')); const chatLabel = append(chatGroup, $('div.onboarding-a-sessions-group-label')); - chatLabel.textContent = localize('onboarding.sessions.group.chat', "Choose Your Agent"); + chatLabel.textContent = localize('onboarding.sessions.group.chat', "Agents Made for the Task"); const chatGrid = append(chatGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(chatGrid, Codicon.listOrdered, @@ -1164,10 +1164,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } private _createFeatureCard(parent: HTMLElement, icon: ThemeIcon, title: string, description?: string): HTMLElement { - const card = this._registerStepFocusable(append(parent, $('div.onboarding-a-feature-card'))); - card.setAttribute('tabindex', '0'); - card.setAttribute('role', 'group'); - card.setAttribute('aria-label', title); + const card = append(parent, $('div.onboarding-a-feature-card')); const iconCol = append(card, $('div.onboarding-a-feature-icon')); iconCol.appendChild(renderIcon(icon)); const textCol = append(card, $('div.onboarding-a-feature-text')); From 182d43f2e43c0b72e105fbb73b0975f2f4d72f5e Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 27 May 2026 14:38:24 -0700 Subject: [PATCH 02/18] Update src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts Co-authored-by: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index e832d22e58f78..eaf15a0e696fb 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1133,7 +1133,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Group 1: Chat modes — Plan / Agent const chatGroup = append(features, $('.onboarding-a-sessions-group')); const chatLabel = append(chatGroup, $('div.onboarding-a-sessions-group-label')); - chatLabel.textContent = localize('onboarding.sessions.group.chat', "Agents Made for the Task"); + chatLabel.textContent = localize('onboarding.sessions.group.chat', "Agents made for the task"); const chatGrid = append(chatGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(chatGrid, Codicon.listOrdered, From a43ae2e8d9380a0e82c62f21ac04e1c71b13c32b Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 27 May 2026 14:38:34 -0700 Subject: [PATCH 03/18] Update src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts Co-authored-by: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index eaf15a0e696fb..855baf7f77061 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1147,7 +1147,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Group 2: ways to run and customize agents beyond the default Chat experience const moreGroup = append(features, $('.onboarding-a-sessions-group')); const moreLabel = append(moreGroup, $('div.onboarding-a-sessions-group-label')); - moreLabel.textContent = localize('onboarding.sessions.group.more', "Agents That Work Your Way"); + moreLabel.textContent = localize('onboarding.sessions.group.more', "Agents that work your way"); const moreGrid = append(moreGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(moreGrid, Codicon.rocket, From 6418c95ec259c1f683c2943f990957a0df7fc9a8 Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 27 May 2026 14:38:44 -0700 Subject: [PATCH 04/18] Update src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts Co-authored-by: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 855baf7f77061..ad276fc2c496f 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1138,7 +1138,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._createFeatureCard(chatGrid, Codicon.listOrdered, localize('onboarding.sessions.planMode', "Plan"), - localize('onboarding.sessions.planMode.desc', "Produce a structured implementation plan before any code changes, then hand it off to an implementation agent to execute.")); + localize('onboarding.sessions.planMode.desc', "Produce a structured implementation plan before any code changes, then hand it off to an agent to execute.")); this._createFeatureCard(chatGrid, Codicon.commentDiscussion, localize('onboarding.sessions.agentMode', "Agent"), From cd9b8c778c86d5b6f03e8877d5dca4fdad823004 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 27 May 2026 23:59:14 +0200 Subject: [PATCH 05/18] multiple panes improvements --- .../lib/stylelint/vscode-known-variables.json | 6 +++ src/vs/platform/list/browser/listService.ts | 14 ++++-- src/vs/sessions/browser/parts/chatView.ts | 10 +++++ .../browser/parts/media/chatCompositeBar.css | 4 +- .../browser/parts/media/sessionsPart.css | 10 +++++ src/vs/sessions/browser/parts/sessionView.ts | 43 ++++++++++++++++++- src/vs/sessions/browser/parts/sessionsPart.ts | 4 +- src/vs/sessions/common/contextkeys.ts | 1 + src/vs/sessions/common/theme.ts | 20 +++++++++ .../sessions/contrib/chat/browser/chatView.ts | 35 ++++++++++----- .../layout/browser/sessionLayoutController.ts | 15 +++++-- .../sessions/browser/sessionsActions.ts | 4 +- .../chat/browser/widget/chatListWidget.ts | 27 ++++++++++++ .../chat/browser/widget/chatOptions.ts | 13 ++++-- .../contrib/chat/browser/widget/chatWidget.ts | 22 +++++++++- 15 files changed, 200 insertions(+), 28 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 3040277d65e4f..6388e65759334 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -12,6 +12,8 @@ "--vscode-activityBarBadge-background", "--vscode-activityBarBadge-foreground", "--vscode-activityBarTop-activeBackground", + "--vscode-activeSessionView-background", + "--vscode-activeSessionView-foreground", "--vscode-activityBarTop-activeBorder", "--vscode-activityBarTop-background", "--vscode-activityBarTop-dropBorder", @@ -25,6 +27,8 @@ "--vscode-agentSessionReadIndicator-foreground", "--vscode-agentSessionSelectedBadge-border", "--vscode-agentSessionSelectedUnfocusedBadge-border", + "--vscode-inactiveSessionView-background", + "--vscode-inactiveSessionView-foreground", "--vscode-agentStatusIndicator-background", "--vscode-badge-background", "--vscode-badge-foreground", @@ -947,6 +951,8 @@ "--mobile-diff-tok-keyword", "--mobile-diff-tok-number", "--chat-editing-last-edit-shift", + "--session-view-background", + "--session-view-foreground", "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", diff --git a/src/vs/platform/list/browser/listService.ts b/src/vs/platform/list/browser/listService.ts index 6bce710170452..da82630110aee 100644 --- a/src/vs/platform/list/browser/listService.ts +++ b/src/vs/platform/list/browser/listService.ts @@ -849,9 +849,12 @@ function createKeyboardNavigationEventFilter(keybindingService: IKeybindingServi }; } -export interface IWorkbenchObjectTreeOptions extends IObjectTreeOptions, IResourceNavigatorOptions { - readonly accessibilityProvider: IListAccessibilityProvider; +export interface IWorkbenchObjectTreeOptionsUpdate extends IAbstractTreeOptionsUpdate { readonly overrideStyles?: IStyleOverride; +} + +export interface IWorkbenchObjectTreeOptions extends IObjectTreeOptions, IWorkbenchObjectTreeOptionsUpdate, IResourceNavigatorOptions { + readonly accessibilityProvider: IListAccessibilityProvider; readonly selectionNavigation?: boolean; readonly scrollToActiveElement?: boolean; } @@ -882,8 +885,13 @@ export class WorkbenchObjectTree, TFilterData = void> this.disposables.add(this.internals); } - override updateOptions(options: IAbstractTreeOptionsUpdate): void { + override updateOptions(options: IWorkbenchObjectTreeOptionsUpdate = {}): void { super.updateOptions(options); + + if (options.overrideStyles) { + this.internals.updateStyleOverrides(options.overrideStyles); + } + this.internals.updateOptions(options); } } diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 395381d6d7372..f3d87695868fd 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -63,6 +63,16 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + /** + * Notifies the view whether it is the currently active session in the + * sessions grid. Subclasses may use this to adjust their visual styling + * (e.g. the chat list's background color). The default implementation + * is a no-op. + */ + setActive(_active: boolean): void { + // no-op by default + } + /** * Called by the workbench grid to size this leaf. Sizes {@link element} * to the allocated dimensions and then delegates to {@link doLayout} so diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 8dd5b8d7d9e15..83eed2d8417a9 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -6,7 +6,8 @@ .chat-composite-bar { display: flex; align-items: center; - background-color: var(--chat-bar-background); + background-color: var(--session-view-background); + color: var(--session-view-foreground); padding: 0 4px; height: 35px; flex-shrink: 0; @@ -58,7 +59,6 @@ border-radius: 4px; user-select: none; flex-shrink: 0; - min-width: 44px; max-width: min(200px, 40cqi); } diff --git a/src/vs/sessions/browser/parts/media/sessionsPart.css b/src/vs/sessions/browser/parts/media/sessionsPart.css index f29ff46901426..7f3b72c1363d9 100644 --- a/src/vs/sessions/browser/parts/media/sessionsPart.css +++ b/src/vs/sessions/browser/parts/media/sessionsPart.css @@ -14,6 +14,16 @@ background-color: var(--vscode-agentsPanel-background); } +.monaco-workbench .part.sessionspart .session-view, +.monaco-workbench .part.sessionspart .session-view > .session-view-content { + background-color: var(--session-view-background); + color: var(--session-view-foreground); +} + +.monaco-workbench .part.sessionspart .session-view .interactive-session { + --vscode-interactive-session-foreground: var(--session-view-foreground) !important; +} + .monaco-workbench .part.sessionspart > .content > .monaco-progress-container { top: 0; } diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index e4159295e2750..3fb055135907e 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -11,12 +11,14 @@ import { URI } from '../../../base/common/uri.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../platform/instantiation/common/serviceCollection.js'; import { IContextKey, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { asCssVariable } from '../../../platform/theme/common/colorUtils.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { AbstractChatView, ChatViewKind } from './chatView.js'; import { ChatCompositeBar } from './chatCompositeBar.js'; import { autorun } from '../../../base/common/observable.js'; -import { SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext } from '../../common/contextkeys.js'; +import { SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext } from '../../common/contextkeys.js'; +import { activeSessionViewBackground, activeSessionViewForeground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../common/theme.js'; import { SessionStatus } from '../../services/sessions/common/session.js'; /** @@ -31,6 +33,10 @@ import { SessionStatus } from '../../services/sessions/common/session.js'; export class SessionView extends Disposable implements ISerializableView { static readonly TYPE = 'sessions.sessionView'; + private static readonly ACTIVE_BACKGROUND = asCssVariable(activeSessionViewBackground); + private static readonly ACTIVE_FOREGROUND = asCssVariable(activeSessionViewForeground); + private static readonly INACTIVE_BACKGROUND = asCssVariable(inactiveSessionViewBackground); + private static readonly INACTIVE_FOREGROUND = asCssVariable(inactiveSessionViewForeground); /** Height of the chat composite bar when visible. */ private static readonly BAR_HEIGHT = 35; @@ -56,6 +62,10 @@ export class SessionView extends Disposable implements ISerializableView { private readonly _sessionIsCreatedKey: IContextKey; private readonly _sessionIsStickyKey: IContextKey; private readonly _sessionIsMaximizedKey: IContextKey; + private readonly _sessionSupportsMultipleChatsKey: IContextKey; + + /** Whether this view currently hosts the active session in the grid. */ + private _isActive = true; constructor( @IChatViewFactory private readonly chatViewFactory: IChatViewFactory, @@ -70,6 +80,7 @@ export class SessionView extends Disposable implements ISerializableView { this._sessionIsCreatedKey = SessionIsCreatedContext.bindTo(scopedContextKeyService); this._sessionIsStickyKey = SessionIsStickyContext.bindTo(scopedContextKeyService); this._sessionIsMaximizedKey = SessionIsMaximizedContext.bindTo(scopedContextKeyService); + this._sessionSupportsMultipleChatsKey = SessionSupportsMultipleChatsContext.bindTo(scopedContextKeyService); const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); @@ -78,6 +89,7 @@ export class SessionView extends Disposable implements ISerializableView { this._contentContainer = $('.session-view-content'); this.element.appendChild(this._contentContainer); + this._applyActiveSessionStyles(); // Re-layout children when the composite bar becomes visible/hidden this._register(this._compositeBar.onDidChangeVisibility(() => this._layoutChildren())); @@ -86,7 +98,7 @@ export class SessionView extends Disposable implements ISerializableView { openSession(session: IActiveSession | undefined): void { this._openSessionDisposables.clear(); - this._handleContextKeys(session); + this._openSessionDisposables.add(this._handleContextKeys(session)); this._openSessionDisposables.add(autorun(reader => { let desiredKind: ChatViewKind; @@ -106,6 +118,7 @@ export class SessionView extends Disposable implements ISerializableView { : this.chatViewFactory.createNewChatView(desiredKind === 'newChatInSession'); this._contentContainer.replaceChildren(view.element); this._currentView.value = view; + view.setActive(this._isActive); } if (session) { @@ -121,6 +134,7 @@ export class SessionView extends Disposable implements ISerializableView { if (!session) { this._sessionIsCreatedKey.set(false); this._sessionIsStickyKey.set(false); + this._sessionSupportsMultipleChatsKey.set(false); return Disposable.None; } @@ -133,6 +147,8 @@ export class SessionView extends Disposable implements ISerializableView { this._sessionIsStickyKey.set(session.sticky.read(reader)); })); + this._sessionSupportsMultipleChatsKey.set(session.capabilities.supportsMultipleChats); + return disposables; } @@ -170,4 +186,27 @@ export class SessionView extends Disposable implements ISerializableView { setMaximized(maximized: boolean): void { this._sessionIsMaximizedKey.set(maximized); } + + /** + * Updates whether this view currently hosts the active session in the grid. + * Forwarded to the inner chat view so it can adjust its visual styling + * (e.g. dim the list background for inactive sessions). + */ + setActive(active: boolean): void { + if (this._isActive === active) { + return; + } + this._isActive = active; + this._applyActiveSessionStyles(); + this._currentView.value?.setActive(active); + } + + private _applyActiveSessionStyles(): void { + const background = this._isActive ? SessionView.ACTIVE_BACKGROUND : SessionView.INACTIVE_BACKGROUND; + const foreground = this._isActive ? SessionView.ACTIVE_FOREGROUND : SessionView.INACTIVE_FOREGROUND; + this.element.style.setProperty('--session-view-background', background); + this.element.style.setProperty('--session-view-foreground', foreground); + this.element.style.setProperty('--part-background', background); + this.element.style.setProperty('--part-foreground', foreground); + } } diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 916d7140b4eda..fd2a89b427148 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -236,7 +236,9 @@ export class SessionsPart extends Part { // Mark the active session's element for styling/focus indication. const activeId = active?.sessionId; for (const [key, slot] of this._views) { - slot.view.element.classList.toggle('is-active', key === activeId); + const isActive = key === activeId; + slot.view.element.classList.toggle('is-active', isActive); + slot.view.setActive(isActive); } this._updateContextKeys(visible); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index b7a9e5a9efdde..d6482cd85d5af 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -24,6 +24,7 @@ export const ChatSessionProviderIdContext = new RawContextKey('chatSessi export const SessionIsCreatedContext = new RawContextKey('sessionIsCreated', false, localize('sessionIsCreated', "Whether the session view's session has been created (chat view shown, not new-session view)")); export const SessionIsStickyContext = new RawContextKey('sessionIsSticky', false, localize('sessionIsSticky', "Whether the session view's session is sticky in the grid")); export const SessionIsMaximizedContext = new RawContextKey('sessionIsMaximized', false, localize('sessionIsMaximized', "Whether the session view is currently maximized in the sessions part's grid")); +export const SessionSupportsMultipleChatsContext = new RawContextKey('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); //#endregion diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index d6ed3b6226915..fcdd0b4eba062 100644 --- a/src/vs/sessions/common/theme.ts +++ b/src/vs/sessions/common/theme.ts @@ -162,3 +162,23 @@ export const agentsUnreadBadgeForeground = registerColor( 'agentsUnreadBadge.foreground', ACTIVITY_BAR_BADGE_FOREGROUND, localize('agentsUnreadBadge.foreground', 'Foreground color of the unread sessions count badge on the sidebar toggle.') ); + +export const activeSessionViewBackground = registerColor( + 'activeSessionView.background', agentsPanelBackground, + localize('activeSessionView.background', 'Background color of an active session view in the agent sessions window.') +); + +export const inactiveSessionViewBackground = registerColor( + 'inactiveSessionView.background', agentsChatInputBackground, + localize('inactiveSessionView.background', 'Background color of an inactive session view in the agent sessions window.') +); + +export const activeSessionViewForeground = registerColor( + 'activeSessionView.foreground', agentsPanelForeground, + localize('activeSessionView.foreground', 'Foreground color of an active session view in the agent sessions window.') +); + +export const inactiveSessionViewForeground = registerColor( + 'inactiveSessionView.foreground', agentsPanelForeground, + localize('inactiveSessionView.foreground', 'Foreground color of an inactive session view in the agent sessions window.') +); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 5ae04df6551a9..b3a8f569026e5 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -10,7 +10,6 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { inputBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../workbench/common/theme.js'; import { ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; import { IChatModelReference, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -22,7 +21,8 @@ import { IChat } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { NewChatWidget } from './newChatViewPane.js'; import { NewChatInSessionWidget } from './newChatInSessionViewPane.js'; -import { agentsPanelBackground, agentsPanelForeground } from '../../../common/theme.js'; +import { activeSessionViewBackground, activeSessionViewForeground, agentsPanelBackground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js'; +import { isEqual } from '../../../../base/common/resources.js'; /** * A session view that hosts a {@link NewChatWidget} — the "new session" UI @@ -89,6 +89,9 @@ export class ChatView extends AbstractChatView { /** Tracks the currently loaded chat resource to avoid redundant reloads. */ private _currentChatResource: URI | undefined; + /** Whether this view currently represents the active session. */ + private _isActive = true; + constructor( @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -123,18 +126,22 @@ export class ChatView extends AbstractChatView { inputEditorMinLines: 2, isSessionsWindow: true }, - { - listForeground: agentsPanelForeground, - listBackground: agentsPanelBackground, - overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, - inputEditorBackground: inputBackground, - resultEditorBackground: agentsPanelBackground, - } + this._buildStyles(this._isActive) )); this._widget.render(this.element); this._widget.setVisible(true); } + private _buildStyles(active: boolean) { + return { + listForeground: active ? activeSessionViewForeground : inactiveSessionViewForeground, + listBackground: active ? activeSessionViewBackground : inactiveSessionViewBackground, + overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, + inputEditorBackground: inactiveSessionViewBackground, + resultEditorBackground: agentsPanelBackground, + }; + } + /** The underlying chat widget. */ get widget(): ChatWidget { return this._widget; @@ -144,7 +151,7 @@ export class ChatView extends AbstractChatView { const resource = chat.resource; // Skip loading if we're already showing this chat - if (this._currentChatResource?.toString() === resource.toString()) { + if (isEqual(this._currentChatResource, resource)) { return; } @@ -198,6 +205,14 @@ export class ChatView extends AbstractChatView { override focus(): void { this._widget.focusInput(); } + + override setActive(active: boolean): void { + if (this._isActive === active) { + return; + } + this._isActive = active; + this._widget.setStyles(this._buildStyles(active)); + } } /** diff --git a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts index dae63a3b9b02a..7043972e153be 100644 --- a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts @@ -63,6 +63,7 @@ export class LayoutController extends Disposable { private readonly _viewStateBySession: ResourceMap; private readonly _workingSets: ResourceMap; private readonly _workingSetSequencer = new Sequencer(); + private readonly _useModalConfigObs; constructor( @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @@ -234,7 +235,7 @@ export class LayoutController extends Disposable { // --- Editor working sets --- - const useModalConfigObs = observableConfigValue<'off' | 'some' | 'all'>('workbench.editor.useModal', 'all', this._configurationService); + this._useModalConfigObs = observableConfigValue<'off' | 'some' | 'all'>('workbench.editor.useModal', 'all', this._configurationService); // Workspace folders — used to defer session switch until workspace is ready const workspaceFoldersObs = observableFromEvent( @@ -264,7 +265,7 @@ export class LayoutController extends Disposable { }); this._register(autorun(reader => { - const useModalConfig = useModalConfigObs.read(reader); + const useModalConfig = this._useModalConfigObs.read(reader); if (useModalConfig === 'all') { return; } @@ -438,17 +439,23 @@ export class LayoutController extends Disposable { : 'empty'; return this._workingSetSequencer.queue(async () => { + // Switching the active session must never reveal the main editor area + // (or restore editors into it) while modal-only mode is in effect — the + // outer autorun already guards against this, but `useModal` may have + // flipped to 'all' between this call being queued and now. + const isModal = this._useModalConfigObs.get() === 'all'; + if (workingSet === 'empty') { await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); return; } - if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (!isModal && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._layoutService.setPartHidden(false, Parts.EDITOR_PART); } const result = await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); - if (result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (!isModal && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._layoutService.setPartHidden(false, Parts.EDITOR_PART); } }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 326d9ecbd9d59..d3d5308d1e2f6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -18,7 +18,7 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; @@ -200,7 +200,7 @@ registerAction2(class AddChatToSessionBarAction extends Action2 { icon: Codicon.add, menu: { id: Menus.SessionBarInlineToolbar, - when: SessionIsCreatedContext, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext), group: 'navigation', order: 10, }, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 6391bb329b556..ca65a77adc157 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -815,6 +815,33 @@ export class ChatListWidget extends Disposable { this._renderer.updateOptions(options); } + /** + * Update the list/tree color overrides. Re-applies the same fan-out from + * `listBackground`/`listForeground` to all interaction states that was + * originally configured at construction time. + */ + setStyles(styles: IChatListWidgetStyles): void { + this._tree.updateOptions({ + overrideStyles: { + listFocusBackground: styles.listBackground, + listInactiveFocusBackground: styles.listBackground, + listActiveSelectionBackground: styles.listBackground, + listFocusAndSelectionBackground: styles.listBackground, + listInactiveSelectionBackground: styles.listBackground, + listHoverBackground: styles.listBackground, + listBackground: styles.listBackground, + listFocusForeground: styles.listForeground, + listHoverForeground: styles.listForeground, + listInactiveFocusForeground: styles.listForeground, + listInactiveSelectionForeground: styles.listForeground, + listActiveSelectionForeground: styles.listForeground, + listFocusAndSelectionForeground: styles.listForeground, + listActiveSelectionIconForeground: undefined, + listInactiveSelectionIconForeground: undefined, + } + }); + } + /** * Set the visibility of the list. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts b/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts index 2c8a3abf4187a..8c4d2f146cc29 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatOptions.ts @@ -74,9 +74,9 @@ export class ChatEditorOptions extends Disposable { constructor( viewId: string | undefined, - private readonly foreground: string, - private readonly inputEditorBackgroundColor: string, - private readonly resultEditorBackgroundColor: string, + private foreground: string, + private inputEditorBackgroundColor: string, + private resultEditorBackgroundColor: string, @IConfigurationService private readonly configurationService: IConfigurationService, @IThemeService private readonly themeService: IThemeService, @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService @@ -126,4 +126,11 @@ export class ChatEditorOptions extends Disposable { }; this._onDidChange.fire(); } + + setColors(foreground: string, inputEditorBackgroundColor: string, resultEditorBackgroundColor: string): void { + this.foreground = foreground; + this.inputEditorBackgroundColor = inputEditorBackgroundColor; + this.resultEditorBackgroundColor = resultEditorBackgroundColor; + this.update(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 27155977514c7..a0e8e55ecf2b3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -390,7 +390,7 @@ export class ChatWidget extends Disposable implements IChatWidget { location: ChatAgentLocation | IChatWidgetLocationOptions, viewContext: IChatWidgetViewContext | undefined, private readonly viewOptions: IChatWidgetViewOptions, - private readonly styles: IChatWidgetStyles, + private styles: IChatWidgetStyles, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, @IDialogService private readonly dialogService: IDialogService, @@ -2031,6 +2031,26 @@ export class ChatWidget extends Disposable implements IChatWidget { this.container.style.setProperty('--vscode-chat-list-background', this.themeService.getColorTheme().getColor(this.styles.listBackground)?.toString() ?? ''); } + /** + * Updates the widget's color styles after construction. Propagates the new + * `listForeground`/`listBackground` to the list widget, pushes the new color + * tokens into `editorOptions` so subscribers (code blocks, result/input editor + * backgrounds, container CSS variables) pick them up via `onDidChange`, and + * refreshes the CSS variables the chat container exposes for stylesheet rules. + */ + setStyles(styles: IChatWidgetStyles): void { + this.styles = styles; + this.listWidget?.setStyles({ + listForeground: styles.listForeground, + listBackground: styles.listBackground, + }); + if (this.container) { + // Updating editorOptions fires onDidChange which triggers onDidStyleChange + // and also propagates the new colors to subscribers like CodeBlockPart. + this.editorOptions.setColors(styles.listForeground, styles.inputEditorBackground, styles.resultEditorBackground); + } + } + setModel(model: IChatModel | undefined): void { if (!this.container || !this.inputPart) { From 150d40b6323c916a0e1a558b0dea3e8fe8417a83 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 27 May 2026 15:01:37 -0700 Subject: [PATCH 06/18] agentHost: adopt new customization protocol shape Brings the platform and workbench code in line with the reorganized AHP customization types: containers (PluginCustomization, DirectoryCustomization) carry the children (agents, skills, prompts, rules, hooks, MCP servers) discovered inside them. - Replaces CustomizationRef/SessionCustomization/CustomizationAgentRef with the new Customization tree, plus ClientPluginCustomization for client-published plugins. Adds a customizationId(uri, range?) helper for minting session-unique ids that disambiguate inline manifest declarations. - Updates IAgent and IAgentPluginManager: getCustomizations/getSessionCustomizations return Customization[]; setClientCustomizations/syncCustomizations take ClientPluginCustomization[]; setCustomizationEnabled now takes the opaque id. - Reworks copilotAgent's PluginController + SessionDiscoveredEntry around the flat Customization shape with the CustomizationLoadStatus enum and a children array; SessionCustomizationUpdated dispatches the whole entry. - Restores the legacy plugin-only agentHostCustomizationConfigSchema so existing agent-host-config.json files keep working; entries are lifted into the new Customization container shape at read time by getAgentHostConfiguredCustomizations / toContainerCustomization. - Renames toCustomizationAgentRefs to toAgentCustomizations; drops the redundant enabled flag on ChildCustomization (children inherit their container's enablement). customAgents.getEffectiveAgents now walks children filtered by CustomizationType.Agent. - Updates the workbench item provider, active-client service, harnesses, custom-agent pickers, and the remote harness to the new types; toStatusString/toStatusMessage drive UI status off CustomizationLoadStatus. - Adapts all consumer tests (reducers, agentPluginManager, agentSideEffects, copilotAgent, mockAgent, syncedCustomizationBundler, resolveCustomizationRefs, localAgentHostSessionsProvider, remoteAgentHostCustomizationHarness, agentHostAgents/AgentPicker) to the new shapes. - Cleans up a stale URI auto-generated import in channels-session/actions.ts. (Commit message generated by Copilot) --- .../browser/remoteAgentHostProtocolClient.ts | 4 +- .../common/agentHostCustomizationConfig.ts | 45 +- .../agentHost/common/agentPluginManager.ts | 11 +- .../platform/agentHost/common/agentService.ts | 16 +- .../platform/agentHost/common/customAgents.ts | 41 +- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 5 +- .../state/protocol/channels-root/state.ts | 15 +- .../protocol/channels-session/actions.ts | 70 ++-- .../protocol/channels-session/reducer.ts | 62 +-- .../state/protocol/channels-session/state.ts | 391 +++++++++++++++--- .../common/state/protocol/common/actions.ts | 4 +- .../common/state/protocol/version/registry.ts | 1 + .../agentHost/common/state/sessionState.ts | 37 +- .../node/agentHostSkillCompletionProvider.ts | 25 +- .../agentHost/node/agentPluginManager.ts | 12 +- .../agentHost/node/agentSideEffects.ts | 2 +- .../agentHost/node/claude/claudeAgent.ts | 4 +- .../agentHost/node/copilot/copilotAgent.ts | 115 +++--- .../node/copilot/copilotPluginConverters.ts | 25 +- .../node/shared/sessionPluginBundler.ts | 19 +- .../remoteAgentHostProtocolClient.test.ts | 14 +- .../agentHostSkillCompletionProvider.test.ts | 18 +- .../test/node/agentPluginManager.test.ts | 35 +- .../agentHost/test/node/agentService.test.ts | 4 +- .../test/node/agentSideEffects.test.ts | 98 ++--- .../agentHost/test/node/copilotAgent.test.ts | 28 +- .../platform/agentHost/test/node/mockAgent.ts | 17 +- .../agentHost/test/node/reducers.test.ts | 88 ++-- .../sessionCustomizationDiscovery.test.ts | 2 +- .../common/agentHostSessionsProvider.ts | 4 +- .../agentHost/browser/agentHostAgentPicker.ts | 22 +- .../browser/baseAgentHostSessionsProvider.ts | 4 +- .../test/browser/agentHostAgentPicker.test.ts | 25 +- .../test/browser/agentHostAgents.test.ts | 55 ++- .../localAgentHostSessionsProvider.test.ts | 101 +++-- .../remoteAgentHostCustomizationHarness.ts | 28 +- ...emoteAgentHostCustomizationHarness.test.ts | 106 +++-- .../agentCustomizationItemProvider.ts | 69 ++-- .../agentHost/agentHostActiveClientService.ts | 15 +- .../agentHost/agentHostCustomAgentPicker.ts | 8 +- .../agentHost/agentHostLocalCustomizations.ts | 18 +- .../agentHost/agentHostSessionHandler.ts | 6 +- .../agentHost/syncedCustomizationBundler.ts | 17 +- .../agentHostChatContribution.test.ts | 26 +- .../resolveCustomizationRefs.test.ts | 6 +- .../syncedCustomizationBundler.test.ts | 4 +- 47 files changed, 1026 insertions(+), 698 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 489df63e26c30..0d55aec6b497b 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -24,7 +24,7 @@ import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/ import { AgentHostPermissionMode, IAgentHostPermissionService } from '../common/agentHostPermissionService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type CustomizationRef, type RootState } from '../common/state/sessionState.js'; +import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type ClientPluginCustomization, type RootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; @@ -873,7 +873,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC * the customization, but should not need to write them. Grants are * deduped per connection and revoked when the connection closes. */ - private _grantImplicitReadsForCustomizations(refs: readonly CustomizationRef[]): void { + private _grantImplicitReadsForCustomizations(refs: readonly ClientPluginCustomization[]): void { for (const ref of refs) { let uri: URI; try { diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index 421d067001a08..c185d3c41be55 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -5,7 +5,8 @@ import { localize } from '../../../nls.js'; import { createSchema, schemaProperty } from './agentHostSchema.js'; -import { type CustomizationRef } from './state/protocol/state.js'; +import { CustomizationType, type Customization } from './state/protocol/state.js'; +import { customizationId } from './state/sessionState.js'; /** * Well-known root-config keys used by the platform to configure agent-host @@ -23,8 +24,21 @@ export const enum AgentHostConfigKey { DisableCustomTerminalTool = 'disableCustomTerminalTool', } +/** + * Persisted on-disk shape for a host-configured plugin. Kept stable across + * the customization protocol refactor so existing `agent-host-config.json` + * files keep working; entries are mapped to the new + * {@link Customization} shape at read time by + * {@link getAgentHostConfiguredCustomizations}. + */ +interface IPersistedCustomizationConfigEntry { + uri: string; + displayName: string; + description?: string; +} + export const agentHostCustomizationConfigSchema = createSchema({ - [AgentHostConfigKey.Customizations]: schemaProperty({ + [AgentHostConfigKey.Customizations]: schemaProperty({ type: 'array', title: localize('agentHost.config.customizations.title', "Plugins"), description: localize('agentHost.config.customizations.description', "Plugins configured on this agent host and available to remote sessions."), @@ -63,12 +77,33 @@ export const agentHostCustomizationConfigSchema = createSchema({ }); export const defaultAgentHostCustomizationConfigValues = { - [AgentHostConfigKey.Customizations]: [] as CustomizationRef[], + [AgentHostConfigKey.Customizations]: [] as IPersistedCustomizationConfigEntry[], }; -export function getAgentHostConfiguredCustomizations(values: Record | undefined): readonly CustomizationRef[] { +/** + * Reads the persisted (legacy-shaped) plugin entries from the agent-host + * root config and lifts them into the new {@link Customization} container + * shape used by the rest of the platform. + */ +export function getAgentHostConfiguredCustomizations(values: Record | undefined): readonly Customization[] { const raw = values?.[AgentHostConfigKey.Customizations]; - return agentHostCustomizationConfigSchema.validate(AgentHostConfigKey.Customizations, raw) + const entries = agentHostCustomizationConfigSchema.validate(AgentHostConfigKey.Customizations, raw) ? raw : defaultAgentHostCustomizationConfigValues[AgentHostConfigKey.Customizations]; + return entries.map(toContainerCustomization); } + +/** + * Lifts a persisted plugin config entry into the new + * {@link Customization} container shape. + */ +export function toContainerCustomization(entry: IPersistedCustomizationConfigEntry): Customization { + return { + type: CustomizationType.Plugin, + id: customizationId(entry.uri), + uri: entry.uri, + name: entry.displayName, + enabled: true, + }; +} + diff --git a/src/vs/platform/agentHost/common/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts index 5f0a94c815bd5..2c2517f3c9fb4 100644 --- a/src/vs/platform/agentHost/common/agentPluginManager.ts +++ b/src/vs/platform/agentHost/common/agentPluginManager.ts @@ -5,7 +5,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import type { CustomizationRef, SessionCustomization } from './state/sessionState.js'; +import type { ClientPluginCustomization, Customization } from './state/sessionState.js'; export const IAgentPluginManager = createDecorator('agentPluginManager'); @@ -14,7 +14,7 @@ export const IAgentPluginManager = createDecorator('agentPl */ export interface ISyncedCustomization { /** The session customization with loading/error status. */ - readonly customization: SessionCustomization; + readonly customization: Customization; /** Local plugin directory URI, defined when the sync was successful. */ readonly pluginDir?: URI; } @@ -38,9 +38,9 @@ export interface IAgentPluginManager { readonly basePath: URI; /** - * Syncs a set of client-provided customization refs to local storage. + * Syncs a set of client-provided plugin customizations to local storage. * - * Each ref is copied to a local directory, respecting nonce-based + * Each plugin is copied to a local directory, respecting nonce-based * caching. The optional {@link progress} callback fires with the single * customization that completed or failed, allowing callers to publish * targeted incremental status updates. @@ -51,5 +51,6 @@ export interface IAgentPluginManager { * @returns Final status for every customization, with `pluginDir` * defined when the sync was successful. */ - syncCustomizations(clientId: string, customizations: CustomizationRef[], progress?: (status: SessionCustomization) => void): Promise; + syncCustomizations(clientId: string, customizations: ClientPluginCustomization[], progress?: (status: Customization) => void): Promise; } + diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index b60a75a0367fb..0ae473a71a593 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -18,7 +18,7 @@ import type { CompletionsParams, CompletionsResult, CreateTerminalParams, Resolv import { ProtectedResourceMetadata, type ChangesetSummary, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionCustomization, type SessionInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; +import { ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type SessionInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -636,17 +636,19 @@ export interface IAgent { readonly onDidCustomizationsChange?: Event; /** - * Returns the host-owned customization refs this agent currently exposes. + * Returns the host-owned customizations this agent currently exposes. * * Used to publish baseline customization metadata on {@link AgentInfo}. + * Always container customizations ({@link PluginCustomization} or + * {@link DirectoryCustomization}). */ - getCustomizations?(): readonly CustomizationRef[]; + getCustomizations?(): readonly Customization[]; /** * Returns the effective customization list for a session, including * source, enablement, and loading/error status. */ - getSessionCustomizations?(session: URI): Promise; + getSessionCustomizations?(session: URI): Promise; /** * Authenticate for a specific resource. Returns true if accepted. @@ -676,7 +678,7 @@ export interface IAgent { * * The agent MAY defer a client restart until all active sessions are idle. */ - setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise; + setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise; /** * Receives client-provided tool definitions to make available in a @@ -705,8 +707,10 @@ export interface IAgent { /** * Notifies the agent that a customization has been toggled on or off. * The agent MAY restart its client before the next message is sent. + * + * @param id The opaque session-unique customization id. */ - setCustomizationEnabled(uri: string, enabled: boolean): void; + setCustomizationEnabled(id: string, enabled: boolean): void; /** Gracefully shut down all sessions. */ shutdown(): Promise; diff --git a/src/vs/platform/agentHost/common/customAgents.ts b/src/vs/platform/agentHost/common/customAgents.ts index ace9077ce4d1c..d99eda46321ac 100644 --- a/src/vs/platform/agentHost/common/customAgents.ts +++ b/src/vs/platform/agentHost/common/customAgents.ts @@ -4,34 +4,38 @@ *--------------------------------------------------------------------------------------------*/ import type { URI } from '../../../base/common/uri.js'; -import type { CustomizationAgentRef, SessionCustomization } from './state/protocol/state.js'; +import { CustomizationType, type AgentCustomization, type Customization } from './state/protocol/state.js'; /** * Computes the effective set of selectable custom agents for a session. * - * Custom agents are contributed exclusively by - * {@link SessionCustomization.agents} — only the agent host populates that - * field after parsing each customization. Disabled session customizations - * are skipped; customizations with an absent `agents` field are treated as - * "unknown" (e.g. the host has not finished parsing yet) and skipped, while - * an empty array means "no agents contributed" and is respected. + * Custom agents live as {@link CustomizationType.Agent | `Agent`} entries + * in each container customization's {@link Customization.children | `children`} + * array. Only the agent host populates `children` (after parsing the + * container). Disabled containers are skipped; containers with an absent + * `children` field are treated as "unknown" (e.g. the host has not finished + * parsing yet) and skipped, while an empty array means "no children + * contributed" and is respected. * - * The picker is keyed on the agent's stable {@link CustomizationAgentRef.uri}; + * The picker is keyed on the agent's stable {@link AgentCustomization.uri}; * duplicates within the session's customization list are coalesced. */ export function getEffectiveAgents( - sessionCustomizations: readonly SessionCustomization[] | undefined, -): readonly CustomizationAgentRef[] { - const seen = new Map(); + sessionCustomizations: readonly Customization[] | undefined, +): readonly AgentCustomization[] { + const seen = new Map(); if (sessionCustomizations) { - for (const customization of sessionCustomizations) { - if (customization.enabled === false || !customization.agents) { + for (const container of sessionCustomizations) { + if (container.enabled === false || !container.children) { continue; } - for (const agent of customization.agents) { - const key = agent.uri.toString(); + for (const child of container.children) { + if (child.type !== CustomizationType.Agent) { + continue; + } + const key = child.uri.toString(); if (!seen.has(key)) { - seen.set(key, agent); + seen.set(key, child); } } } @@ -63,10 +67,10 @@ export function agentHostAgentPickerStorageKey(resourceScheme: string): string { * sessions-layer `ISessionAgentRef` both provide URI strings. */ export function resolveAgentHostAgent( - agents: readonly CustomizationAgentRef[], + agents: readonly AgentCustomization[], sessionAgentUri: URI | string | undefined, storedAgentUri: string | undefined, -): CustomizationAgentRef | undefined { +): AgentCustomization | undefined { if (sessionAgentUri !== undefined) { const sessionStr = typeof sessionAgentUri === 'string' ? sessionAgentUri : sessionAgentUri.toString(); const match = agents.find(a => a.uri === sessionStr); @@ -76,3 +80,4 @@ export function resolveAgentHostAgent( } return storedAgentUri ? agents.find(a => a.uri === storedAgentUri) : undefined; } + diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index c925a0548ec44..d9f882145b047 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -48aaa5d +9f3ca96 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index ea1be673c0ca8..7171e65120bab 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionAgentChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetOperationsChangedAction, type ChangesetClearedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionAgentChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetOperationsChangedAction, type ChangesetClearedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; // ─── Root vs Session vs Terminal vs Changeset Action Unions ───────────────── @@ -68,6 +68,7 @@ export type SessionAction = | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction + | SessionCustomizationRemovedAction | SessionTruncatedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction @@ -119,6 +120,7 @@ export type ServerSessionAction = | SessionInputRequestedAction | SessionCustomizationsChangedAction | SessionCustomizationUpdatedAction + | SessionCustomizationRemovedAction | SessionActivityChangedAction | SessionChangesetsChangedAction | SessionMetaChangedAction @@ -224,6 +226,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionCustomizationUpdated]: false, + [ActionType.SessionCustomizationRemoved]: false, [ActionType.SessionTruncated]: true, [ActionType.SessionIsReadChanged]: true, [ActionType.SessionIsArchivedChanged]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index f2fd5bb682ab8..db7e3b2c328d4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -8,7 +8,7 @@ import type { ConfigSchema, ProtectedResourceMetadata } from '../common/state.js'; import type { TerminalInfo } from '../channels-terminal/state.js'; -import type { CustomizationRef } from '../channels-session/state.js'; +import type { Customization } from '../channels-session/state.js'; // ─── Root State ────────────────────────────────────────────────────────────── @@ -64,12 +64,17 @@ export interface AgentInfo { */ protectedResources?: ProtectedResourceMetadata[]; /** - * Customizations (Open Plugins) associated with this agent. + * Customizations associated with this agent. * - * Each entry is a reference to an [Open Plugins](https://open-plugins.com/) - * plugin that the agent host can activate for sessions using this agent. + * Always container customizations — + * {@link PluginCustomization | `PluginCustomization`} entries the agent + * bundles, plus {@link DirectoryCustomization | `DirectoryCustomization`} + * entries it watches in any workspace it's used with. When a session is + * created with this agent, these entries are augmented (e.g. directory + * URIs are resolved against the workspace, children are parsed) and + * propagated into the session's `customizations` list. */ - customizations?: CustomizationRef[]; + customizations?: Customization[]; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index cddd667d2307c..55ddb371ecdab 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -7,8 +7,8 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import type { URI, StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type UserMessage, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type SessionCustomization, type CustomizationRef, type CustomizationAgentRef, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type CustomizationStatus, type AgentSelection } from './state.js'; +import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type UserMessage, type ResponsePart, type ToolCallResult, type ToolResultContent, type ToolDefinition, type SessionActiveClient, type Customization, type SessionInputAnswer, type SessionInputRequest, type SessionInputResponseKind, type ConfirmationOption, type AgentSelection } from './state.js'; import type { ModelSelection } from '../channels-root/state.js'; import type { ChangesetSummary } from '../channels-changeset/state.js'; @@ -556,15 +556,17 @@ export interface SessionActiveClientToolsChangedAction { */ export interface SessionCustomizationsChangedAction { type: ActionType.SessionCustomizationsChanged; - /** Updated customization list (full replacement) */ - customizations: SessionCustomization[]; + /** Updated customization list (full replacement). */ + customizations: Customization[]; } /** - * A client toggled a customization on or off. + * A client toggled a container customization on or off. * - * The server locates the customization by `uri` in the session's - * customization list and sets its `enabled` flag. + * Targets a top-level container (plugin or directory) by `id`. Only + * containers have an `enabled` flag; children are always active when + * their container is enabled. Is a no-op when no matching container is + * found. * * @category Session Actions * @version 1 @@ -572,45 +574,45 @@ export interface SessionCustomizationsChangedAction { */ export interface SessionCustomizationToggledAction { type: ActionType.SessionCustomizationToggled; - /** The URI of the customization to toggle */ - uri: URI; - /** Whether to enable or disable the customization */ + /** The id of the container to toggle. */ + id: string; + /** Whether to enable or disable the container. */ enabled: boolean; } /** - * Upserts mutable fields on a single customization. + * Upserts a top-level customization (plugin or directory). * - * Dispatched by the server to update one or more fields on a customization, - * or to add a new customization to the session, without republishing the - * entire `customizations` list. The reducer locates the existing entry by - * `customization.uri`: + * The reducer locates the existing entry by `customization.id`: * - * - If an entry exists, each provided field is assigned; absent (or - * `undefined`) fields are left unchanged. The stored `customization` - * ref is replaced with the one in the action. - * - If no entry exists, a new {@link SessionCustomization} is appended - * using the provided fields; `enabled` defaults to `false` when absent. + * - If found, the entry is replaced entirely with `customization`, + * including its `children` array. To preserve existing children, the + * host must include them on the payload. + * - If not found, the entry is appended. * * @category Session Actions * @version 1 */ export interface SessionCustomizationUpdatedAction { type: ActionType.SessionCustomizationUpdated; - /** The customization to update or insert (matched by `customization.uri`) */ - customization: CustomizationRef; - /** New enabled state (defaults to `false` on insert) */ - enabled?: boolean; - /** New loading status */ - status?: CustomizationStatus; - /** New human-readable status detail */ - statusMessage?: string; - /** - * Custom agents contributed by this customization, as resolved by the - * agent host. Populated only by the agent host. See - * {@link SessionCustomization.agents} for absent-vs-empty semantics. - */ - agents?: CustomizationAgentRef[]; + /** The customization to upsert (matched by `customization.id`). */ + customization: Customization; +} + +/** + * Removes a customization by id. + * + * Searches every container and its children for the entry. If the entry + * is a container, its children are removed with it. Is a no-op when no + * matching id is found. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCustomizationRemovedAction { + type: ActionType.SessionCustomizationRemoved; + /** The id of the customization to remove. */ + id: string; } // ─── Config Actions ────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index af8cd33dced3e..7178ccb8c965e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type SessionCustomization, type SessionInputRequest, type SessionState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type SessionInputRequest, type SessionState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js'; import type { SessionAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; @@ -603,7 +603,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: if (!list) { return state; } - const idx = list.findIndex(c => c.customization.uri === action.uri); + const idx = list.findIndex(c => c.id === action.id); if (idx < 0) { return state; } @@ -614,38 +614,44 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: case ActionType.SessionCustomizationUpdated: { const list = state.customizations ?? []; - const idx = list.findIndex(c => c.customization.uri === action.customization.uri); + const idx = list.findIndex(c => c.id === action.customization.id); if (idx < 0) { - const inserted: SessionCustomization = { - customization: action.customization, - enabled: action.enabled ?? false, - }; - if (action.status !== undefined) { - inserted.status = action.status; - } - if (action.statusMessage !== undefined) { - inserted.statusMessage = action.statusMessage; - } - if (action.agents !== undefined) { - inserted.agents = action.agents; - } - return { ...state, customizations: [...list, inserted] }; + return { ...state, customizations: [...list, action.customization] }; } const updated = [...list]; - const next = { ...list[idx], customization: action.customization }; - if (action.enabled !== undefined) { - next.enabled = action.enabled; - } - if (action.status !== undefined) { - next.status = action.status; + updated[idx] = action.customization; + return { ...state, customizations: updated }; + } + + case ActionType.SessionCustomizationRemoved: { + const list = state.customizations; + if (!list) { + return state; } - if (action.statusMessage !== undefined) { - next.statusMessage = action.statusMessage; + const topIdx = list.findIndex(c => c.id === action.id); + if (topIdx >= 0) { + const updated = list.slice(); + updated.splice(topIdx, 1); + return { ...state, customizations: updated }; } - if (action.agents !== undefined) { - next.agents = action.agents; + let changed = false; + const updated = list.map(container => { + const children = container.children; + if (!children) { + return container; + } + const childIdx = children.findIndex(c => c.id === action.id); + if (childIdx < 0) { + return container; + } + changed = true; + const newChildren = children.slice(); + newChildren.splice(childIdx, 1); + return { ...container, children: newChildren }; + }); + if (!changed) { + return state; } - updated[idx] = next; return { ...state, customizations: updated }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index ed598951e4e7a..2998d10f8420b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -106,12 +106,19 @@ export interface SessionState { /** Session configuration schema and current values */ config?: SessionConfigState; /** - * Server-provided customizations active in this session. + * Top-level customizations active in this session. * - * Client-provided customizations are available on - * {@link SessionActiveClient.customizations | activeClient.customizations}. + * Always container customizations — {@link PluginCustomization} or + * {@link DirectoryCustomization}. Children (agents, skills, prompts, + * rules, hooks, MCP servers) live in each container's + * {@link ContainerCustomizationBase.children | `children`} array. + * + * Client-published plugins arrive via + * {@link SessionActiveClient.customizations | `activeClient.customizations`} + * and the host propagates them into this list (typically with the + * container's `clientId` set and `children` populated). */ - customizations?: SessionCustomization[]; + customizations?: Customization[]; /** * Additional provider-specific metadata for this session. * @@ -137,8 +144,15 @@ export interface SessionActiveClient { displayName?: string; /** Tools this client provides to the session */ tools: ToolDefinition[]; - /** Customizations this client contributes to the session */ - customizations?: CustomizationRef[]; + /** + * Plugin customizations this client contributes to the session. + * + * Clients publish in [Open Plugins](https://open-plugins.com/) format + * — i.e. always container-shaped plugins. They MAY synthesize virtual + * plugins in memory and rely on the host to expand them into concrete + * children inside {@link SessionState.customizations}. + */ + customizations?: ClientPluginCustomization[]; } /** @@ -203,18 +217,17 @@ export interface SessionSummary { /** * A selected custom agent for a session. * - * The `uri` identifies a specific custom agent (matching a - * {@link CustomizationAgentRef.uri | `CustomizationAgentRef.uri`} exposed - * via the session's effective customizations). Consumers resolve the - * agent's display name by looking up `uri` in - * {@link SessionCustomization.agents | `SessionCustomization.agents`}. + * The `uri` identifies a specific custom agent (matching an + * {@link AgentCustomization.uri | `AgentCustomization.uri`} exposed via + * the session's effective customizations). Consumers resolve the agent's + * display name by looking up `uri` in the session's customization tree. * * A session with no `agent` selected uses the provider's default behavior. * * @category Session State */ export interface AgentSelection { - /** Stable agent URI (matches a {@link CustomizationAgentRef.uri}) */ + /** Stable agent URI (matches an {@link AgentCustomization.uri}). */ uri: URI; } @@ -1255,97 +1268,345 @@ export type ToolResultContent = // ─── Customization Types ───────────────────────────────────────────────────── /** - * A lightweight reference to a custom agent contributed by a customization. + * Discriminant for the kind of customization. * - * Custom agents have a single `name` (sourced from the agent file's YAML - * frontmatter, or derived from the file name); they do not have a separate - * display name. + * Top-level entries in {@link SessionState.customizations} and + * {@link AgentInfo.customizations} are always + * {@link CustomizationType.Plugin | `Plugin`} or + * {@link CustomizationType.Directory | `Directory`}; the remaining + * types appear only as children of those containers. * * @category Customization Types */ -export interface CustomizationAgentRef { - /** Stable agent URI */ - uri: URI; - /** Agent name (from frontmatter `name`, or file-derived) */ - name: string; - /** Optional short description for UI preview (from frontmatter `description`) */ - description?: string; +export const enum CustomizationType { + Plugin = 'plugin', + Directory = 'directory', + Agent = 'agent', + Skill = 'skill', + Prompt = 'prompt', + Rule = 'rule', + Hook = 'hook', + McpServer = 'mcpServer', } /** - * A reference to an [Open Plugins](https://open-plugins.com/) plugin. + * Customization types that appear as children of a + * {@link PluginCustomization} or {@link DirectoryCustomization}. * - * This is intentionally thin — AHP specifies plugin identity and metadata - * but not implementation details, which are defined by the Open Plugins spec. + * @category Customization Types + */ +export type ChildCustomizationType = + | CustomizationType.Agent + | CustomizationType.Skill + | CustomizationType.Prompt + | CustomizationType.Rule + | CustomizationType.Hook + | CustomizationType.McpServer; + +/** + * Fields shared by every customization variant. * * @category Customization Types */ -export interface CustomizationRef { - /** Plugin URI (e.g. an HTTPS URL or marketplace identifier) */ +interface CustomizationBase { + /** + * Session-unique opaque identifier. Used by every action that targets a + * specific customization. Minted by whoever publishes the customization + * (typically the agent host). + */ + id: string; + /** + * Source URI for this customization. A plugin URL, a file URI, or a + * directory URI. + * + * For declarations that live inside a larger file — e.g. an MCP + * server declared inline in a `plugins.json` manifest — `uri` points + * to the containing file and {@link CustomizationBase.range | `range`} + * narrows it to the declaration's span. + */ uri: URI; - /** Human-readable name */ - displayName: string; - /** Description of what the plugin provides */ - description?: string; - /** Icons for the plugin */ + /** Human-readable name. */ + name: string; + /** Icons for UI display. */ icons?: Icon[]; /** - * Opaque version token for this customization. - * - * Clients SHOULD include a nonce with every customization they provide. - * Consumers can compare nonces to detect whether a customization has - * changed since it was last seen, avoiding redundant reloads or copies. + * Optional span within {@link CustomizationBase.uri | `uri`} when this + * customization is a subset of a larger file (for example, one entry + * in an inline `mcpServers` block of a `plugins.json` manifest). + * Absent when the customization covers the whole resource. */ - nonce?: string; + range?: TextRange; } /** - * Loading status for a server-managed customization. + * Discriminant values for {@link CustomizationLoadState}. * * @category Customization Types */ -export const enum CustomizationStatus { - /** Plugin is being loaded */ +export const enum CustomizationLoadStatus { Loading = 'loading', - /** Plugin is fully operational */ Loaded = 'loaded', - /** Plugin partially loaded but has warnings */ Degraded = 'degraded', - /** Plugin was unable to load */ Error = 'error', } /** - * A customization active in a session. + * Container is being loaded by the host. + * + * @category Customization Types + */ +export interface CustomizationLoadingState { + kind: CustomizationLoadStatus.Loading; +} + +/** + * Container loaded successfully. + * + * @category Customization Types + */ +export interface CustomizationLoadedState { + kind: CustomizationLoadStatus.Loaded; +} + +/** + * Container partially loaded but has warnings. + * + * @category Customization Types + */ +export interface CustomizationDegradedState { + kind: CustomizationLoadStatus.Degraded; + /** Human-readable description of the warning. */ + message: string; +} + +/** + * Container failed to load. + * + * @category Customization Types + */ +export interface CustomizationErrorState { + kind: CustomizationLoadStatus.Error; + /** Human-readable error message. */ + message: string; +} + +/** + * Discriminated load state for a container customization + * ({@link PluginCustomization} or {@link DirectoryCustomization}). + * + * @category Customization Types + */ +export type CustomizationLoadState = + | CustomizationLoadingState + | CustomizationLoadedState + | CustomizationDegradedState + | CustomizationErrorState; + +/** + * Fields shared by container customizations. * * @category Customization Types */ -export interface SessionCustomization { - /** The plugin this customization refers to */ - customization: CustomizationRef; - /** Whether this customization is currently enabled */ +interface ContainerCustomizationBase extends CustomizationBase { + /** Whether this container is currently enabled. */ enabled: boolean; /** - * The `clientId` of the client that contributed this customization. - * Absent for server-provided customizations. + * `clientId` of the client that contributed this container. Absent for + * server-originated entries. */ clientId?: string; - /** Server-reported loading status */ - status?: CustomizationStatus; /** - * Human-readable status detail (e.g. error message or degradation warning). + * Host-reported load state. Absent means the host has not yet reported + * a load state for this container. */ - statusMessage?: string; + load?: CustomizationLoadState; /** - * Custom agents contributed by this customization, as resolved by the - * agent host after parsing the customization. + * Children discovered inside this container. * - * Consumers MUST treat an absent field as "unknown" (e.g. the host has - * not finished parsing the customization yet). An empty array means the - * host parsed the customization and it contributes no agents. - * - * Clients are not authoritative here: only the agent host populates - * this field. + * Absent means the host has not parsed this container yet. An empty + * array means the host parsed the container and it contributes + * nothing. */ - agents?: CustomizationAgentRef[]; + children?: ChildCustomization[]; +} + +/** + * An [Open Plugins](https://open-plugins.com/) plugin. + * + * @category Customization Types + */ +export interface PluginCustomization extends ContainerCustomizationBase { + type: CustomizationType.Plugin; +} + +/** + * A {@link PluginCustomization} as published by a client. Extends the + * server-facing shape with an opaque `nonce` so the host can detect when + * the client's view of a plugin has changed and re-parse only as needed. + * + * Clients SHOULD include a `nonce`. Server-side fields like + * {@link ContainerCustomizationBase.children | `children`} and + * {@link ContainerCustomizationBase.load | `load`} are typically left + * absent on publication and populated by the host when the resolved + * plugin appears in {@link SessionState.customizations}. + * + * @category Customization Types + */ +export interface ClientPluginCustomization extends PluginCustomization { + /** Opaque version token used by the host to detect changes. */ + nonce?: string; } + +/** + * A directory the host watches for this session. + * + * Presence in the customization list signals that the host may discover + * customizations from this directory. When `writable` is `true`, clients + * MAY persist new customizations into the directory using + * [`resourceWrite`](/reference/commands#resourcewrite); the host will + * then surface the resulting child via the customization actions. + * + * The directory may not yet exist on disk. + * + * @category Customization Types + */ +export interface DirectoryCustomization extends ContainerCustomizationBase { + type: CustomizationType.Directory; + /** Which child customization type this directory holds. */ + contents: ChildCustomizationType; + /** Whether clients may write into this directory. */ + writable: boolean; +} + +/** + * A custom agent contributed by a plugin or directory. + * + * Mirrors the [Open Plugins agent](https://open-plugins.com/agent-builders/components/agents) + * format: a markdown file with YAML frontmatter, where the body is the + * agent's system prompt. + * + * @category Customization Types + */ +export interface AgentCustomization extends CustomizationBase { + type: CustomizationType.Agent; + /** + * Short description of what the agent specializes in and when to + * invoke it. Sourced from the agent file's frontmatter `description`. + */ + description?: string; +} + +/** + * A skill contributed by a plugin or directory. + * + * Covers both [Open Plugins skill formats](https://open-plugins.com/agent-builders/components/skills) + * — the `skills/` directory layout (one subdirectory per skill, each with + * a `SKILL.md`) and the flatter `commands/` directory of slash-command + * skills. + * + * @category Customization Types + */ +export interface SkillCustomization extends CustomizationBase { + type: CustomizationType.Skill; + /** + * Short description used for help text and auto-invocation matching. + * Sourced from the skill's frontmatter `description`. + */ + description?: string; + /** + * When `true`, only the user can invoke this skill — the agent will not + * auto-invoke it. Sourced from the command skill's frontmatter + * `disable-model-invocation` flag. + */ + disableModelInvocation?: boolean; +} + +/** + * A prompt contributed by a plugin or directory. + * + * @category Customization Types + */ +export interface PromptCustomization extends CustomizationBase { + type: CustomizationType.Prompt; + /** Short description of what the prompt does. */ + description?: string; +} + +/** + * A rule contributed by a plugin or directory. + * + * Mirrors the [Open Plugins rule](https://open-plugins.com/agent-builders/components/rules) + * format: a markdown file (e.g. `.mdc`) whose body is injected into + * context while the rule is active. This type also covers tool-specific + * "instruction" formats (e.g. VS Code Copilot's + * `.github/instructions/*.md`), which differ only in naming — they + * share the same semantics of `description`, optional always-on + * activation, and optional glob scoping. + * + * @category Customization Types + */ +export interface RuleCustomization extends CustomizationBase { + type: CustomizationType.Rule; + /** + * Description of what the rule enforces. + */ + description?: string; + /** + * When `true`, the rule is always active (subject to `globs` if any). + * When `false` or absent, the agent or user decides whether to apply + * the rule. + */ + alwaysApply?: boolean; + /** + * Glob patterns the rule applies to. When present, the rule is only + * active for matching files. + */ + globs?: string[]; +} + +/** + * A hook manifest contributed by a plugin or directory. + * + * @category Customization Types + */ +export interface HookCustomization extends CustomizationBase { + type: CustomizationType.Hook; +} + +/** + * An MCP manifest contributed by a plugin or directory. + * + * When the server is declared inline in the containing plugin manifest, + * `uri` points at the manifest file and + * {@link CustomizationBase.range | `range`} narrows it to the + * declaration's span. + * + * @category Customization Types + */ +export interface McpServerCustomization extends CustomizationBase { + type: CustomizationType.McpServer; +} + +/** + * Child customizations that live inside a {@link PluginCustomization} or + * {@link DirectoryCustomization}. + * + * @category Customization Types + */ +export type ChildCustomization = + | AgentCustomization + | SkillCustomization + | PromptCustomization + | RuleCustomization + | HookCustomization + | McpServerCustomization; + +/** + * A top-level customization active in a session. Always a container + * ({@link PluginCustomization} or {@link DirectoryCustomization}); the + * remaining customization types appear inside the container's + * {@link ContainerCustomizationBase.children | `children`} array. + * + * @category Customization Types + */ +export type Customization = PluginCustomization | DirectoryCustomization; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 00e506da06a62..1b7fd8715392e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,7 +10,7 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionTurnStartedAction, SessionDeltaAction, SessionResponsePartAction, SessionToolCallStartAction, SessionToolCallDeltaAction, SessionToolCallReadyAction, SessionToolCallConfirmedAction, SessionToolCallCompleteAction, SessionToolCallResultConfirmedAction, SessionToolCallContentChangedAction, SessionTurnCompleteAction, SessionTurnCancelledAction, SessionErrorAction, SessionTitleChangedAction, SessionUsageAction, SessionReasoningAction, SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, SessionActiveClientChangedAction, SessionActiveClientToolsChangedAction, SessionPendingMessageSetAction, SessionPendingMessageRemovedAction, SessionQueuedMessagesReorderedAction, SessionInputRequestedAction, SessionInputAnswerChangedAction, SessionInputCompletedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionTruncatedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionTurnStartedAction, SessionDeltaAction, SessionResponsePartAction, SessionToolCallStartAction, SessionToolCallDeltaAction, SessionToolCallReadyAction, SessionToolCallConfirmedAction, SessionToolCallCompleteAction, SessionToolCallResultConfirmedAction, SessionToolCallContentChangedAction, SessionTurnCompleteAction, SessionTurnCancelledAction, SessionErrorAction, SessionTitleChangedAction, SessionUsageAction, SessionReasoningAction, SessionModelChangedAction, SessionAgentChangedAction, SessionServerToolsChangedAction, SessionActiveClientChangedAction, SessionActiveClientToolsChangedAction, SessionPendingMessageSetAction, SessionPendingMessageRemovedAction, SessionQueuedMessagesReorderedAction, SessionInputRequestedAction, SessionInputAnswerChangedAction, SessionInputCompletedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionTruncatedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetOperationsChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -58,6 +58,7 @@ export const enum ActionType { SessionCustomizationsChanged = 'session/customizationsChanged', SessionCustomizationToggled = 'session/customizationToggled', SessionCustomizationUpdated = 'session/customizationUpdated', + SessionCustomizationRemoved = 'session/customizationRemoved', SessionTruncated = 'session/truncated', SessionIsReadChanged = 'session/isReadChanged', SessionIsArchivedChanged = 'session/isArchivedChanged', @@ -155,6 +156,7 @@ export type StateAction = | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction + | SessionCustomizationRemovedAction | SessionTruncatedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 90598453611fb..cfb323089761d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -90,6 +90,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionCustomizationsChanged]: '0.1.0', [ActionType.SessionCustomizationToggled]: '0.1.0', [ActionType.SessionCustomizationUpdated]: '0.1.0', + [ActionType.SessionCustomizationRemoved]: '0.2.0', [ActionType.SessionTruncated]: '0.1.0', [ActionType.SessionIsReadChanged]: '0.1.0', [ActionType.SessionIsArchivedChanged]: '0.1.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 036bc3ebf1ab8..0cba6a9ff4fa4 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -21,6 +21,7 @@ import { type RootState, type SessionState, type SessionSummary, + type TextRange, type ToolCallCancelledState, type ToolCallCompletedState, type ToolCallResult, @@ -53,7 +54,24 @@ export { type FileEdit as ISessionFileDiff, type ModelSelection, type AgentSelection, - type CustomizationAgentRef, + type AgentCustomization, + type Customization, + type PluginCustomization, + type DirectoryCustomization, + type ClientPluginCustomization, + type ChildCustomization, + type SkillCustomization, + type PromptCustomization, + type RuleCustomization, + type HookCustomization, + type McpServerCustomization, + type CustomizationLoadState, + type CustomizationLoadingState, + type CustomizationLoadedState, + type CustomizationDegradedState, + type CustomizationErrorState, + CustomizationLoadStatus, + CustomizationType, type SessionModelInfo, type SessionState, type SessionSummary, @@ -70,8 +88,6 @@ export { type ToolCallState, type ToolCallStreamingState, type ToolDefinition, - type CustomizationRef, - type SessionCustomization, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type ToolResultContent, type ToolResultFileEditContent, @@ -91,7 +107,6 @@ export { type ChangesetState, type ChangesetFile, type ChangesetOperation, - CustomizationStatus, MessageAttachmentKind, PendingMessageKind, PolicyState, @@ -161,6 +176,20 @@ export function isAhpRootChannel(uri: string): boolean { } } +/** + * Mints a session-unique opaque id for a customization, derived from its + * source URI and (when present) its `range` within the source. Plugins MAY + * declare multiple children (e.g. MCP servers, hooks) inside the same + * manifest file; including the range disambiguates them without an extra + * mapping table. + */ +export function customizationId(uri: string, range?: TextRange): string { + if (!range) { + return uri; + } + return `${uri}#${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`; +} + // ---- VS Code-specific derived types ----------------------------------------- /** diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index 6d0b0b4a47e22..bb6e1269a5a5f 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -14,16 +14,16 @@ import { toAgentClientUri } from '../common/agentClientUri.js'; import type { IAgent } from '../common/agentService.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; import { MessageAttachmentKind } from '../common/state/protocol/state.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js'; +import { CustomizationLoadStatus, type ClientPluginCustomization, type Customization, type CustomizationLoadState } from '../common/state/sessionState.js'; import { parsePlugin, type INamedPluginResource } from '../../agentPlugins/common/pluginParsers.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; import { extractLeadingSlashToken } from './agentHostSlashCompletion.js'; interface ISkillCustomizationCandidate { - readonly customization: CustomizationRef; - readonly enabled: boolean; + readonly customization: Customization; + readonly nonce?: string; readonly clientId?: string; - readonly status?: CustomizationStatus; + readonly load?: CustomizationLoadState; } interface ISkillCompletionMetadata { @@ -80,7 +80,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const skillBySlashName = new Map(); for (const candidate of candidates) { const pluginRoot = this._resolvePluginRoot(candidate); - const cacheKey = this._cacheKey(agent.id, pluginRoot, candidate.customization.nonce); + const cacheKey = this._cacheKey(agent.id, pluginRoot, candidate.nonce); reachableCacheKeys.add(cacheKey); const skills = await this._getCachedSkills(agent.id, cacheKey, pluginRoot); @@ -116,7 +116,7 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge } private async _getCandidates(agent: IAgent, session: URI): Promise { - let sessionCustomizations: readonly SessionCustomization[] = []; + let sessionCustomizations: readonly Customization[] = []; if (agent.getSessionCustomizations) { try { sessionCustomizations = await agent.getSessionCustomizations(session); @@ -128,12 +128,13 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const seen = new Set(); const candidates: ISkillCustomizationCandidate[] = []; for (const item of sessionCustomizations) { - seen.add(item.customization.uri); + seen.add(item.uri); + const nonce = (item as ClientPluginCustomization).nonce; candidates.push({ - customization: item.customization, - enabled: item.enabled, + customization: item, + ...(nonce !== undefined ? { nonce } : {}), ...(item.clientId !== undefined ? { clientId: item.clientId } : {}), - ...(item.status !== undefined ? { status: item.status } : {}), + ...(item.load !== undefined ? { load: item.load } : {}), }); } @@ -142,10 +143,10 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge if (seen.has(customization.uri)) { continue; } - candidates.push({ customization, enabled: true }); + candidates.push({ customization }); } - return candidates.filter(candidate => candidate.enabled && candidate.status !== CustomizationStatus.Loading && candidate.status !== CustomizationStatus.Error); + return candidates.filter(candidate => candidate.customization.enabled && candidate.load?.kind !== CustomizationLoadStatus.Loading && candidate.load?.kind !== CustomizationLoadStatus.Error); } private _watchAgent(agent: IAgent): void { diff --git a/src/vs/platform/agentHost/node/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts index 0e272769a9fa0..eca2380d985cb 100644 --- a/src/vs/platform/agentHost/node/agentPluginManager.ts +++ b/src/vs/platform/agentHost/node/agentPluginManager.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js'; +import { CustomizationLoadStatus, type ClientPluginCustomization, type Customization } from '../common/state/sessionState.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; const DEFAULT_MAX_PLUGINS = 20; @@ -67,8 +67,8 @@ export class AgentPluginManager implements IAgentPluginManager { async syncCustomizations( clientId: string, - customizations: CustomizationRef[], - progress?: (status: SessionCustomization) => void, + customizations: ClientPluginCustomization[], + progress?: (status: Customization) => void, ): Promise { await this._ensureCacheLoaded(); @@ -77,13 +77,13 @@ export class AgentPluginManager implements IAgentPluginManager { this._sequencer.queue(ref.uri, async (): Promise => { try { const pluginDir = await this._syncPlugin(clientId, ref); - const customization = { customization: ref, enabled: true, status: CustomizationStatus.Loaded }; + const customization: Customization = { ...ref, load: { kind: CustomizationLoadStatus.Loaded } }; progress?.(customization); return { customization, pluginDir }; } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.error(`[AgentPluginManager] Failed to sync plugin ${ref.uri}: ${message}`); - const customization = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message }; + const customization: Customization = { ...ref, load: { kind: CustomizationLoadStatus.Error, message } }; progress?.(customization); return { customization }; } @@ -99,7 +99,7 @@ export class AgentPluginManager implements IAgentPluginManager { * Syncs a single plugin to local storage. Skips the copy when the * nonce matches the cached value. Returns the local directory URI. */ - private async _syncPlugin(clientId: string, ref: CustomizationRef): Promise { + private async _syncPlugin(clientId: string, ref: ClientPluginCustomization): Promise { const pluginUri = toAgentClientUri(URI.parse(ref.uri), clientId); const key = this._keyForUri(ref.uri); const destDir = URI.joinPath(this._basePath, key); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3ec2080b8dae6..3e5dabdd3202c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -838,7 +838,7 @@ export class AgentSideEffects extends Disposable { } case ActionType.SessionCustomizationToggled: { const agent = this._options.getAgent(channel); - agent?.setCustomizationEnabled?.(action.uri, action.enabled); + agent?.setCustomizationEnabled?.(action.id, action.enabled); break; } case ActionType.SessionIsReadChanged: { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 9399f1091e091..95489c57094ca 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -26,7 +26,7 @@ import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESO import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { PolicyState, ProtectedResourceMetadata, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { CustomizationRef, isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type MessageAttachment, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; @@ -908,7 +908,7 @@ export class ClaudeAgent extends Disposable implements IAgent { entry?.session.completeClientToolCall(toolCallId, result); } - setClientCustomizations(_session: URI, _clientId: string, _customizations: CustomizationRef[]): Promise { + setClientCustomizations(_session: URI, _clientId: string, _customizations: ClientPluginCustomization[]): Promise { throw new Error('TODO: Phase 11'); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index feef26b234007..9e0b803542a3a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -22,17 +22,17 @@ import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginP import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, platformRootSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type AgentSelection, type SessionCustomization, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type AgentSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { CustomizationRef, CustomizationStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type MessageAttachment, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { CustomizationLoadStatus, ResponsePartKind, SessionInputResponseKind, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type PolicyState, type ResponsePart, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; @@ -41,7 +41,7 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toCustomizationAgentRefs, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { parsedPluginsEqual, toAgentCustomizations, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools } from './copilotShellTools.js'; import { SessionCustomizationDiscovery } from './sessionCustomizationDiscovery.js'; @@ -333,11 +333,11 @@ export class CopilotAgent extends Disposable implements IAgent { return [GITHUB_COPILOT_PROTECTED_RESOURCE]; } - getCustomizations(): readonly CustomizationRef[] { + getCustomizations(): readonly Customization[] { return this._plugins.getConfiguredHostCustomizations(); } - async getSessionCustomizations(session: URI): Promise { + async getSessionCustomizations(session: URI): Promise { return this._plugins.getSessionCustomizationsSettled(await this._getSessionCustomizationDirectory(session)); } @@ -1023,7 +1023,7 @@ export class CopilotAgent extends Disposable implements IAgent { return { items: branches.map(branch => ({ value: branch, label: branch })) }; } - async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { + async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { const directory = await this._getSessionCustomizationDirectory(session); return this._plugins.sync(clientId, customizations, directory, action => { this._onDidSessionProgress.fire({ kind: 'action', session, action }); @@ -1837,7 +1837,7 @@ export class CopilotAgent extends Disposable implements IAgent { } interface IResolvedCustomization { - readonly customization: SessionCustomization; + readonly customization: Customization; readonly pluginDir?: URI; readonly plugin?: IParsedPlugin; } @@ -1900,13 +1900,16 @@ class SessionDiscoveredEntry extends Disposable { const pluginDir = URI.parse(bundleResult.ref.uri); const plugin = await this._resolvePlugin(pluginDir); this._resolved = { - customization: { - customization: bundleResult.ref, - enabled: true, - status: plugin ? CustomizationStatus.Loaded : CustomizationStatus.Error, - statusMessage: plugin ? undefined : localize('copilotAgent.pluginParseError', "Error parsing plugin."), - ...(plugin ? { agents: toCustomizationAgentRefs(plugin.agents) } : {}), - }, + customization: plugin + ? { + ...bundleResult.ref, + load: { kind: CustomizationLoadStatus.Loaded }, + children: toAgentCustomizations(plugin.agents), + } + : { + ...bundleResult.ref, + load: { kind: CustomizationLoadStatus.Error, message: localize('copilotAgent.pluginParseError', "Error parsing plugin.") }, + }, pluginDir, plugin, }; @@ -1928,7 +1931,7 @@ class PluginController extends Disposable { private _hostSync: Promise = Promise.resolve([]); private _clientRevision = 0; private _hostRevision = 0; - private _lastAppliedRefs: readonly CustomizationRef[] = []; + private _lastAppliedRefs: readonly Customization[] = []; /** * Per-working-directory bundles built from on-disk discovery @@ -1961,12 +1964,12 @@ class PluginController extends Disposable { super.dispose(); } - public getConfiguredHostCustomizations(): readonly CustomizationRef[] { - return this._hostCustomizations.map(item => item.customization.customization); + public getConfiguredHostCustomizations(): readonly Customization[] { + return this._hostCustomizations.map(item => item.customization); } - public getSessionCustomizations(directory: URI | undefined): readonly SessionCustomization[] { - const result: SessionCustomization[] = [ + public getSessionCustomizations(directory: URI | undefined): readonly Customization[] { + const result: Customization[] = [ ...this._hostCustomizations.map(item => this._applyEnablement(item.customization)), ...this._clientCustomizations.map(item => this._applyEnablement(item.customization)), ]; @@ -1990,7 +1993,7 @@ class PluginController extends Disposable { * {@link SessionDiscoveredEntry} kicks off its `_refresh()` in its * constructor without anyone awaiting it. */ - public async getSessionCustomizationsSettled(directory: URI | undefined): Promise { + public async getSessionCustomizationsSettled(directory: URI | undefined): Promise { const entry = directory ? this._getOrCreateSessionEntry(directory) : undefined; await Promise.all([ this._hostSync.catch(err => { @@ -2066,7 +2069,8 @@ class PluginController extends Disposable { * changed since the last application. */ private _applyHostCustomizations(): void { - const customizations = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.Customizations) ?? []; + const entries = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.Customizations) ?? []; + const customizations = entries.map(toContainerCustomization); if (equals(customizations, this._lastAppliedRefs)) { return; } @@ -2075,9 +2079,8 @@ class PluginController extends Disposable { const revision = ++this._hostRevision; this._hostCustomizations = customizations.map(customization => ({ customization: { - customization, - enabled: true, - status: CustomizationStatus.Loading, + ...customization, + load: { kind: CustomizationLoadStatus.Loading }, }, })); this._onDidChange.fire(); @@ -2093,38 +2096,33 @@ class PluginController extends Disposable { }); } - public sync(clientId: string, customizations: CustomizationRef[], directory: URI | undefined, publish?: (action: SessionAction) => void) { + public sync(clientId: string, customizations: ClientPluginCustomization[], directory: URI | undefined, publish?: (action: SessionAction) => void) { const revision = ++this._clientRevision; this._clientCustomizations = customizations.map(customization => ({ customization: { - customization, + ...customization, clientId, - enabled: true, - status: CustomizationStatus.Loading, + load: { kind: CustomizationLoadStatus.Loading }, }, })); publish?.({ type: ActionType.SessionCustomizationsChanged, customizations: [...this.getSessionCustomizations(directory)], }); - const published = new Map(); + const published = new Map(); for (const customization of this._clientCustomizations) { const enabled = this._applyEnablement(customization.customization); - published.set(enabled.customization.uri, this._applyEnablement(customization.customization)); + published.set(enabled.uri, enabled); } const publishUpdate = (item: IResolvedCustomization) => { const customization = this._applyEnablement(item.customization); - if (equals(published.get(customization.customization.uri), customization)) { + if (equals(published.get(customization.uri), customization)) { return; } - published.set(customization.customization.uri, { ...customization }); + published.set(customization.uri, customization); publish?.({ type: ActionType.SessionCustomizationUpdated, - customization: customization.customization, - enabled: customization.enabled, - status: customization.status, - statusMessage: customization.statusMessage, - agents: customization.agents, + customization, }); }; @@ -2156,35 +2154,32 @@ class PluginController extends Disposable { }))); } - private _isEnabled(customization: SessionCustomization): boolean { - return this._enablement.get(customization.customization.uri) ?? customization.enabled; + private _isEnabled(customization: Customization): boolean { + return this._enablement.get(customization.uri) ?? customization.enabled; } - private _applyEnablement(customization: SessionCustomization): SessionCustomization { + private _applyEnablement(customization: Customization): Customization { const enabled = this._isEnabled(customization); return customization.enabled === enabled ? customization : { ...customization, enabled }; } - private async _resolveConfiguredCustomization(customization: CustomizationRef): Promise { + private async _resolveConfiguredCustomization(customization: Customization): Promise { const pluginDir = URI.parse(customization.uri); const parsed = await this._tryParsePlugin(pluginDir); if (!parsed) { return { customization: { - customization, - enabled: true, - status: CustomizationStatus.Error, - statusMessage: localize('copilotAgent.pluginParseError', "Error parsing plugin."), + ...customization, + load: { kind: CustomizationLoadStatus.Error, message: localize('copilotAgent.pluginParseError', "Error parsing plugin.") }, }, }; } return { customization: { - customization, - enabled: true, - status: CustomizationStatus.Loaded, - agents: toCustomizationAgentRefs(parsed.agents), + ...customization, + load: { kind: CustomizationLoadStatus.Loaded }, + children: toAgentCustomizations(parsed.agents), }, pluginDir, plugin: parsed, @@ -2192,33 +2187,25 @@ class PluginController extends Disposable { } private async _resolveSyncedCustomization(item: ISyncedCustomization, clientId: string): Promise { + const baseCustomization: Customization = { ...item.customization, clientId }; if (!item.pluginDir) { - return { - customization: { - ...item.customization, - clientId, - }, - }; + return { customization: baseCustomization }; } const parsed = await this._tryParsePlugin(item.pluginDir); if (!parsed) { return { customization: { - ...item.customization, - clientId, - status: CustomizationStatus.Error, - statusMessage: localize('copilotAgent.pluginParseError', "Error parsing plugin."), + ...baseCustomization, + load: { kind: CustomizationLoadStatus.Error, message: localize('copilotAgent.pluginParseError', "Error parsing plugin.") }, }, }; } return { customization: { - ...item.customization, - customization: item.customization.customization, - clientId, - agents: toCustomizationAgentRefs(parsed.agents), + ...baseCustomization, + children: toAgentCustomizations(parsed.agents), }, pluginDir: item.pluginDir, plugin: parsed, diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts index 2df42810b5a2b..98997a257d9fd 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -10,7 +10,8 @@ import { parseFrontMatter } from '../../../../base/common/yaml.js'; import { IFileService } from '../../../files/common/files.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import type { CustomizationAgentRef } from '../../common/state/protocol/state.js'; +import { CustomizationType, type AgentCustomization } from '../../common/state/protocol/state.js'; +import { customizationId } from '../../common/state/sessionState.js'; import { dirname } from '../../../../base/common/path.js'; type SessionHooks = NonNullable; @@ -106,15 +107,21 @@ export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], } /** - * Projects parsed plugin agents into the protocol's {@link CustomizationAgentRef} - * shape so they can be advertised on the owning {@link CustomizationRef.agents}. + * Projects parsed plugin agents into the protocol's {@link AgentCustomization} + * shape so they can be advertised as children of the owning container + * customization. */ -export function toCustomizationAgentRefs(agents: readonly INamedPluginResource[]): CustomizationAgentRef[] { - return agents.map(a => ({ - uri: a.uri.toString(), - name: a.name, - ...(a.description ? { description: a.description } : {}), - })); +export function toAgentCustomizations(agents: readonly INamedPluginResource[]): AgentCustomization[] { + return agents.map(a => { + const uri = a.uri.toString(); + return { + type: CustomizationType.Agent, + id: customizationId(uri), + uri, + name: a.name, + ...(a.description ? { description: a.description } : {}), + }; + }); } // --------------------------------------------------------------------------- diff --git a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts index c32a1497cd28f..0c4d1ad8e919f 100644 --- a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts +++ b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts @@ -10,8 +10,8 @@ import { basename, dirname } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { IAgentPluginManager } from '../../common/agentPluginManager.js'; -import type { CustomizationRef } from '../../common/state/sessionState.js'; -import type { URI as ProtocolURI } from '../../common/state/protocol/state.js'; +import { customizationId, type ClientPluginCustomization } from '../../common/state/sessionState.js'; +import { CustomizationType, type URI as ProtocolURI } from '../../common/state/protocol/state.js'; import { DiscoveredType, type IDiscoveredFile } from '../copilot/sessionCustomizationDiscovery.js'; const DISPLAY_NAME = 'VS Code Synced Data'; @@ -35,7 +35,7 @@ function pluginDirForType(type: DiscoveredType): string { } interface IBundleResult { - readonly ref: CustomizationRef; + readonly ref: ClientPluginCustomization; } /** @@ -80,8 +80,8 @@ export class SessionPluginBundler extends Disposable { * Bundles the given files into the on-disk plugin directory. * * Overwrites any previous bundle for this working directory. Returns a - * {@link CustomizationRef} pointing at the on-disk plugin root with a - * content-based nonce, or `undefined` when there are no files. + * {@link ClientPluginCustomization} pointing at the on-disk plugin root + * with a content-based nonce, or `undefined` when there are no files. */ async bundle(files: readonly IDiscoveredFile[]): Promise { if (files.length === 0) { @@ -125,11 +125,14 @@ export class SessionPluginBundler extends Disposable { const nonce = String(hash(hashParts.join('\n'))); this._lastNonce = nonce; + const rootUriString = this._rootUri.toString() as ProtocolURI; return { ref: { - uri: this._rootUri.toString() as ProtocolURI, - displayName: DISPLAY_NAME, - description: `${files.length} customization(s) discovered for this session`, + type: CustomizationType.Plugin, + id: customizationId(rootUriString), + uri: rootUriString, + name: DISPLAY_NAME, + enabled: true, nonce, }, }; diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 81e984968aaf9..be186d1798319 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -23,7 +23,7 @@ import { ActionType, type SessionActiveClientChangedAction, type SessionTitleCha import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { ROOT_STATE_URI, StateComponents } from '../../common/state/sessionState.js'; +import { CustomizationType, ROOT_STATE_URI, StateComponents, customizationId } from '../../common/state/sessionState.js'; import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; @@ -642,8 +642,8 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, - { uri: 'file:///other/bar', displayName: 'Bar' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, + { type: CustomizationType.Plugin, id: customizationId('file:///other/bar'), uri: 'file:///other/bar', name: 'Bar', enabled: true }, ] }, }); @@ -668,8 +668,8 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, - { uri: 'file:///plugins/bar', displayName: 'Bar' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/bar'), uri: 'file:///plugins/bar', name: 'Bar', enabled: true }, ] }, }); @@ -691,7 +691,7 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, ] }, }; @@ -725,7 +725,7 @@ suite('RemoteAgentHostProtocolClient', () => { clientId: 'c1', tools: [], customizations: [ - { uri: 'file:///plugins/foo', displayName: 'Foo' }, + { type: CustomizationType.Plugin, id: customizationId('file:///plugins/foo'), uri: 'file:///plugins/foo', name: 'Foo', enabled: true }, ], }, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts index aebdafbc3fbea..5126b6af14d86 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts @@ -14,8 +14,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { CompletionItemKind } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; -import { CustomizationStatus, type CustomizationRef } from '../../common/state/sessionState.js'; +import { CustomizationType, MessageAttachmentKind } from '../../common/state/protocol/state.js'; +import { CustomizationLoadStatus, customizationId, type ClientPluginCustomization } from '../../common/state/sessionState.js'; import { AgentHostCompletions, CompletionTriggerCharacter } from '../../node/agentHostCompletions.js'; import { AgentHostSkillCompletionProvider } from '../../node/agentHostSkillCompletionProvider.js'; import { MockAgent } from './mockAgent.js'; @@ -38,10 +38,14 @@ suite('AgentHostSkillCompletionProvider', () => { return URI.from({ scheme: Schemas.inMemory, path }); } - function customization(root: URI, nonce?: string): CustomizationRef { + function customization(root: URI, nonce?: string): ClientPluginCustomization { + const uri = root.toString(); return { - uri: root.toString(), - displayName: root.path, + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: root.path, + enabled: true, ...(nonce !== undefined ? { nonce } : {}), }; } @@ -116,7 +120,7 @@ suite('AgentHostSkillCompletionProvider', () => { const globalCustomization = customization(globalRoot, 'global'); const agent = new MockAgent('mock'); agent.customizations = [globalCustomization]; - agent.getSessionCustomizations = async () => [{ customization: sessionCustomization, enabled: true, status: CustomizationStatus.Loaded }]; + agent.getSessionCustomizations = async () => [{ ...sessionCustomization, load: { kind: CustomizationLoadStatus.Loaded } }]; const provider = createProvider(agent); const result = await run(provider, '/'); @@ -130,7 +134,7 @@ suite('AgentHostSkillCompletionProvider', () => { const ref = customization(root, '1'); const agent = new MockAgent('mock'); agent.customizations = [ref]; - agent.getSessionCustomizations = async () => [{ customization: ref, enabled: false, status: CustomizationStatus.Loaded }]; + agent.getSessionCustomizations = async () => [{ ...ref, enabled: false, load: { kind: CustomizationLoadStatus.Loaded } }]; const provider = createProvider(agent); const result = await run(provider, '/'); diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts index 19514a21b334b..5a94df8b59555 100644 --- a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts @@ -13,7 +13,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js'; -import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../../common/state/sessionState.js'; +import { customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; import { AgentPluginManager } from '../../node/agentPluginManager.js'; suite('AgentPluginManager', () => { @@ -37,8 +38,16 @@ suite('AgentPluginManager', () => { return URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }).toString(); } - function makeRef(name: string, nonce?: string): CustomizationRef { - return { uri: pluginUri(name), displayName: `Plugin ${name}`, nonce }; + function makeRef(name: string, nonce?: string): ClientPluginCustomization { + const uri = pluginUri(name); + return { + type: CustomizationType.Plugin, + id: customizationId(uri), + uri, + name: `Plugin ${name}`, + enabled: true, + ...(nonce !== undefined ? { nonce } : {}), + }; } async function seedPluginDir(name: string, files: Record): Promise { @@ -62,9 +71,9 @@ suite('AgentPluginManager', () => { makeRef('alpha', 'n1'), makeRef('beta', 'n2'), ]); - assert.strictEqual(results[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(results[0].customization.load?.kind, 'loaded'); assert.ok(results[0].pluginDir, 'should have pluginDir'); - assert.strictEqual(results[1].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(results[1].customization.load?.kind, 'loaded'); assert.ok(results[1].pluginDir, 'should have pluginDir'); }); @@ -72,8 +81,8 @@ suite('AgentPluginManager', () => { const results = await manager.syncCustomizations('test-client', [makeRef('nonexistent')]); assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].customization.status, CustomizationStatus.Error); - assert.ok(results[0].customization.statusMessage); + assert.strictEqual(results[0].customization.load?.kind, 'error'); + assert.ok(results[0].customization.load?.kind === 'error' && results[0].customization.load.message); assert.strictEqual(results[0].pluginDir, undefined); }); @@ -84,19 +93,19 @@ suite('AgentPluginManager', () => { makeRef('good', 'n1'), makeRef('missing'), ]); - assert.strictEqual(results[1].customization.status, CustomizationStatus.Error); + assert.strictEqual(results[1].customization.load?.kind, 'error'); assert.strictEqual(results[1].pluginDir, undefined); }); test('fires progress callback with changed customization status', async () => { await seedPluginDir('prog', { 'index.js': 'content' }); - const progressCalls: SessionCustomization[] = []; + const progressCalls: Customization[] = []; await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], status => { progressCalls.push(status); }); - assert.deepStrictEqual(progressCalls.map(call => call.status), [CustomizationStatus.Loaded]); + assert.deepStrictEqual(progressCalls.map(call => call.load?.kind), ['loaded']); }); test('skips copy when nonce matches', async () => { @@ -123,8 +132,8 @@ suite('AgentPluginManager', () => { ]); // Both should succeed without error - assert.strictEqual(r1[0].customization.status, CustomizationStatus.Loaded); - assert.strictEqual(r2[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(r1[0].customization.load?.kind, 'loaded'); + assert.strictEqual(r2[0].customization.load?.kind, 'loaded'); }); }); @@ -165,7 +174,7 @@ suite('AgentPluginManager', () => { const result = await manager2.syncCustomizations('test-client', [ref]); // Should be loaded from cache (nonce match), not error - assert.strictEqual(result[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(result[0].customization.load?.kind, 'loaded'); assert.ok(result[0].pluginDir); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5e64e62b9d90d..2bce4d638fcc5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -23,7 +23,7 @@ import { AgentSession } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, MessageAttachmentKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationType, MessageAttachmentKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, customizationId, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -1148,7 +1148,7 @@ suite('AgentService (node dispatcher)', () => { const activeClient: SessionActiveClient = { clientId: 'client-eager', tools: [{ name: 't1', description: 'd', inputSchema: { type: 'object' } }], - customizations: [{ uri: 'file:///plugin-a', displayName: 'A' }], + customizations: [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'A', enabled: true }], }; const session = await service.createSession({ provider: 'copilot', activeClient }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 3b185251f29b8..751f35033c735 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -21,9 +21,9 @@ import { AgentSession, IAgent } from '../../common/agentService.js'; import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; -import { CustomizationStatus } from '../../common/state/protocol/state.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -212,7 +212,7 @@ suite('AgentSideEffects', () => { activeClient: { clientId: 'test-client', tools: [{ name: 'testTool', inputSchema: { type: 'object' } }], - customizations: [{ uri: 'file:///customizations/SKILL.md', displayName: 'Test Skill' }] + customizations: [{ type: CustomizationType.Plugin, id: customizationId('file:///customizations/SKILL.md'), uri: 'file:///customizations/SKILL.md', name: 'Test Skill', enabled: true }] }, }; stateManager.dispatchClientAction(sessionUri.toString(), activeClientAction, { clientId: 'test', clientSeq: 1 }); @@ -876,18 +876,11 @@ suite('AgentSideEffects', () => { test('calls setClientCustomizations and dispatches customizationsChanged once', async () => { setupSession(); - agent.getSessionCustomizations = async () => [ - { - customization: { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - enabled: true, - status: CustomizationStatus.Loaded, - }, - { - customization: { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - enabled: true, - status: CustomizationStatus.Loaded, - }, - ]; + const pluginA: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; + const pluginB: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; + const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginA.id, uri: pluginA.uri, name: pluginA.name, enabled: true }; + const pluginBClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: pluginB.id, uri: pluginB.uri, name: pluginB.name, enabled: true }; + agent.getSessionCustomizations = async () => [pluginA, pluginB]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -897,10 +890,7 @@ suite('AgentSideEffects', () => { activeClient: { clientId: 'test-client', tools: [], - customizations: [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - ] + customizations: [pluginAClient, pluginBClient] }, }; sideEffects.handleAction(sessionUri.toString(), action); @@ -910,10 +900,7 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ clientId: 'test-client', - customizations: [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, - ], + customizations: [pluginAClient, pluginBClient], }]); const customizationActions = envelopes @@ -928,16 +915,13 @@ suite('AgentSideEffects', () => { test('dispatches customizationUpdated for sync progress after initial replacement', async () => { setupSession(); - const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; - let currentCustomizations: readonly SessionCustomization[] = []; + const pluginAClient: ClientPluginCustomization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }; + let currentCustomizations: readonly Customization[] = []; agent.getSessionCustomizations = async () => currentCustomizations; agent.setClientCustomizations = async (session, clientId, customizations) => { agent.setClientCustomizationsCalls.push({ clientId, customizations }); - currentCustomizations = [{ - customization, - enabled: true, - status: CustomizationStatus.Loading, - }]; + const loading: Customization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loading } }; + currentCustomizations = [loading]; agent.fireProgress({ kind: 'action', session, @@ -947,19 +931,14 @@ suite('AgentSideEffects', () => { }, }); await new Promise(resolve => setTimeout(resolve, 0)); - currentCustomizations = [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; + const loaded: Customization = { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } }; + currentCustomizations = [loaded]; agent.fireProgress({ kind: 'action', session, action: { type: ActionType.SessionCustomizationUpdated, - customization, - enabled: true, - status: CustomizationStatus.Loaded, + customization: loaded, }, }); return currentCustomizations.map(customization => ({ customization })); @@ -973,7 +952,7 @@ suite('AgentSideEffects', () => { activeClient: { clientId: 'test-client', tools: [], - customizations: [customization], + customizations: [pluginAClient], }, }); await new Promise(resolve => setTimeout(resolve, 50)); @@ -983,17 +962,14 @@ suite('AgentSideEffects', () => { const firstCustomizationsChanged = customizationsChanged[0].action; assert.strictEqual(firstCustomizationsChanged.type, ActionType.SessionCustomizationsChanged); assert.deepStrictEqual(firstCustomizationsChanged.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loading, + ...pluginAClient, + load: { kind: CustomizationLoadStatus.Loading }, }]); const customizationUpdated = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationUpdated); assert.deepStrictEqual(customizationUpdated.map(e => e.action), [{ type: ActionType.SessionCustomizationUpdated, - customization, - enabled: true, - status: CustomizationStatus.Loaded, + customization: { ...pluginAClient, load: { kind: CustomizationLoadStatus.Loaded } }, }]); }); @@ -1047,13 +1023,9 @@ suite('AgentSideEffects', () => { test('republishes agent and session customizations for existing sessions', async () => { setupSession('file:///workspace'); - const customization = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; + const customization: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; agent.customizations = [customization]; - agent.getSessionCustomizations = async () => [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; + agent.getSessionCustomizations = async () => [customization]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1073,11 +1045,7 @@ suite('AgentSideEffects', () => { const sessionCustomizationAction = envelopes.filter(e => e.action.type === ActionType.SessionCustomizationsChanged).at(-1); assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true })); - assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]); + assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [customization]); }); test('updates telemetry level from root config', () => { @@ -1106,13 +1074,9 @@ suite('AgentSideEffects', () => { disposables.add(sideEffects.registerProgressListener(agent)); setupSession('file:///workspace'); - const customization = { uri: 'file:///plugin-b', displayName: 'Plugin B' }; + const customization: Customization = { type: CustomizationType.Plugin, id: customizationId('file:///plugin-b'), uri: 'file:///plugin-b', name: 'Plugin B', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; agent.customizations = [customization]; - agent.getSessionCustomizations = async () => [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]; + agent.getSessionCustomizations = async () => [customization]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1126,18 +1090,14 @@ suite('AgentSideEffects', () => { const sessionCustomizationAction = envelopes.find(e => e.action.type === ActionType.SessionCustomizationsChanged); assert.ok(sessionCustomizationAction && hasKey(sessionCustomizationAction.action, { customizations: true })); - assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [{ - customization, - enabled: true, - status: CustomizationStatus.Loaded, - }]); + assert.deepStrictEqual(sessionCustomizationAction.action.customizations, [customization]); }); test('does not republish when registerProgressListener is disposed', async () => { const listener = sideEffects.registerProgressListener(agent); setupSession('file:///workspace'); - agent.customizations = [{ uri: 'file:///plugin-c', displayName: 'Plugin C' }]; + agent.customizations = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-c'), uri: 'file:///plugin-c', name: 'Plugin C', enabled: true }]; const envelopes: ActionEnvelope[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); @@ -1163,7 +1123,7 @@ suite('AgentSideEffects', () => { const action: SessionAction = { type: ActionType.SessionCustomizationToggled, - uri: 'file:///plugin-a', + id: 'file:///plugin-a', enabled: false, }; sideEffects.handleAction(sessionUri.toString(), action); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index f345884fbef9d..5445ad9cdddf9 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -29,7 +29,8 @@ import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPlu import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, CustomizationStatus, ResponsePartKind, SessionCustomization, ToolCallConfirmationReason, ToolCallStatus, TurnState, type CustomizationRef, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentSessionUri, CustomizationLoadStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type Customization, type MarkdownResponsePart, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; import { ActionType, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -51,7 +52,7 @@ class TestAgentPluginManager implements IAgentPluginManager { readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); - async syncCustomizations(_clientId: string, _customizations: CustomizationRef[], _progress?: (status: SessionCustomization) => void): Promise { + async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[], _progress?: (status: Customization) => void): Promise { return []; } } @@ -608,9 +609,9 @@ suite('CopilotAgent', () => { suite('createSession activeClient eager-claim', () => { class SpyingPluginManager extends TestAgentPluginManager { - public readonly calls: { clientId: string; customizations: CustomizationRef[] }[] = []; + public readonly calls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; - override async syncCustomizations(clientId: string, customizations: CustomizationRef[], _progress?: (status: SessionCustomization) => void): Promise { + override async syncCustomizations(clientId: string, customizations: ClientPluginCustomization[], _progress?: (status: Customization) => void): Promise { this.calls.push({ clientId, customizations: [...customizations] }); return []; } @@ -629,7 +630,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const customizations: CustomizationRef[] = [{ uri: 'file:///plugin-a', displayName: 'Plugin A' }]; + const customizations: ClientPluginCustomization[] = [{ type: CustomizationType.Plugin, id: customizationId('file:///plugin-a'), uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }]; const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'test-session'), workingDirectory: URI.file('/workspace'), @@ -681,9 +682,9 @@ suite('CopilotAgent', () => { ); class PluginDirSpyManager extends TestAgentPluginManager { - override async syncCustomizations(_clientId: string, customizations: CustomizationRef[]): Promise { + override async syncCustomizations(_clientId: string, customizations: ClientPluginCustomization[]): Promise { return customizations.map(c => ({ - customization: { customization: c, enabled: true, status: CustomizationStatus.Loaded }, + customization: { ...c, load: { kind: CustomizationLoadStatus.Loaded } }, pluginDir, })); } @@ -705,18 +706,21 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const session = AgentSession.uri('copilotcli', 'sync-customizations-test'); - await agent.setClientCustomizations(session, 'client-1', [{ uri: pluginDir.toString(), displayName: 'Plugin A' }]); + await agent.setClientCustomizations(session, 'client-1', [{ type: CustomizationType.Plugin, id: customizationId(pluginDir.toString()), uri: pluginDir.toString(), name: 'Plugin A', enabled: true }]); // Wait for the deferred resolution chain in PluginController.sync. await new Promise(r => setTimeout(r, 50)); - const updatesWithAgents = actions + const updatesWithChildren = actions .filter(a => a.type === ActionType.SessionCustomizationUpdated) .filter((a): a is Extract => true) - .filter(a => a.agents !== undefined); + .filter(a => a.customization.children !== undefined); - assert.strictEqual(updatesWithAgents.length > 0, true, 'expected SessionCustomizationUpdated to carry parsed agents'); - assert.deepStrictEqual(updatesWithAgents.at(-1)!.agents, [{ + assert.strictEqual(updatesWithChildren.length > 0, true, 'expected SessionCustomizationUpdated to carry parsed children'); + const agentChildren = updatesWithChildren.at(-1)!.customization.children!.filter(c => c.type === CustomizationType.Agent); + assert.deepStrictEqual(agentChildren, [{ + type: CustomizationType.Agent, + id: customizationId(URI.joinPath(pluginDir, 'agents', 'helper.md').toString()), uri: URI.joinPath(pluginDir, 'agents', 'helper.md').toString(), name: 'helper-agent', description: 'helps out', diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 0809a65b55b52..a4d95afd47f25 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -14,7 +14,7 @@ import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryReco import { ProtectedResourceMetadata, type MessageAttachment, type ModelSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationStatus, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, parseSubagentSessionUri, type CustomizationRef, type PendingMessage, type SessionCustomization, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -55,13 +55,13 @@ export class MockAgent implements IAgent { readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; readonly changeModelCalls: { session: URI; model: ModelSelection }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; - readonly setClientCustomizationsCalls: { clientId: string; customizations: CustomizationRef[] }[] = []; + readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ - customizations: CustomizationRef[] = []; + customizations: Customization[] = []; private readonly _onDidCustomizationsChange = new Emitter(); readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; - getSessionCustomizations?: (session: URI) => Promise; + getSessionCustomizations?: (session: URI) => Promise; /** * Configurable session history. Tests construct {@link IHistoryRecord} @@ -165,17 +165,16 @@ export class MockAgent implements IAgent { return true; } - getCustomizations(): CustomizationRef[] { + getCustomizations(): Customization[] { return this.customizations; } - async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { + async setClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise { this.setClientCustomizationsCalls.push({ clientId, customizations }); const results: ISyncedCustomization[] = customizations.map(c => ({ customization: { - customization: c, - enabled: true, - status: CustomizationStatus.Loaded, + ...c, + load: { kind: CustomizationLoadStatus.Loaded }, }, })); this._onDidSessionProgress.fire({ diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 40cd62cc117e7..a0d4a40c1cee9 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -7,7 +7,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { changesetReducer, sessionReducer } from '../../common/state/protocol/reducers.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationStatus, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type ChangesetState, type CustomizationAgentRef, type CustomizationRef, type SessionState } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationLoadStatus, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type SessionState } from '../../common/state/sessionState.js'; +import { CustomizationType } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { return { @@ -249,87 +250,46 @@ suite('changesetReducer', () => { }); }); -suite('sessionReducer – SessionCustomizationUpdated.agents', () => { +suite('sessionReducer – SessionCustomizationUpdated', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const ref: CustomizationRef = { uri: 'file:///plugin-a', displayName: 'Plugin A' }; - const agentA: CustomizationAgentRef = { uri: 'file:///plugin-a/agents/helper.md', name: 'helper' }; - const agentB: CustomizationAgentRef = { uri: 'file:///plugin-a/agents/reviewer.md', name: 'reviewer', description: 'reviews code' }; + const agentA: AgentCustomization = { type: CustomizationType.Agent, id: 'file:///plugin-a/agents/helper.md', uri: 'file:///plugin-a/agents/helper.md', name: 'helper' }; + const agentB: AgentCustomization = { type: CustomizationType.Agent, id: 'file:///plugin-a/agents/reviewer.md', uri: 'file:///plugin-a/agents/reviewer.md', name: 'reviewer', description: 'reviews code' }; - function withCustomization(status: CustomizationStatus): SessionState { - return sessionReducer(makeSession(), { - type: ActionType.SessionCustomizationUpdated, - customization: ref, + function pluginA(extra: Partial = {}): Customization { + return { + type: CustomizationType.Plugin, + id: 'file:///plugin-a', + uri: 'file:///plugin-a', + name: 'Plugin A', enabled: true, - status, - }); + ...extra, + }; } - test('insert: persists agents from the action onto SessionCustomization', () => { + test('insert: appends a new top-level customization with its children', () => { + const customization = pluginA({ load: { kind: CustomizationLoadStatus.Loaded }, children: [agentA, agentB] }); const state = sessionReducer(makeSession(), { type: ActionType.SessionCustomizationUpdated, - customization: ref, - enabled: true, - status: CustomizationStatus.Loaded, - agents: [agentA, agentB], - }); - - assert.deepStrictEqual(state.customizations, [{ - customization: ref, - enabled: true, - status: CustomizationStatus.Loaded, - agents: [agentA, agentB], - }]); - }); - - test('update: replaces previously-set agents when the action carries a new array', () => { - const seeded = sessionReducer(withCustomization(CustomizationStatus.Loading), { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentA], - }); - const next = sessionReducer(seeded, { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentB], + customization, }); - assert.deepStrictEqual(next.customizations?.[0].agents, [agentB]); - }); - - test('update: preserves existing agents when the action omits the field', () => { - const seeded = sessionReducer(withCustomization(CustomizationStatus.Loading), { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentA], - }); - const next = sessionReducer(seeded, { - type: ActionType.SessionCustomizationUpdated, - customization: ref, - status: CustomizationStatus.Loaded, - }); - - assert.deepStrictEqual(next.customizations?.[0], { - customization: ref, - enabled: true, - status: CustomizationStatus.Loaded, - agents: [agentA], - }); + assert.deepStrictEqual(state.customizations, [customization]); }); - test('update: an empty agents array is respected (means "no agents contributed")', () => { - const seeded = sessionReducer(withCustomization(CustomizationStatus.Loading), { + test('update: replaces the matching entry entirely', () => { + const initial = pluginA({ load: { kind: CustomizationLoadStatus.Loading }, children: [agentA] }); + const seeded = sessionReducer(makeSession(), { type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [agentA], + customization: initial, }); + const updated = pluginA({ load: { kind: CustomizationLoadStatus.Loaded }, children: [agentB] }); const next = sessionReducer(seeded, { type: ActionType.SessionCustomizationUpdated, - customization: ref, - agents: [], + customization: updated, }); - assert.deepStrictEqual(next.customizations?.[0].agents, []); + assert.deepStrictEqual(next.customizations, [updated]); }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index bf4be87e326df..ffce923f8b6d2 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -108,7 +108,7 @@ suite('SessionCustomizationDiscovery + SessionPluginBundler', () => { const result = await bundler.bundle(files); assert.ok(result); - assert.strictEqual(result.ref.displayName, 'VS Code Synced Data'); + assert.strictEqual(result.ref.name, 'VS Code Synced Data'); assert.ok(result.ref.nonce); const root = bundler.rootUri; diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 6a24992d322cb..238e510a63dd7 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -8,7 +8,7 @@ import { IObservable } from '../../base/common/observable.js'; import { equals } from '../../base/common/objects.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; -import { CustomizationAgentRef, RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; @@ -111,7 +111,7 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { * an empty array when the session is unknown or no agents have been * advertised. */ - getCustomAgents(sessionId: string): readonly CustomizationAgentRef[]; + getCustomAgents(sessionId: string): readonly AgentCustomization[]; /** * Set (or clear) the selected custom agent for a session. Optional so diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index d8483f2312295..166cef330cb3c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts @@ -28,7 +28,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { ChatContextKeyExprs } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { AICustomizationManagementCommands } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import type { CustomizationAgentRef } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { AgentCustomization } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { type IChatInputPickerOptions, ChatInputPickerActionViewItem } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { Menus } from '../../../../browser/menus.js'; import { IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; @@ -108,13 +108,13 @@ export function agentHostAgentPickerStorageKey(resourceScheme: string): string { * * Takes the agent URI directly (rather than an `ISessionAgentRef`) because * `ISession.mode` only carries the URI — the display name is recovered from - * the resolved {@link CustomizationAgentRef}. + * the resolved {@link AgentCustomization}. */ export function resolveAgentHostAgent( - agents: readonly CustomizationAgentRef[], + agents: readonly AgentCustomization[], selectedAgentUri: string | undefined, storedAgentUri: string | undefined, -): CustomizationAgentRef | undefined { +): AgentCustomization | undefined { if (selectedAgentUri) { const match = agents.find(a => a.uri === selectedAgentUri); if (match) { @@ -125,9 +125,9 @@ export function resolveAgentHostAgent( } interface IAgentPickerDelegate { - readonly currentAgent: IObservable; - readonly currentAgents: () => readonly CustomizationAgentRef[]; - readonly setAgent: (agent: CustomizationAgentRef | undefined) => void; + readonly currentAgent: IObservable; + readonly currentAgents: () => readonly AgentCustomization[]; + readonly setAgent: (agent: AgentCustomization | undefined) => void; readonly sessionResource: () => URI | undefined; } @@ -165,7 +165,7 @@ class AgentHostAgentPickerActionItem extends ChatInputPickerActionViewItem { }, }); - const makeAgentAction = (agent: CustomizationAgentRef): IActionWidgetDropdownAction => { + const makeAgentAction = (agent: AgentCustomization): IActionWidgetDropdownAction => { const current = this.delegate.currentAgent.get(); const agentUri = URI.parse(agent.uri); const toolbarActions: IAction[] = [{ @@ -189,7 +189,7 @@ class AgentHostAgentPickerActionItem extends ChatInputPickerActionViewItem { category: customCategory, toolbarActions, run: async () => { - this.delegate.setAgent({ uri: agent.uri, name: agent.name, ...(agent.description ? { description: agent.description } : {}) }); + this.delegate.setAgent(agent); if (this.element) { this.renderLabel(this.element); } @@ -280,7 +280,7 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC super(); const factory = (_action: import('../../../../../base/common/actions.js').IAction, _options: import('../../../../../base/browser/ui/actionbar/actionViewItems.js').IActionViewItemOptions, scopedInstantiationService: import('../../../../../platform/instantiation/common/instantiation.js').IInstantiationService) => { - const currentAgent = observableValue('currentAgent', undefined); + const currentAgent = observableValue('currentAgent', undefined); let settingAgentInternally = false; const getProvider = (session: ISession | undefined): IAgentHostSessionsProvider | undefined => { @@ -298,7 +298,7 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC const provider = getProvider(session); return session && provider ? provider.getCustomAgents(session.sessionId) : []; }, - setAgent: (agent: CustomizationAgentRef | undefined) => { + setAgent: (agent: AgentCustomization | undefined) => { const previous = currentAgent.get(); currentAgent.set(agent, undefined); const session = sessionsManagementService.activeSession.get(); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 10ff951e68e4d..a186388b50e81 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../ import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../../../../platform/agentHost/common/changesetUri.js'; import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentSelection, CustomizationAgentRef, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentSelection, AgentCustomization, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChangesetState, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -1689,7 +1689,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - getCustomAgents(sessionId: string): readonly CustomizationAgentRef[] { + getCustomAgents(sessionId: string): readonly AgentCustomization[] { const sessionState = this._lastSessionStates.get(sessionId); return getEffectiveAgents(sessionState?.customizations); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts index c17e768738c04..cc187dd87e080 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts @@ -5,15 +5,15 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import type { CustomizationAgentRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationType, type AgentCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { agentHostAgentPickerStorageKey, resolveAgentHostAgent } from '../../../../../../platform/agentHost/common/customAgents.js'; suite('agentHostAgentPicker', () => { ensureNoDisposablesAreLeakedInTestSuite(); - const alpha: CustomizationAgentRef = { uri: 'agent://a', name: 'alpha' }; - const beta: CustomizationAgentRef = { uri: 'agent://b', name: 'beta', description: 'b desc' }; - const agents: readonly CustomizationAgentRef[] = [alpha, beta]; + const alpha: AgentCustomization = { type: CustomizationType.Agent, id: 'agent://a', uri: 'agent://a', name: 'alpha' }; + const beta: AgentCustomization = { type: CustomizationType.Agent, id: 'agent://b', uri: 'agent://b', name: 'beta', description: 'b desc' }; + const agents: readonly AgentCustomization[] = [alpha, beta]; suite('agentHostAgentPickerStorageKey', () => { test('builds a per-scheme storage key', () => { @@ -26,10 +26,7 @@ suite('agentHostAgentPicker', () => { suite('resolveAgentHostAgent', () => { test('returns the session-selected agent when its URI is in the list', () => { - assert.deepStrictEqual( - resolveAgentHostAgent(agents, 'agent://b', undefined), - { uri: 'agent://b', name: 'beta', description: 'b desc' }, - ); + assert.deepStrictEqual(resolveAgentHostAgent(agents, 'agent://b', undefined), beta); }); test('falls back to the stored URI when the session has no selection', () => { @@ -39,28 +36,20 @@ suite('agentHostAgentPicker', () => { test('returns undefined when neither session nor stored selection matches the list', () => { assert.strictEqual(resolveAgentHostAgent(agents, undefined, 'agent://missing'), undefined); assert.strictEqual(resolveAgentHostAgent(agents, 'agent://missing', undefined), undefined); - assert.strictEqual(resolveAgentHostAgent(agents, 'agent://missing', undefined), undefined); }); test('session selection wins over stored selection', () => { - assert.deepStrictEqual( - resolveAgentHostAgent(agents, 'agent://a', 'agent://b'), - { uri: 'agent://a', name: 'alpha' }, - ); + assert.deepStrictEqual(resolveAgentHostAgent(agents, 'agent://a', 'agent://b'), alpha); }); test('falls through to stored URI when the session agent URI is not in the list', () => { // The session's recorded selection is no longer in the effective // agent list (e.g. the customization providing it was removed), // so the stored fallback is consulted. - assert.deepStrictEqual( - resolveAgentHostAgent(agents, 'agent://gone', 'agent://a'), - { uri: 'agent://a', name: 'alpha' }, - ); + assert.deepStrictEqual(resolveAgentHostAgent(agents, 'agent://gone', 'agent://a'), alpha); }); test('returns undefined for an empty agent list', () => { - assert.strictEqual(resolveAgentHostAgent([], 'agent://a', 'agent://a'), undefined); assert.strictEqual(resolveAgentHostAgent([], 'agent://a', 'agent://a'), undefined); assert.strictEqual(resolveAgentHostAgent([], undefined, undefined), undefined); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts index a09bdcc9d1108..fc4e7c0369a3d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgents.test.ts @@ -5,15 +5,28 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { CustomizationStatus, type CustomizationAgentRef, type SessionCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, type AgentCustomization, type Customization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -function sc(uri: string, agents?: CustomizationAgentRef[], enabled = true): SessionCustomization { +function sc(uri: string, children?: AgentCustomization[], enabled = true): Customization { return { - customization: { uri, displayName: uri }, + type: CustomizationType.Plugin, + id: uri, + uri, + name: uri, enabled, - status: CustomizationStatus.Loaded, - ...(agents ? { agents } : {}), + load: { kind: CustomizationLoadStatus.Loaded }, + ...(children ? { children } : {}), + }; +} + +function agent(uri: string, name: string, description?: string): AgentCustomization { + return { + type: CustomizationType.Agent, + id: uri, + uri, + name, + ...(description ? { description } : {}), }; } @@ -25,46 +38,46 @@ suite('getEffectiveAgents', () => { assert.deepStrictEqual(getEffectiveAgents([sc('plugin://a'), sc('plugin://b', [])]), []); }); - test('treats undefined `agents` as unknown and empty array as no agents', () => { + test('treats undefined `children` as unknown and empty array as no agents', () => { const result = getEffectiveAgents([ - sc('plugin://a', [{ uri: 'agent://review', name: 'review' }]), + sc('plugin://a', [agent('agent://review', 'review')]), sc('plugin://b', []), ]); - assert.deepStrictEqual(result, [{ uri: 'agent://review', name: 'review' }]); + assert.deepStrictEqual(result, [agent('agent://review', 'review')]); }); test('skips disabled session customizations', () => { const result = getEffectiveAgents([ - sc('plugin://a', [{ uri: 'agent://a', name: 'a' }], false), - sc('plugin://b', [{ uri: 'agent://b', name: 'b' }]), + sc('plugin://a', [agent('agent://a', 'a')], false), + sc('plugin://b', [agent('agent://b', 'b')]), ]); - assert.deepStrictEqual(result, [{ uri: 'agent://b', name: 'b' }]); + assert.deepStrictEqual(result, [agent('agent://b', 'b')]); }); test('de-dupes by uri (first-seen wins)', () => { const result = getEffectiveAgents([ sc('plugin://a', [ - { uri: 'agent://shared', name: 'shared', description: 'from a' }, - { uri: 'agent://only-a', name: 'only-a' }, + agent('agent://shared', 'shared', 'from a'), + agent('agent://only-a', 'only-a'), ]), sc('plugin://b', [ - { uri: 'agent://shared', name: 'shared', description: 'from b' }, - { uri: 'agent://only-b', name: 'only-b' }, + agent('agent://shared', 'shared', 'from b'), + agent('agent://only-b', 'only-b'), ]), ]); assert.deepStrictEqual(result, [ - { uri: 'agent://only-a', name: 'only-a' }, - { uri: 'agent://only-b', name: 'only-b' }, - { uri: 'agent://shared', name: 'shared', description: 'from a' }, + agent('agent://only-a', 'only-a'), + agent('agent://only-b', 'only-b'), + agent('agent://shared', 'shared', 'from a'), ]); }); test('sorts by name, breaking ties by uri', () => { const result = getEffectiveAgents([ sc('plugin://a', [ - { uri: 'agent://z', name: 'beta' }, - { uri: 'agent://x', name: 'beta' }, - { uri: 'agent://y', name: 'alpha' }, + agent('agent://z', 'beta'), + agent('agent://x', 'beta'), + agent('agent://y', 'alpha'), ]), ]); assert.deepStrictEqual(result.map(a => a.uri), ['agent://y', 'agent://x', 'agent://z']); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 69bde488915c1..03f9d4f06b34c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -15,7 +15,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { AgentSession, IAgentHostService, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { CustomizationStatus, SessionLifecycle, type AgentInfo, type ChangesetSummary, type ModelSelection, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, SessionLifecycle, type AgentInfo, type ChangesetSummary, type Customization, type ModelSelection, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, type ChangesetState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -746,34 +746,46 @@ suite('LocalAgentHostSessionsProvider', () => { lifecycle: SessionLifecycle.Ready, turns: [], customizations: [{ - customization: { uri: 'plugin://session-1', displayName: 'session plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://session-1', + uri: 'plugin://session-1', + name: 'session plugin', enabled: true, - status: CustomizationStatus.Loaded, - agents: [ - { uri: 'agent://shared', name: 'shared', description: 'from session' }, - { uri: 'agent://session-only', name: 'session-only' }, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [ + { type: CustomizationType.Agent, id: 'agent://shared', uri: 'agent://shared', name: 'shared', description: 'from session' }, + { type: CustomizationType.Agent, id: 'agent://session-only', uri: 'agent://session-only', name: 'session-only' }, ], }, { - customization: { uri: 'plugin://session-2', displayName: 'second session plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://session-2', + uri: 'plugin://session-2', + name: 'second session plugin', enabled: true, - status: CustomizationStatus.Loaded, - agents: [ - { uri: 'agent://another', name: 'another' }, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [ + { type: CustomizationType.Agent, id: 'agent://another', uri: 'agent://another', name: 'another' }, // Duplicate URI — must NOT replace the first-seen entry. - { uri: 'agent://shared', name: 'shared (duplicate)' }, + { type: CustomizationType.Agent, id: 'agent://shared-dup', uri: 'agent://shared', name: 'shared (duplicate)' }, ], }, { // Disabled customizations are skipped entirely. - customization: { uri: 'plugin://disabled', displayName: 'disabled plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://disabled', + uri: 'plugin://disabled', + name: 'disabled plugin', enabled: false, - status: CustomizationStatus.Loaded, - agents: [{ uri: 'agent://disabled', name: 'disabled' }], + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'agent://disabled', uri: 'agent://disabled', name: 'disabled' }], }, { - // Customizations with `agents === undefined` are treated as + // Customizations with `children === undefined` are treated as // "unknown" (host not yet finished parsing) and skipped. - customization: { uri: 'plugin://unparsed', displayName: 'unparsed plugin' }, + type: CustomizationType.Plugin, + id: 'plugin://unparsed', + uri: 'plugin://unparsed', + name: 'unparsed plugin', enabled: true, - status: CustomizationStatus.Loading, + load: { kind: CustomizationLoadStatus.Loading }, }], }; // Force a session-state subscription so `_lastSessionStates` gets @@ -783,10 +795,10 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.setSessionState('agents-merge', 'copilotcli', fakeState); assert.deepStrictEqual(provider.getCustomAgents(session!.sessionId), [ - { uri: 'agent://another', name: 'another' }, - { uri: 'agent://session-only', name: 'session-only' }, + { type: CustomizationType.Agent, id: 'agent://another', uri: 'agent://another', name: 'another' }, + { type: CustomizationType.Agent, id: 'agent://session-only', uri: 'agent://session-only', name: 'session-only' }, // First-seen wins for the duplicate `agent://shared` URI. - { uri: 'agent://shared', name: 'shared', description: 'from session' }, + { type: CustomizationType.Agent, id: 'agent://shared', uri: 'agent://shared', name: 'shared', description: 'from session' }, ]); }); @@ -803,8 +815,11 @@ suite('LocalAgentHostSessionsProvider', () => { description: '', models: [], customizations: [{ + type: CustomizationType.Plugin, + id: 'plugin://root', uri: 'plugin://root', - displayName: 'root plugin', + name: 'root plugin', + enabled: true, }], } as AgentInfo, ]); @@ -846,13 +861,13 @@ suite('LocalAgentHostSessionsProvider', () => { lifecycle: SessionLifecycle.Ready, turns: [], customizations: [{ - customization: { - uri: 'plugin://s', - displayName: 'session plugin', - }, + type: CustomizationType.Plugin, + id: 'plugin://s', + uri: 'plugin://s', + name: 'session plugin', enabled: true, - status: CustomizationStatus.Loaded, - agents: [{ uri: 'agent://s', name: 's' }], + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'agent://s', uri: 'agent://s', name: 's' }], }], }); assert.ok(fired > afterRoot, 'expected event to fire on session state customization change'); @@ -891,13 +906,16 @@ suite('LocalAgentHostSessionsProvider', () => { // Push a SessionState carrying customizations as if the host had // resolved them and dispatched a SessionCustomizationsChanged. - const customizations = [{ - customization: { uri: 'plugin://new-session', displayName: 'p' }, + const customizations: Customization[] = [{ + type: CustomizationType.Plugin, + id: 'plugin://new-session', + uri: 'plugin://new-session', + name: 'p', enabled: true, - status: CustomizationStatus.Loaded, - agents: [ - { uri: 'agent://reviewer', name: 'reviewer' }, - { uri: 'agent://triage', name: 'triage' }, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [ + { type: CustomizationType.Agent, id: 'agent://reviewer', uri: 'agent://reviewer', name: 'reviewer' }, + { type: CustomizationType.Agent, id: 'agent://triage', uri: 'agent://triage', name: 'triage' }, ], }]; const state: SessionState = { @@ -916,8 +934,8 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.setSessionState(rawId, sessionTypeId, state); assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ - { uri: 'agent://reviewer', name: 'reviewer' }, - { uri: 'agent://triage', name: 'triage' }, + { type: CustomizationType.Agent, id: 'agent://reviewer', uri: 'agent://reviewer', name: 'reviewer' }, + { type: CustomizationType.Agent, id: 'agent://triage', uri: 'agent://triage', name: 'triage' }, ]); assert.ok(fired > 0, 'expected onDidChangeCustomAgents to fire when SessionState arrives'); @@ -928,11 +946,11 @@ suite('LocalAgentHostSessionsProvider', () => { ...state, customizations: [{ ...customizations[0], - agents: [{ uri: 'agent://only', name: 'only' }], + children: [{ type: CustomizationType.Agent, id: 'agent://only', uri: 'agent://only', name: 'only' }], }], }); assert.deepStrictEqual(provider.getCustomAgents(session.sessionId), [ - { uri: 'agent://only', name: 'only' }, + { type: CustomizationType.Agent, id: 'agent://only', uri: 'agent://only', name: 'only' }, ]); assert.ok(fired > after, 'expected onDidChangeCustomAgents to fire again on a second update'); }); @@ -956,10 +974,13 @@ suite('LocalAgentHostSessionsProvider', () => { lifecycle: SessionLifecycle.Ready, turns: [], customizations: [{ - customization: { uri: 'plugin://x', displayName: 'p' }, + type: CustomizationType.Plugin, + id: 'plugin://x', + uri: 'plugin://x', + name: 'p', enabled: true, - status: CustomizationStatus.Loaded, - agents: [{ uri: 'agent://x', name: 'x' }], + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'agent://x', uri: 'agent://x', name: 'x' }], }], }); assert.strictEqual(provider.getCustomAgents(first.sessionId).length, 1); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 385adb2083ed5..56852642f0335 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -17,15 +17,16 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import type { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { ROOT_STATE_URI, type AgentInfo, type CustomizationRef } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ROOT_STATE_URI, customizationId, type AgentInfo, type Customization } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationSyncProvider, type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; +import { CustomizationType } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -function customizationKey(customization: CustomizationRef): string { +function customizationKey(customization: Customization): string { return customization.uri; } @@ -57,12 +58,12 @@ export class RemoteAgentPluginController extends Disposable { ]; } - async removeConfiguredPlugin(customizationToRemove: CustomizationRef): Promise { + async removeConfiguredPlugin(customizationToRemove: Customization): Promise { const updated = this.getConfiguredCustomizations().filter(customization => customizationKey(customization) !== customizationKey(customizationToRemove)); this.dispatchCustomizations(updated); } - private getConfiguredCustomizations(): readonly CustomizationRef[] { + private getConfiguredCustomizations(): readonly Customization[] { const rootState = this._connection.rootState.value; if (!rootState || rootState instanceof Error) { return []; @@ -71,11 +72,14 @@ export class RemoteAgentPluginController extends Disposable { return getAgentHostConfiguredCustomizations(rootState.config?.values); } - private dispatchCustomizations(customizations: readonly CustomizationRef[]): void { + private dispatchCustomizations(customizations: readonly Customization[]): void { this._connection.dispatch(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { - [AgentHostConfigKey.Customizations]: [...customizations], + [AgentHostConfigKey.Customizations]: customizations.map(c => ({ + uri: c.uri, + displayName: c.name, + })), }, }); } @@ -103,9 +107,13 @@ export class RemoteAgentPluginController extends Disposable { } const original = fromAgentHostUri(selected); - const newCustomization: CustomizationRef = { - uri: original.toString(), - displayName: basename(original) || original.path, + const uriString = original.toString(); + const newCustomization: Customization = { + type: CustomizationType.Plugin, + id: customizationId(uriString), + uri: uriString, + name: basename(original) || original.path, + enabled: true, }; const current = this.getConfiguredCustomizations(); @@ -114,7 +122,7 @@ export class RemoteAgentPluginController extends Disposable { this._notificationService.info(localize( 'remoteAgentHost.pluginAlreadyConfigured', "'{0}' is already configured on {1}.", - newCustomization.displayName, + newCustomization.name, this._hostLabel, )); return; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index fbb374e1b7438..cdb0d4baa4df6 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -10,7 +10,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, isSessionAction, type ActionEnvelope, type INotification, type StateAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { CustomizationStatus, type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationLoadStatus, CustomizationType, type AgentInfo, type Customization, type RootState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { StateComponents, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -107,7 +107,7 @@ const testSessionResource = URI.parse('agent-host-copilotcli:/session-1'); const agentHostProviderId = 'copilotcli'; const agentHostSessionId = `${agentHostProviderId}:/session-1`; -function createAgentInfo(customizations: readonly CustomizationRef[]): AgentInfo { +function createAgentInfo(customizations: readonly Customization[]): AgentInfo { return { provider: agentHostProviderId, displayName: 'Copilot', @@ -134,16 +134,17 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const pluginA: CustomizationRef = { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' }; - const pluginB: CustomizationRef = { - uri: 'file:///plugins/other', - displayName: 'Other Plugin', - }; + const pluginA: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/shared', uri: 'file:///plugins/shared', name: 'Shared Plugin', enabled: true }; connection.setRootState({ agents: [], config: { schema: { type: 'object', properties: {} }, - values: { customizations: [pluginA, pluginB] }, + values: { + customizations: [ + { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' }, + { uri: 'file:///plugins/other', displayName: 'Other Plugin' }, + ], + }, }, }); @@ -154,7 +155,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.RootConfigChanged, config: { - customizations: [pluginB], + customizations: [{ uri: 'file:///plugins/other', displayName: 'Other Plugin' }], }, }, }]); @@ -170,8 +171,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const pluginA: CustomizationRef = { uri: 'file:///plugins/a', displayName: 'Plugin A' }; - const pluginB: CustomizationRef = { uri: 'file:///plugins/b', displayName: 'Plugin B' }; + const pluginA: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/a', uri: 'file:///plugins/a', name: 'Plugin A', enabled: true }; + const pluginB: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/b', uri: 'file:///plugins/b', name: 'Plugin B', enabled: true }; connection.setRootState({ agents: [createAgentInfo([pluginA, pluginB])], @@ -206,11 +207,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const hostScoped: CustomizationRef = { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' }; - const synced: SessionCustomization = { - customization: hostScoped, + const hostScoped: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/shared', uri: 'file:///plugins/shared', name: 'Shared Plugin', enabled: true }; + const synced: Customization = { + ...hostScoped, clientId: 'test-client', - enabled: true, }; connection.setRootState({ @@ -256,12 +256,11 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const hostPlugin: CustomizationRef = { uri: 'file:///plugins/host-plugin', displayName: 'Host Plugin' }; - const clientPlugin: CustomizationRef = { uri: 'file:///plugins/client-plugin', displayName: 'Client Plugin' }; - const synced: SessionCustomization = { - customization: clientPlugin, + const hostPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/host-plugin', uri: 'file:///plugins/host-plugin', name: 'Host Plugin', enabled: true }; + const clientPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client-plugin', uri: 'file:///plugins/client-plugin', name: 'Client Plugin', enabled: true }; + const synced: Customization = { + ...clientPlugin, clientId: 'test-client', - enabled: true, }; connection.setRootState({ @@ -315,12 +314,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { )); const bundleUri = `${SYNCED_CUSTOMIZATION_SCHEME}:///test-authority`; - const bundleRef: CustomizationRef = { uri: bundleUri, displayName: 'VS Code Synced Data', nonce: 'abc' }; - const synced: SessionCustomization = { - customization: bundleRef, + const bundleRef: Customization = { type: CustomizationType.Plugin, id: bundleUri, uri: bundleUri, name: 'VS Code Synced Data', enabled: true, load: { kind: CustomizationLoadStatus.Loaded } }; + const synced: Customization = { + ...bundleRef, clientId: 'test-client', - enabled: true, - status: CustomizationStatus.Loaded, }; connection.setRootState({ agents: [createAgentInfo([])] }); @@ -411,11 +408,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { )); const bundleUri = `${SYNCED_CUSTOMIZATION_SCHEME}:///test-authority`; - const bundleRef: CustomizationRef = { uri: bundleUri, displayName: 'VS Code Synced Data', nonce: 'abc' }; - const synced: SessionCustomization = { - customization: bundleRef, + const bundleRef: Customization = { type: CustomizationType.Plugin, id: bundleUri, uri: bundleUri, name: 'VS Code Synced Data', enabled: true }; + const synced: Customization = { + ...bundleRef, clientId: 'test-client', - enabled: true, }; connection.setRootState({ agents: [createAgentInfo([])] }); @@ -464,12 +460,11 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const pluginRef: CustomizationRef = { uri: 'file:///plugins/my-plugin', displayName: 'My Plugin' }; - const sessionCustomization: SessionCustomization = { - customization: pluginRef, + const pluginRef: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/my-plugin', uri: 'file:///plugins/my-plugin', name: 'My Plugin', enabled: true }; + const sessionCustomization: Customization = { + ...pluginRef, enabled: false, - status: CustomizationStatus.Error, - statusMessage: 'something went wrong', + load: { kind: CustomizationLoadStatus.Error, message: 'something went wrong' }, }; connection.setRootState({ agents: [createAgentInfo([pluginRef])] }); @@ -517,7 +512,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const pluginRef: CustomizationRef = { uri: 'file:///plugins/host', displayName: 'Host Plugin' }; + const pluginRef: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/host', uri: 'file:///plugins/host', name: 'Host Plugin', enabled: true }; connection.setRootState({ agents: [createAgentInfo([pluginRef])] }); const fileService = new class extends mock() { @@ -543,10 +538,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { origin: undefined, action: { type: ActionType.SessionCustomizationsChanged, - customizations: [{ - customization: pluginRef, - enabled: true - }], + customizations: [pluginRef], }, }); @@ -564,8 +556,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const hostPlugin: CustomizationRef = { uri: 'file:///plugins/host', displayName: 'Host Plugin' }; - const clientPlugin: CustomizationRef = { uri: 'file:///plugins/client', displayName: 'Client Plugin' }; + const hostPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/host', uri: 'file:///plugins/host', name: 'Host Plugin', enabled: true }; + const clientPlugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client', uri: 'file:///plugins/client', name: 'Client Plugin', enabled: true }; connection.setRootState({ agents: [createAgentInfo([hostPlugin])] }); @@ -590,9 +582,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.SessionCustomizationsChanged, customizations: [{ - customization: clientPlugin, + ...clientPlugin, clientId: 'test-client', - enabled: true }], }, }); @@ -618,15 +609,19 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const pluginA: CustomizationRef = { uri: 'file:///plugins/a', displayName: 'Plugin A' }; - const pluginB: CustomizationRef = { uri: 'file:///plugins/b', displayName: 'Plugin B' }; - const pluginC: CustomizationRef = { uri: 'file:///plugins/c', displayName: 'Plugin C' }; + const pluginB: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/b', uri: 'file:///plugins/b', name: 'Plugin B', enabled: true }; connection.setRootState({ agents: [], config: { schema: { type: 'object', properties: {} }, - values: { customizations: [pluginA, pluginB, pluginC] }, + values: { + customizations: [ + { uri: 'file:///plugins/a', displayName: 'Plugin A' }, + { uri: 'file:///plugins/b', displayName: 'Plugin B' }, + { uri: 'file:///plugins/c', displayName: 'Plugin C' }, + ], + }, }, }); @@ -638,7 +633,10 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.RootConfigChanged, config: { - customizations: [pluginA, pluginC], + customizations: [ + { uri: 'file:///plugins/a', displayName: 'Plugin A' }, + { uri: 'file:///plugins/c', displayName: 'Plugin C' }, + ], }, }, }); @@ -655,8 +653,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { {} as IAICustomizationWorkspaceService, )); - const clientA: CustomizationRef = { uri: 'file:///plugins/client-a', displayName: 'Client A' }; - const clientB: CustomizationRef = { uri: 'file:///plugins/client-b', displayName: 'Client B' }; + const clientA: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client-a', uri: 'file:///plugins/client-a', name: 'Client A', enabled: true }; + const clientB: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/client-b', uri: 'file:///plugins/client-b', name: 'Client B', enabled: true }; connection.setRootState({ agents: [createAgentInfo([])] }); @@ -681,8 +679,8 @@ suite('RemoteAgentHostCustomizationHarness', () => { action: { type: ActionType.SessionCustomizationsChanged, customizations: [ - { customization: clientA, clientId: 'test-client', enabled: true }, - { customization: clientB, clientId: 'test-client', enabled: true }, + { ...clientA, clientId: 'test-client' }, + { ...clientB, clientId: 'test-client' }, ], }, }); @@ -705,7 +703,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const plugin: CustomizationRef = { uri: 'file:///plugins/skills-bundle', displayName: 'Skills Bundle' }; + const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/skills-bundle', uri: 'file:///plugins/skills-bundle', name: 'Skills Bundle', enabled: true }; connection.setRootState({ agents: [createAgentInfo([plugin])] }); @@ -779,7 +777,7 @@ suite('RemoteAgentHostCustomizationHarness', () => { createNotificationService(), {} as IAICustomizationWorkspaceService, )); - const plugin: CustomizationRef = { uri: 'file:///plugins/skills-bundle', displayName: 'Skills Bundle' }; + const plugin: Customization = { type: CustomizationType.Plugin, id: 'file:///plugins/skills-bundle', uri: 'file:///plugins/skills-bundle', name: 'Skills Bundle', enabled: true }; connection.setRootState({ agents: [createAgentInfo([plugin])] }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index f73980dbefbe2..03e839980da73 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { CustomizationStatus, StateComponents, type SessionCustomization, type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationLoadStatus, CustomizationType, StateComponents, type AgentInfo, type ClientPluginCustomization, type Customization, type CustomizationLoadState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ICustomizationAgentRef, ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; @@ -32,7 +32,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - private _agentCustomizations: readonly CustomizationRef[]; + private _agentCustomizations: readonly Customization[]; /** Cache: pluginUri → last expansion (keyed by nonce so we re-fetch on content change). */ private readonly _expansionCache = new ResourceMap<{ nonce: string | undefined; children: readonly ICustomizationItem[] }>(); @@ -44,7 +44,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto private readonly _connectionAuthority: string, private readonly _fileService: IFileService, private readonly _logService: ILogService, - private readonly _getItemActions?: (customization: CustomizationRef, clientId: string | undefined) => ICustomizationItemAction[] | undefined, + private readonly _getItemActions?: (customization: Customization, clientId: string | undefined) => ICustomizationItemAction[] | undefined, ) { super(); this._contentExpander = new AgentCustomizationContentExpander(this._fileService, this._logService); @@ -67,7 +67,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } - private _readRootCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined { + private _readRootCustomizations(rootState: RootState | Error | undefined): readonly Customization[] | undefined { if (!rootState || rootState instanceof Error || !rootState.config) { return undefined; } @@ -75,7 +75,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return getAgentHostConfiguredCustomizations(rootState.config?.values); } - private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly CustomizationRef[] | undefined { + private _readAgentCustomizations(rootState: RootState | Error | undefined): readonly Customization[] | undefined { if (!rootState || rootState instanceof Error) { return undefined; } @@ -95,7 +95,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return toAgentHostUri(original, this._connectionAuthority); } - private toBadge(customization: CustomizationRef, fromClient: boolean): { badge?: string; badgeTooltip?: string; groupKey?: string } { + private toBadge(customization: Customization, fromClient: boolean): { badge?: string; badgeTooltip?: string; groupKey?: string } { if (fromClient) { return { groupKey: REMOTE_CLIENT_GROUP, @@ -107,20 +107,20 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }; } - private toItem(customization: CustomizationRef, source: AICustomizationSource, sessionCustomization?: SessionCustomization): ICustomizationItem { - const clientId = sessionCustomization?.clientId; // set if the configuration came from the client + private toItem(customization: Customization, source: AICustomizationSource): ICustomizationItem { + const clientId = customization.clientId; // set if the configuration came from the client const badge = this.toBadge(customization, clientId !== undefined); const uri = this.toRemoteUri(customization.uri); return { itemKey: customizationItemKey(customization, clientId), uri: uri, type: 'plugin', - name: customization.displayName, - description: customization.description, + name: customization.name, + description: undefined, source, - status: toStatusString(sessionCustomization?.status), - statusMessage: sessionCustomization?.statusMessage, - enabled: sessionCustomization?.enabled ?? true, + status: toStatusString(customization.load), + statusMessage: toStatusMessage(customization.load), + enabled: customization.enabled, badge: badge.badge, badgeTooltip: badge.badgeTooltip, groupKey: badge.groupKey, @@ -135,7 +135,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return AgentSession.uri(this._agentInfo.provider, rawId); } - private getSessionCustomizations(sessionResource: URI): readonly SessionCustomization[] { + private getSessionCustomizations(sessionResource: URI): readonly Customization[] { const sessionUri = this._resolveSessionUri(sessionResource); const sessionState = this._connection.getSubscriptionUnmanaged(StateComponents.Session, sessionUri)?.value; return sessionState && !(sessionState instanceof Error) ? sessionState.customizations ?? [] : []; @@ -143,7 +143,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto async provideCustomAgents(sessionResource: URI): Promise { const sessionCustomizations = this.getSessionCustomizations(sessionResource); - const agents = sessionCustomizations.flatMap(c => c.agents ?? []); + const agents = sessionCustomizations.flatMap(c => c.children?.filter(child => child.type === CustomizationType.Agent) ?? []); return agents.map(agent => ({ uri: this.toRemoteUri(agent.uri), name: agent.name, @@ -163,7 +163,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto items.set(customizationItemKey(customization, undefined), item); const pluginMeta = { item, - nonce: customization.nonce, + nonce: (customization as ClientPluginCustomization).nonce, status: undefined, statusMessage: undefined, enabled: undefined, @@ -174,7 +174,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto expandPromises.push(this._expandPluginContents(pluginMeta, token)); } for (const sessionCustomization of this.getSessionCustomizations(sessionResource)) { - const isBundleItem = isSyntheticBundle(sessionCustomization.customization); + const isBundleItem = isSyntheticBundle(sessionCustomization); const isClientSynced = sessionCustomization.clientId !== undefined; const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP; @@ -185,17 +185,17 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto // expanded below so individual user files appear in per-type tabs. let item: ICustomizationItem; if (!isBundleItem) { - item = this.toItem(sessionCustomization.customization, AICustomizationSources.plugin, sessionCustomization); - items.set(customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId), item); + item = this.toItem(sessionCustomization, AICustomizationSources.plugin); + items.set(customizationItemKey(sessionCustomization, sessionCustomization.clientId), item); } else { // create a dummy parent item for the synthetic bundle, it does not go into the items map, just need it to expand. - item = { uri: this.toRemoteUri(sessionCustomization.customization.uri), type: 'plugin', source: AICustomizationSources.plugin, name: '', groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } satisfies ICustomizationItem; + item = { uri: this.toRemoteUri(sessionCustomization.uri), type: 'plugin', source: AICustomizationSources.plugin, name: '', groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } satisfies ICustomizationItem; } const pluginMeta = { item, - nonce: sessionCustomization.customization.nonce, - status: toStatusString(sessionCustomization.status), - statusMessage: sessionCustomization.statusMessage, + nonce: (sessionCustomization as ClientPluginCustomization).nonce, + status: toStatusString(sessionCustomization.load), + statusMessage: toStatusMessage(sessionCustomization.load), enabled: sessionCustomization.enabled, childGroupKey, isBundleItem @@ -243,21 +243,22 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } } -function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { - switch (status) { - case CustomizationStatus.Loading: return 'loading'; - case CustomizationStatus.Loaded: return 'loaded'; - case CustomizationStatus.Degraded: return 'degraded'; - case CustomizationStatus.Error: return 'error'; - default: return undefined; +function toStatusString(load: CustomizationLoadState | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { + return load?.kind; +} + +function toStatusMessage(load: CustomizationLoadState | undefined): string | undefined { + if (load?.kind === CustomizationLoadStatus.Degraded || load?.kind === CustomizationLoadStatus.Error) { + return load.message; } + return undefined; } -function customizationKey(customization: CustomizationRef): string { - return customization.uri; +function customizationKey(customization: Customization): string { + return customization.id; } -function customizationItemKey(customization: CustomizationRef, clientId: string | undefined): string { +function customizationItemKey(customization: Customization, clientId: string | undefined): string { return clientId !== undefined ? `${customizationKey(customization)}::${clientId}` : customizationKey(customization); @@ -268,7 +269,7 @@ function customizationItemKey(customization: CustomizationRef, clientId: string * which is an implementation detail of the customization sync pipeline * and should not be surfaced as a standalone item in the UI. */ -function isSyntheticBundle(customization: CustomizationRef): boolean { +function isSyntheticBundle(customization: Customization): boolean { try { return URI.parse(customization.uri).scheme === SYNCED_CUSTOMIZATION_SCHEME; } catch { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index d8a8ec3a4b0e3..cb6f468e915bc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -13,7 +13,8 @@ import { InstantiationType, registerSingleton } from '../../../../../../platform import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; -import type { CustomizationRef, SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -48,7 +49,7 @@ export interface IAgentHostActiveClientService { /** Returns a {@link SessionActiveClient} for `sessionType` using the caller-supplied `clientId`. Customizations are empty when `sessionType` has not been registered. */ getActiveClient(sessionType: string, clientId: string): SessionActiveClient; - getCustomizations(sessionType: string): IObservable; + getCustomizations(sessionType: string): IObservable; readonly clientTools: IObservable; } @@ -56,7 +57,7 @@ export interface IAgentHostActiveClientService { export class AgentHostActiveClientService extends Disposable implements IAgentHostActiveClientService { declare readonly _serviceBrand: undefined; - private readonly _customizationsByType: ISettableObservable>>; + private readonly _customizationsByType: ISettableObservable>>; readonly clientTools: IObservable; constructor( @@ -85,7 +86,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const store = new DisposableStore(); const syncProvider = store.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); const bundler = store.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); - const customizations = observableValue('agentCustomizations', []); + const customizations = observableValue('agentCustomizations', []); let updateSeq = 0; const updateCustomizations = async () => { const seq = ++updateSeq; @@ -117,7 +118,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo }; } - private _setCustomizations(sessionType: string, customizations: IObservable): IDisposable { + private _setCustomizations(sessionType: string, customizations: IObservable): IDisposable { const next = new Map(this._customizationsByType.get()); next.set(sessionType, customizations); this._customizationsByType.set(next, undefined); @@ -140,11 +141,11 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo }; } - getCustomizations(sessionType: string): IObservable { + getCustomizations(sessionType: string): IObservable { return derived(reader => this._customizationsByType.read(reader).get(sessionType)?.read(reader) ?? EMPTY_CUSTOMIZATIONS); } } -const EMPTY_CUSTOMIZATIONS: readonly CustomizationRef[] = Object.freeze([]); +const EMPTY_CUSTOMIZATIONS: readonly ClientPluginCustomization[] = Object.freeze([]); registerSingleton(IAgentHostActiveClientService, AgentHostActiveClientService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts index 7504c619fe7f9..52f215ba3d947 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomAgentPicker.ts @@ -20,7 +20,7 @@ import { IAgentHostService } from '../../../../../../platform/agentHost/common/a import { agentHostAgentPickerStorageKey, getEffectiveAgents, resolveAgentHostAgent } from '../../../../../../platform/agentHost/common/customAgents.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import type { CustomizationAgentRef, SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { AgentCustomization, SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -80,7 +80,7 @@ function toBackendSessionUri(sessionResource: URI): URI | undefined { */ export class WorkbenchAgentHostAgentPickerActionItem extends ChatInputPickerActionViewItem { - private readonly _currentAgent = observableValue('agentHostCurrentAgent', undefined); + private readonly _currentAgent = observableValue('agentHostCurrentAgent', undefined); private readonly _subRef = this._register(new MutableDisposable; readonly backendSession: URI }>()); /** Captured at construction so the footer menu doesn't depend on a private parent field. */ private readonly _ctxKeyService: IContextKeyService; @@ -261,7 +261,7 @@ export class WorkbenchAgentHostAgentPickerActionItem extends ChatInputPickerActi return value && !(value instanceof Error) ? value : undefined; } - private _currentAgents(): readonly CustomizationAgentRef[] { + private _currentAgents(): readonly AgentCustomization[] { return getEffectiveAgents(this._readState()?.customizations); } @@ -279,7 +279,7 @@ export class WorkbenchAgentHostAgentPickerActionItem extends ChatInputPickerActi this._currentAgent.set(resolved, undefined); } - private _userSetAgent(agent: CustomizationAgentRef | undefined): void { + private _userSetAgent(agent: AgentCustomization | undefined): void { const resource = this._sessionResource(); const backend = resource ? this._resolveBackend(resource) : undefined; if (!resource || !backend) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 5eb615ab1c1be..13f9d55fc8283 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -6,8 +6,8 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { AICustomizationSource, AICustomizationSources, BUILTIN_STORAGE } from '../../../common/aiCustomizationWorkspaceService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptPath, IPromptsService, matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; @@ -134,7 +134,7 @@ export async function resolveCustomizationRefs( agentPluginService: IAgentPluginService, bundler: SyncedCustomizationBundler, sessionType: string, -): Promise { +): Promise { const enumerated = await enumerateLocalCustomizationsForHarness(promptsService, syncProvider, sessionType, CancellationToken.None); const enabled = enumerated.filter(e => !e.disabled); if (enabled.length === 0) { @@ -142,7 +142,7 @@ export async function resolveCustomizationRefs( } const plugins = agentPluginService.plugins.get(); - const pluginRefs = new Map(); + const pluginRefs = new Map(); const looseFiles: { uri: URI; type: PromptsType }[] = []; for (const entry of enabled) { @@ -156,14 +156,20 @@ export async function resolveCustomizationRefs( } const key = plugin.uri.toString(); if (!pluginRefs.has(key)) { - pluginRefs.set(key, { uri: key as ProtocolURI, displayName: plugin.label }); + pluginRefs.set(key, { + type: CustomizationType.Plugin, + id: customizationId(key), + uri: key as ProtocolURI, + name: plugin.label, + enabled: true, + }); } } else { looseFiles.push({ uri: entry.uri, type: entry.type }); } } - const refs: CustomizationRef[] = [...pluginRefs.values()]; + const refs: ClientPluginCustomization[] = [...pluginRefs.values()]; if (looseFiles.length > 0) { const result = await bundler.bundle(looseFiles); if (result) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 9d16340c982d6..3c3b30960e4e4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -23,10 +23,10 @@ import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../ import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -931,7 +931,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * role for this session and publish the current customizations and * client-provided tools. */ - private _dispatchActiveClient(backendSession: URI, customizations: CustomizationRef[]): void { + private _dispatchActiveClient(backendSession: URI, customizations: ClientPluginCustomization[]): void { const current = this._getCurrentActiveClient(); this._dispatchAction(backendSession, { type: ActionType.SessionActiveClientChanged, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts index b473d3982c9f1..4ca9c8c31d485 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts @@ -10,8 +10,8 @@ import { URI } from '../../../../../../base/common/uri.js'; import { hash } from '../../../../../../base/common/hash.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; -import { type CustomizationRef } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; // Re-export so existing consumers don't need to change their import source. @@ -48,7 +48,7 @@ interface ISyncableFile { } interface IBundleResult { - readonly ref: CustomizationRef; + readonly ref: ClientPluginCustomization; } /** @@ -98,7 +98,7 @@ export class SyncedCustomizationBundler extends Disposable { /** * Bundles the given files into the in-memory plugin filesystem. * - * Overwrites any previous bundle content. Returns a {@link CustomizationRef} + * Overwrites any previous bundle content. Returns a {@link ClientPluginCustomization} * pointing at the virtual plugin directory with a content-based nonce. * * @returns The bundle result, or `undefined` if no syncable files were provided. @@ -155,11 +155,14 @@ export class SyncedCustomizationBundler extends Disposable { this._lastNonce = nonce; + const rootUriString = this._rootUri.toString() as ProtocolURI; return { ref: { - uri: this._rootUri.toString() as ProtocolURI, - displayName: DISPLAY_NAME, - description: `${syncable.length} customization(s) synced from VS Code`, + type: CustomizationType.Plugin, + id: customizationId(rootUriString), + uri: rootUriString, + name: DISPLAY_NAME, + enabled: true, nonce, }, }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 1abcf628f2aca..7c4e8828a15a9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -20,7 +20,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, Ag import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/agentFeedbackAttachments.js'; import { ActionType, isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import type { CustomizationRef, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationType, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; @@ -498,8 +498,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv disposeSession: async () => { }, ...provisionalServiceOverride, } as Partial as IAgentHostUntitledProvisionalSessionService); - const customizationsByType = new Map>(); - const seedActiveClient = (sessionType: string, entry: { customizations: IObservable }): IDisposable => { + const customizationsByType = new Map>(); + const seedActiveClient = (sessionType: string, entry: { customizations: IObservable }): IDisposable => { customizationsByType.set(sessionType, entry.customizations); return toDisposable(() => { if (customizationsByType.get(sessionType) === entry.customizations) { @@ -514,7 +514,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv // `seedActiveClient` directly. This stub just records an empty // entry so the contribution flow completes. const inner = seedActiveClient(sessionType, { - customizations: constObservable([]), + customizations: constObservable([]), }); return { syncProvider: { @@ -4397,8 +4397,8 @@ suite('AgentHostChatContribution', () => { test('dispatches activeClientChanged when a new session is created', async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); - const customizations = observableValue('customizations', [ - { uri: 'file:///plugin-a', displayName: 'Plugin A' }, + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-a', uri: 'file:///plugin-a', name: 'Plugin A', enabled: true }, ]); disposables.add(seedActiveClient('agent-host-copilot', { customizations })); @@ -4429,7 +4429,7 @@ suite('AgentHostChatContribution', () => { test('re-dispatches activeClientChanged when customizations observable changes', async () => { const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); - const customizations = observableValue('customizations', []); + const customizations = observableValue('customizations', []); disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -4451,14 +4451,14 @@ suite('AgentHostChatContribution', () => { // Update customizations customizations.set([ - { uri: 'file:///plugin-b', displayName: 'Plugin B' }, + { type: CustomizationType.Plugin, id: 'file:///plugin-b', uri: 'file:///plugin-b', name: 'Plugin B', enabled: true }, ], undefined); const activeClientAction = agentHostService.dispatchedActions.find( d => d.action.type === 'session/activeClientChanged' ); assert.ok(activeClientAction, 'should re-dispatch activeClientChanged on change'); - const ac = activeClientAction!.action as { activeClient: { customizations?: CustomizationRef[] } }; + const ac = activeClientAction!.action as { activeClient: { customizations?: ClientPluginCustomization[] } }; assert.strictEqual(ac.activeClient.customizations?.length, 1); assert.strictEqual(ac.activeClient.customizations?.[0].uri, 'file:///plugin-b'); }); @@ -4543,8 +4543,8 @@ suite('AgentHostChatContribution', () => { test('dispatches activeClientChanged when restoring a session where current client customizations are stale', async () => { const { instantiationService, agentHostService, seedActiveClient } = createTestServices(disposables); - const customizations = observableValue('customizations', [ - { uri: 'file:///plugin-new', displayName: 'Plugin New' }, + const customizations = observableValue('customizations', [ + { type: CustomizationType.Plugin, id: 'file:///plugin-new', uri: 'file:///plugin-new', name: 'Plugin New', enabled: true }, ]); disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionResource = AgentSession.uri('copilot', 'existing-session'); @@ -4562,7 +4562,7 @@ suite('AgentHostChatContribution', () => { activeClient: { clientId: agentHostService.clientId, tools: [], - customizations: [{ uri: 'file:///plugin-old', displayName: 'Plugin Old' }], + customizations: [{ type: CustomizationType.Plugin, id: 'file:///plugin-old', uri: 'file:///plugin-old', name: 'Plugin Old', enabled: true }], }, }); @@ -4584,7 +4584,7 @@ suite('AgentHostChatContribution', () => { const activeClientAction = activeClientActions[0].action; assert.strictEqual(activeClientAction.type, 'session/activeClientChanged'); assert.deepStrictEqual(activeClientAction.activeClient?.customizations, [ - { uri: 'file:///plugin-new', displayName: 'Plugin New' }, + { type: CustomizationType.Plugin, id: 'file:///plugin-new', uri: 'file:///plugin-new', name: 'Plugin New', enabled: true }, ]); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index 7040cc05e8640..1960908da9aee 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -50,13 +50,13 @@ type LocalSyncableFile = { readonly uri: URI; readonly type: PromptsType }; class FakeBundler { readonly received: LocalSyncableFile[][] = []; - constructor(private readonly _result: { uri: string; displayName: string } | undefined = { uri: 'open-plugin://bundle', displayName: 'Open Plugin' }) { } + constructor(private readonly _result: { uri: string; name: string } | undefined = { uri: 'open-plugin://bundle', name: 'Open Plugin' }) { } async bundle(files: readonly LocalSyncableFile[]) { this.received.push([...files]); if (!this._result) { return undefined; } - return { ref: { uri: this._result.uri as never, displayName: this._result.displayName }, paths: [] }; + return { ref: { type: 'plugin' as const, id: this._result.uri, uri: this._result.uri as never, name: this._result.name, enabled: true }, paths: [] }; } } @@ -84,7 +84,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { { uri: builtin.toString(), type: PromptsType.skill }, ]); assert.strictEqual(refs.length, 1); - assert.strictEqual(refs[0].displayName, 'Open Plugin'); + assert.strictEqual(refs[0].name, 'Open Plugin'); }); test('omits disabled built-in skills from the bundle', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts index 41dd9b526bcb8..e98532d6b2d5a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts @@ -75,7 +75,7 @@ suite('SyncedCustomizationBundler', () => { const result = await bundler.bundle([{ uri, type: PromptsType.instructions }]); assert.ok(result, 'should return a result'); assert.ok(result.ref.uri, 'should have a URI'); - assert.strictEqual(result.ref.displayName, 'VS Code Synced Data'); + assert.strictEqual(result.ref.name, 'VS Code Synced Data'); assert.ok(result.ref.nonce, 'should have a nonce'); // Verify the file was written to the in-memory FS @@ -310,6 +310,6 @@ suite('SyncedCustomizationBundler', () => { { uri: uriC, type: PromptsType.prompt }, ]); assert.ok(result); - assert.ok(result.ref.description?.includes('3'), 'description should mention file count'); + assert.ok(result.ref.nonce, 'should produce a nonce reflecting the bundled files'); }); }); From 8bc3060452429ec5b397c707d9084ebe9387d97d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 27 May 2026 15:40:01 -0700 Subject: [PATCH 07/18] agentPlugins: single source of truth for child customizations Decompose parsed plugins into their full set of child customizations (agents, skills, prompts/commands, rules, hooks, MCP servers) from a single point of truth in the parser, instead of synthesizing only agents ad-hoc inside the copilot agent. - Adds protocol-level projection types to the parsed primitives: each parsed agent/skill/rule now exposes a typed customization field, and IParsedHookGroup/IMcpServerDefinition carry their HookCustomization/McpServerCustomization. parsePlugin() mints these at parse time using customizationId-based ids. - Introduces toChildCustomizations(plugins) in copilotPluginConverters that walks all parsed primitives and emits a deduped ChildCustomization[]. The copilot agent's host/synced/session-discovered resolvers now populate Customization.children via this single helper instead of calling toAgentCustomizations(plugin.agents) ad-hoc. - Address Copilot review feedback: customizationId now percent-encodes existing '#' and uses a reserved '#range=' suffix so ranged ids cannot collide with URI fragments. - mockAgent.setCustomizationEnabled now records the opaque id so tests can catch id !== uri regressions. - Updates pluginParsers / copilotPluginConverters / convertBareEnvVarsToVsCodeSyntax tests to satisfy the new customization-bearing definition shapes; adds a thin test alias for convertBareEnvVarsToVsCodeSyntax that fills in the irrelevant customization stub. (Commit message generated by Copilot) --- .../agentHost/common/state/sessionState.ts | 7 +- .../agentHost/node/copilot/copilotAgent.ts | 8 +- .../node/copilot/copilotPluginConverters.ts | 47 +++--- .../test/node/agentSideEffects.test.ts | 2 +- .../test/node/copilotPluginConverters.test.ts | 28 +++- .../platform/agentHost/test/node/mockAgent.ts | 6 +- .../agentPlugins/common/pluginParsers.ts | 136 +++++++++++++++++- .../test/common/pluginParsers.test.ts | 8 ++ .../convertBareEnvVarsToVsCodeSyntax.test.ts | 17 ++- 9 files changed, 220 insertions(+), 39 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0cba6a9ff4fa4..cf1d2a7e4a2a8 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -182,12 +182,17 @@ export function isAhpRootChannel(uri: string): boolean { * declare multiple children (e.g. MCP servers, hooks) inside the same * manifest file; including the range disambiguates them without an extra * mapping table. + * + * The range is appended as a reserved `#range=` query-style suffix; any + * existing `#` in the URI is percent-encoded first so a source URI that + * already contains a fragment cannot collide with a ranged id. */ export function customizationId(uri: string, range?: TextRange): string { if (!range) { return uri; } - return `${uri}#${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`; + const safeUri = uri.replace(/#/g, '%23'); + return `${safeUri}#range=${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`; } // ---- VS Code-specific derived types ----------------------------------------- diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 9e0b803542a3a..a8f41ea317999 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -41,7 +41,7 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toAgentCustomizations, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { parsedPluginsEqual, toChildCustomizations, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools } from './copilotShellTools.js'; import { SessionCustomizationDiscovery } from './sessionCustomizationDiscovery.js'; @@ -1904,7 +1904,7 @@ class SessionDiscoveredEntry extends Disposable { ? { ...bundleResult.ref, load: { kind: CustomizationLoadStatus.Loaded }, - children: toAgentCustomizations(plugin.agents), + children: toChildCustomizations([plugin]), } : { ...bundleResult.ref, @@ -2179,7 +2179,7 @@ class PluginController extends Disposable { customization: { ...customization, load: { kind: CustomizationLoadStatus.Loaded }, - children: toAgentCustomizations(parsed.agents), + children: toChildCustomizations([parsed]), }, pluginDir, plugin: parsed, @@ -2205,7 +2205,7 @@ class PluginController extends Disposable { return { customization: { ...baseCustomization, - children: toAgentCustomizations(parsed.agents), + children: toChildCustomizations([parsed]), }, pluginDir: item.pluginDir, plugin: parsed, diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts index 98997a257d9fd..af849e86c9ffb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -9,9 +9,8 @@ import { OperatingSystem, OS } from '../../../../base/common/platform.js'; import { parseFrontMatter } from '../../../../base/common/yaml.js'; import { IFileService } from '../../../files/common/files.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; -import { CustomizationType, type AgentCustomization } from '../../common/state/protocol/state.js'; -import { customizationId } from '../../common/state/sessionState.js'; +import type { IMcpServerDefinition, INamedPluginResource, IParsedAgent, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { type AgentCustomization, type ChildCustomization } from '../../common/state/protocol/state.js'; import { dirname } from '../../../../base/common/path.js'; type SessionHooks = NonNullable; @@ -107,21 +106,35 @@ export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], } /** - * Projects parsed plugin agents into the protocol's {@link AgentCustomization} - * shape so they can be advertised as children of the owning container - * customization. + * Projects parsed plugin agents into their protocol-level + * {@link AgentCustomization} shape. */ -export function toAgentCustomizations(agents: readonly INamedPluginResource[]): AgentCustomization[] { - return agents.map(a => { - const uri = a.uri.toString(); - return { - type: CustomizationType.Agent, - id: customizationId(uri), - uri, - name: a.name, - ...(a.description ? { description: a.description } : {}), - }; - }); +export function toAgentCustomizations(agents: readonly IParsedAgent[]): AgentCustomization[] { + return agents.map(a => a.customization); +} + +/** + * Collects every child customization (agent, skill, rule, hook, MCP + * server) produced by a parsed plugin, deduped by id. This is the single + * source of truth for populating a container customization's `children` + * array — every projector that produced an SDK config above derives its + * matching protocol child from the same parsed primitive. + */ +export function toChildCustomizations(plugins: readonly IParsedPlugin[]): ChildCustomization[] { + const byId = new Map(); + const add = (c: ChildCustomization) => { + if (!byId.has(c.id)) { + byId.set(c.id, c); + } + }; + for (const plugin of plugins) { + for (const a of plugin.agents) { add(a.customization); } + for (const s of plugin.skills) { add(s.customization); } + for (const r of plugin.instructions) { add(r.customization); } + for (const h of plugin.hooks) { add(h.customization); } + for (const m of plugin.mcpServers) { add(m.customization); } + } + return [...byId.values()]; } // --------------------------------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 751f35033c735..60826d45b052b 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -1129,7 +1129,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(sessionUri.toString(), action); assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [ - { uri: 'file:///plugin-a', enabled: false }, + { id: 'file:///plugin-a', enabled: false }, ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts index 8f2eb15960d75..20f8105753f7e 100644 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts @@ -16,7 +16,18 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin, IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; + +function stubMcpCustomization(name = 'test'): McpServerCustomization { + return { type: CustomizationType.McpServer, id: `mcp:${name}`, uri: 'file:///plugin', name }; +} +function stubHookCustomization(type: string): HookCustomization { + return { type: CustomizationType.Hook, id: `hook:${type}`, uri: 'file:///plugin/hooks.json', name: 'hooks.json' }; +} +function stubSkillCustomization(name: string): SkillCustomization { + return { type: CustomizationType.Skill, id: `skill:${name}`, uri: `file:///${name}/SKILL.md`, name }; +} suite('copilotPluginConverters', () => { @@ -46,6 +57,7 @@ suite('copilotPluginConverters', () => { env: { NODE_ENV: 'production', PORT: 3000 as unknown as string }, cwd: '/workspace', }, + customization: stubMcpCustomization('test-server'), }]; const result = toSdkMcpServers(defs); @@ -70,6 +82,7 @@ suite('copilotPluginConverters', () => { url: 'https://example.com/mcp', headers: { 'Authorization': 'Bearer token' }, }, + customization: stubMcpCustomization('remote-server'), }]; const result = toSdkMcpServers(defs); @@ -96,6 +109,7 @@ suite('copilotPluginConverters', () => { type: McpServerType.LOCAL, command: 'echo', }, + customization: stubMcpCustomization('minimal'), }]; const result = toSdkMcpServers(defs); @@ -114,6 +128,7 @@ suite('copilotPluginConverters', () => { command: 'test', env: { KEEP: 'value', DROP: null as unknown as string }, }, + customization: stubMcpCustomization('with-null-env'), }]; const result = toSdkMcpServers(defs); @@ -279,6 +294,7 @@ suite('copilotPluginConverters', () => { commands: [{ command }], uri: URI.file('/plugin/hooks.json'), originalId: type, + customization: stubHookCustomization(type), }; } @@ -395,27 +411,29 @@ suite('copilotPluginConverters', () => { test('returns true for same content', () => { const a = makePlugin({ - skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], + skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], mcpServers: [{ name: 'server', uri: URI.file('/mcp'), configuration: { type: McpServerType.LOCAL, command: 'node' }, + customization: stubMcpCustomization('server'), }], }); const b = makePlugin({ - skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], + skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill], mcpServers: [{ name: 'server', uri: URI.file('/mcp'), configuration: { type: McpServerType.LOCAL, command: 'node' }, + customization: stubMcpCustomization('server'), }], }); assert.strictEqual(parsedPluginsEqual([a], [b]), true); }); test('returns false for different content', () => { - const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] }); - const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] }); + const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a', customization: stubSkillCustomization('a') } satisfies IParsedSkill] }); + const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b', customization: stubSkillCustomization('b') } satisfies IParsedSkill] }); assert.strictEqual(parsedPluginsEqual([a], [b]), false); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index a4d95afd47f25..4ff0a010b7a70 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -56,7 +56,7 @@ export class MockAgent implements IAgent { readonly changeModelCalls: { session: URI; model: ModelSelection }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = []; - readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; + readonly setCustomizationEnabledCalls: { id: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ customizations: Customization[] = []; private readonly _onDidCustomizationsChange = new Emitter(); @@ -188,8 +188,8 @@ export class MockAgent implements IAgent { return results; } - setCustomizationEnabled(uri: string, enabled: boolean): void { - this.setCustomizationEnabledCalls.push({ uri, enabled }); + setCustomizationEnabled(id: string, enabled: boolean): void { + this.setCustomizationEnabledCalls.push({ id, enabled }); } setClientTools(): void { } diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index b084826fd0114..1d7c43ceff922 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -14,6 +14,8 @@ import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { parseFrontMatter } from '../../../base/common/yaml.js'; import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js'; +import { CustomizationType, type AgentCustomization, type HookCustomization, type McpServerCustomization, type RuleCustomization, type SkillCustomization } from '../../agentHost/common/state/protocol/state.js'; +import { customizationId } from '../../agentHost/common/state/sessionState.js'; // --------------------------------------------------------------------------- // Types @@ -49,12 +51,20 @@ export interface IParsedHookGroup { readonly uri: URI; /** Original key as it appears in the hook file. */ readonly originalId: string; + /** + * Protocol-level projection of this hook group as a child customization. + * Multiple groups parsed from the same file share the same `customization.id` + * so consumers can dedupe by id when collecting customizations. + */ + readonly customization: HookCustomization; } export interface IMcpServerDefinition { readonly name: string; readonly configuration: IMcpServerConfiguration; readonly uri: URI; + /** Protocol-level projection of this MCP server as a child customization. */ + readonly customization: McpServerCustomization; } /** A named resource (skill, agent, command, or instruction) within a plugin. */ @@ -68,13 +78,28 @@ export interface INamedPluginResource { readonly description?: string; } +/** A parsed agent paired with its protocol-level child customization. */ +export interface IParsedAgent extends INamedPluginResource { + readonly customization: AgentCustomization; +} + +/** A parsed skill paired with its protocol-level child customization. */ +export interface IParsedSkill extends INamedPluginResource { + readonly customization: SkillCustomization; +} + +/** A parsed rule (instruction) paired with its protocol-level child customization. */ +export interface IParsedRule extends INamedPluginResource { + readonly customization: RuleCustomization; +} + /** The result of parsing a single plugin directory. */ export interface IParsedPlugin { readonly hooks: readonly IParsedHookGroup[]; readonly mcpServers: readonly IMcpServerDefinition[]; - readonly skills: readonly INamedPluginResource[]; - readonly agents: readonly INamedPluginResource[]; - readonly instructions: readonly INamedPluginResource[]; + readonly skills: readonly IParsedSkill[]; + readonly agents: readonly IParsedAgent[]; + readonly instructions: readonly IParsedRule[]; } // --------------------------------------------------------------------------- @@ -143,6 +168,79 @@ export async function detectPluginFormat(pluginUri: URI, fileService: IFileServi return COPILOT_FORMAT; } +// --------------------------------------------------------------------------- +// Child customization helpers +// --------------------------------------------------------------------------- + +/** + * Mints a child-customization id from a source uri plus an optional opaque + * disambiguator. Used when multiple customizations are declared inline in + * a single file (e.g. two MCP servers in one `.mcp.json`, or two hook + * lifecycle groups in one hook file). + * + * Percent-encodes any pre-existing `#` in the URI before appending the + * disambiguating fragment so the resulting id can never collide with a + * URI that happens to already contain a matching fragment. + */ +function buildChildId(uri: URI, disambiguator?: string): string { + const base = customizationId(uri.toString()); + if (!disambiguator) { + return base; + } + return `${base.replace(/#/g, '%23')}#${disambiguator}`; +} + +function makeAgentCustomization(resource: INamedPluginResource): AgentCustomization { + const uri = resource.uri.toString(); + return { + type: CustomizationType.Agent, + id: buildChildId(resource.uri), + uri, + name: resource.name, + ...(resource.description ? { description: resource.description } : {}), + }; +} + +function makeSkillCustomization(resource: INamedPluginResource): SkillCustomization { + const uri = resource.uri.toString(); + return { + type: CustomizationType.Skill, + id: buildChildId(resource.uri), + uri, + name: resource.name, + ...(resource.description ? { description: resource.description } : {}), + }; +} + +function makeRuleCustomization(resource: INamedPluginResource): RuleCustomization { + const uri = resource.uri.toString(); + return { + type: CustomizationType.Rule, + id: buildChildId(resource.uri), + uri, + name: resource.name, + ...(resource.description ? { description: resource.description } : {}), + }; +} + +function makeHookCustomization(hookUri: URI): HookCustomization { + return { + type: CustomizationType.Hook, + id: buildChildId(hookUri), + uri: hookUri.toString(), + name: basename(hookUri), + }; +} + +function makeMcpServerCustomization(definitionUri: URI, name: string): McpServerCustomization { + return { + type: CustomizationType.McpServer, + id: buildChildId(definitionUri, `mcp=${encodeURIComponent(name)}`), + uri: definitionUri.toString(), + name, + }; +} + // --------------------------------------------------------------------------- // Component path config // --------------------------------------------------------------------------- @@ -359,7 +457,7 @@ export function interpolateMcpPluginRoot( interpolated = remote; } - return { name: def.name, configuration: interpolated, uri: def.uri }; + return { name: def.name, configuration: interpolated, uri: def.uri, customization: def.customization }; } /** @@ -543,6 +641,7 @@ function parseHooksJson( const hooksObj = hooks as Record; const result: IParsedHookGroup[] = []; + const customization = makeHookCustomization(hookUri); for (const originalId of Object.keys(hooksObj)) { const canonicalType = HOOK_TYPE_MAP[originalId]; @@ -561,7 +660,7 @@ function parseHooksJson( } if (commands.length > 0) { - result.push({ type: canonicalType, commands, uri: hookUri, originalId }); + result.push({ type: canonicalType, commands, uri: hookUri, originalId, customization }); } } @@ -902,7 +1001,12 @@ export function parseMcpServerDefinitionMap( continue; } - let def: IMcpServerDefinition = { name, configuration, uri: definitionURI }; + let def: IMcpServerDefinition = { + name, + configuration, + uri: definitionURI, + customization: makeMcpServerCustomization(definitionURI, name), + }; if (formatConfig.pluginRootToken && formatConfig.pluginRootEnvVar) { def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootToken, formatConfig.pluginRootEnvVar); } @@ -974,6 +1078,24 @@ export async function parsePlugin( readInstructionComponents(instructionDirs, fileService), ]); - return { hooks, mcpServers, skills, agents, instructions }; + return { + hooks, + mcpServers, + skills: skills.map(toParsedSkill), + agents: agents.map(toParsedAgent), + instructions: instructions.map(toParsedRule), + }; +} + +function toParsedAgent(resource: INamedPluginResource): IParsedAgent { + return { ...resource, customization: makeAgentCustomization(resource) }; +} + +function toParsedSkill(resource: INamedPluginResource): IParsedSkill { + return { ...resource, customization: makeSkillCustomization(resource) }; +} + +function toParsedRule(resource: INamedPluginResource): IParsedRule { + return { ...resource, customization: makeRuleCustomization(resource) }; } diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index 9d9a69923ed63..f238130cb8ef6 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -7,6 +7,11 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +import { CustomizationType, type McpServerCustomization } from '../../../agentHost/common/state/protocol/state.js'; + +function stubMcpCustomization(): McpServerCustomization { + return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///plugin', name: 'test' }; +} import { parseComponentPathConfig, resolveComponentDirs, @@ -242,6 +247,7 @@ suite('pluginParsers', () => { command: '${MY_TOOL}', args: ['--key=${API_KEY}'], }, + customization: stubMcpCustomization(), }; const result = convertBareEnvVarsToVsCodeSyntax(def); assert.strictEqual((result.configuration as { command: string }).command, '${env:MY_TOOL}'); @@ -256,6 +262,7 @@ suite('pluginParsers', () => { type: McpServerType.LOCAL as const, command: '${env:ALREADY_QUALIFIED}', }, + customization: stubMcpCustomization(), }; const result = convertBareEnvVarsToVsCodeSyntax(def); assert.strictEqual((result.configuration as { command: string }).command, '${env:ALREADY_QUALIFIED}'); @@ -269,6 +276,7 @@ suite('pluginParsers', () => { type: McpServerType.LOCAL as const, command: '${lowercase}', }, + customization: stubMcpCustomization(), }; const result = convertBareEnvVarsToVsCodeSyntax(def); assert.strictEqual((result.configuration as { command: string }).command, '${lowercase}'); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts index 896e7b70b9345..36adcf7faba1e 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/convertBareEnvVarsToVsCodeSyntax.test.ts @@ -7,7 +7,22 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IMcpRemoteServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { convertBareEnvVarsToVsCodeSyntax } from '../../../common/plugins/agentPluginServiceImpl.js'; +import { convertBareEnvVarsToVsCodeSyntax as convertBareEnvVarsToVsCodeSyntaxRaw } from '../../../common/plugins/agentPluginServiceImpl.js'; +import { CustomizationType, type McpServerCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IMcpServerDefinition } from '../../../../../../platform/agentPlugins/common/pluginParsers.js'; + +function stubMcpCustomization(): McpServerCustomization { + return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///test', name: 'test' }; +} + +/** + * Wraps the production {@link convertBareEnvVarsToVsCodeSyntaxRaw} so tests + * don't have to spell out the protocol-level `customization` projection on + * every fixture — the env-var conversion never touches it. + */ +function convertBareEnvVarsToVsCodeSyntax(def: Omit) { + return convertBareEnvVarsToVsCodeSyntaxRaw({ ...def, customization: stubMcpCustomization() }); +} suite('convertBareEnvVarsToVsCodeSyntax', () => { ensureNoDisposablesAreLeakedInTestSuite(); From 36c57182de86b7e9c48b87c54738ae5cc42be281 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Wed, 27 May 2026 15:43:00 -0700 Subject: [PATCH 08/18] =?UTF-8?q?Claude=20agent=20=E2=80=94=20Phase=2011:?= =?UTF-8?q?=20customizations=20/=20plugins=20(#318113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Claude agent — Phase 11: customizations/plugins Workbench-pushed customizations (setClientCustomizations / setCustomizationEnabled) flow through IAgentPluginManager into Options.plugins for the Claude SDK Query. Server-side (SDK-discovered) commands / agents / MCP servers are projected as a single "Discovered in Claude" Open Plugins-conformant on-disk bundle. Notable design notes: - The SDK's Query.reloadPlugins() is parameterless and cannot change the plugin URI set after startup, so any client-side customization change triggers a yield-restart through the same rematerializer path used for client-tool changes. send()'s pre-flight runs a single rebind when either toolDiff or clientCustomizationsDiff is dirty. - SessionClientCustomizationsDiff drives dirty from the model state observable (not just enabledPluginPaths), so nonce bumps and metadata refreshes at the same URI are detected. - setClientCustomizations runs inside the per-session sequencer so a fire-and-forget call from AgentSideEffects cannot race a first sendMessage. - ClaudeSdkCustomizationBundler writes a hashed, content-addressed on-disk tree under the plugin manager's basePath. Repeated calls with the same SDK snapshot are nonce-stable and skip the rewrite. The on-disk tree is intentionally a cross-session warm cache. Tests: - New customizations/ test folder mirrors the source structure: SessionClientCustomizationsDiff (URI list, nonce, metadata, enablement, dirty semantics), projector (client+server merge), bundler (write layout, nonce stability, name sanitisation, namespacing, delete-on-change). - claudeAgent.test.ts: sync-and-toggle dispatch, sequencer serialisation, rebind on customizations dirty, mid-turn race coverage, swallowed-SDK-snapshot fallback in getSessionCustomizations. * fix: address customizations lint and review feedback Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> * test: fix customizations enablement key mismatch Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> * Fix Windows path assertions in Phase 11 tests URI.file('/p/a').fsPath is '\p\a' on Windows, so the literal POSIX string comparisons fail there. Compute expected via URI.file().fsPath so the same path round-trip drives both sides of the assertion. * Phase 11 docs: reflect shipped rebind-always architecture The original plan described setCustomizationEnabled as defer-and-coalesce via Query.reloadPlugins() with a tool-set-divergence escalation to rebind. Council review during PR #318113 verified Query.reloadPlugins() is parameterless in @anthropic-ai/claude-agent-sdk and cannot change the plugin URI set captured into Options.plugins at startup, so any client- pushed customization change ships as a yield-restart through the same rematerializer path that client-tool changes use. Rewrites the Phase 11 sections of roadmap.md and phase11-plan.md so the docs match what was merged. Historical "original plan called for X" notes preserved for context. Phase 11 marked DONE on the roadmap. * Claude phase 11: agent picker plumbing + on-disk URIs - Add IAgent.changeAgent for Claude: pre-materialize stash, post-materialize rebind via dirty bit (SDK has no working runtime control to swap agent in place — applyFlagSettings({ agent }) exists but doesn't actually swap). - Thread Options.agent through buildOptions / materialize / rematerializer and persist selection in the per-session metadata overlay so resume picks it up. - ClaudeSdkCustomizationBundler now publishes CustomizationAgentRef.uri as the on-disk `agents/.md` path (was a synthetic `claude-sdk-agent:/` scheme). The workbench customization harness needs a real file URI to parse via promptsService.parseNew — without it the agents never reached the picker. - Hide 'general-purpose' (SDK default) from the picker via shared CLAUDE_SDK_DEFAULT_AGENT_NAME constant. - Tests: 3 changeAgent cases (provisional / mid-session rebind / clear-to-undefined), bundler agent-URI shape. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../agentHost/node/claude/claudeAgent.ts | 106 +++++- .../node/claude/claudeAgentSession.ts | 247 +++++++++++- .../agentHost/node/claude/claudeSdkOptions.ts | 21 ++ .../node/claude/claudeSdkPipeline.ts | 56 ++- .../node/claude/claudeSessionMetadataStore.ts | 34 +- .../claudeSdkCustomizationBundler.ts | 167 ++++++++ .../claudeSessionClientCustomizationsModel.ts | 254 +++++++++++++ .../claudeSessionCustomizationsProjector.ts | 40 ++ .../agentHost/node/claude/phase11-plan.md | 168 +++++++++ .../platform/agentHost/node/claude/roadmap.md | 175 +++++++-- .../test/node/claudeAgent.integrationTest.ts | 18 +- .../agentHost/test/node/claudeAgent.test.ts | 357 +++++++++++++++++- .../test/node/claudeSdkOptions.test.ts | 52 ++- .../test/node/claudeSdkPipeline.test.ts | 46 +++ .../claudeSdkCustomizationBundler.test.ts | 168 +++++++++ ...deSessionClientCustomizationsModel.test.ts | 146 +++++++ ...audeSessionCustomizationsProjector.test.ts | 72 ++++ 17 files changed, 2056 insertions(+), 71 deletions(-) create mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts create mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts create mode 100644 src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts create mode 100644 src/vs/platform/agentHost/node/claude/phase11-plan.md create mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts create mode 100644 src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 9399f1091e091..c708537a1b394 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -17,16 +17,17 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; -import { ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; +import { ActionType } from '../../common/state/sessionActions.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; -import { PolicyState, ProtectedResourceMetadata, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { CustomizationRef, isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type MessageAttachment, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { CustomizationRef, isSubagentSession, parseSubagentSessionUri, SessionInputResponseKind, type MessageAttachment, type PendingMessage, type SessionCustomization, type SessionInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; @@ -141,6 +142,9 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress = this._onDidSessionProgress.event; + private readonly _onDidCustomizationsChange = this._register(new Emitter()); + readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; + private readonly _models = observableValue(this, []); readonly models: IObservable = this._models; @@ -224,6 +228,7 @@ export class ClaudeAgent extends Disposable implements IAgent { @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, ) { super(); this._metadataStore = _instantiationService.createInstance(ClaudeSessionMetadataStore, this.id); @@ -356,6 +361,7 @@ export class ClaudeAgent extends Disposable implements IAgent { config.workingDirectory, project, config.model, + config.agent, config.config, new PendingRequestRegistry(), permissionMode, @@ -364,6 +370,7 @@ export class ClaudeAgent extends Disposable implements IAgent { ); const entry = new ClaudeSessionEntry(session); entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); this._sessions.set(sessionId, entry); return { @@ -468,6 +475,7 @@ export class ClaudeAgent extends Disposable implements IAgent { workingDirectory, project, overlay.model, + overlay.agent, undefined, new PendingRequestRegistry(), permissionMode, @@ -476,6 +484,7 @@ export class ClaudeAgent extends Disposable implements IAgent { ); const entry = new ClaudeSessionEntry(session); entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); + entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); this._sessions.set(sessionId, entry); const canUseTool: NonNullable = (toolName, input, options) => @@ -882,6 +891,26 @@ export class ClaudeAgent extends Disposable implements IAgent { }); } + /** + * Switch (or clear with `undefined`) the selected custom agent for an + * existing session. Mirrors {@link changeModel}: session owns its + * provisional/runtime branching and metadata write + * (see {@link ClaudeAgentSession.setAgent}). For external-only + * sessions (no in-memory record), the agent is persisted directly to + * the overlay so a later resume picks it up. + */ + async changeAgent(session: URI, agent: AgentSelection | undefined): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + const sess = this._findAnySession(sessionId); + if (sess) { + await sess.setAgent(agent); + } else { + await this._metadataStore.write(session, { agent: agent ?? null }); + } + }); + } + setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void { const sessionId = AgentSession.id(session); this._logService.info(`[Claude:${sessionId}] setClientTools clientId=${clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`); @@ -908,12 +937,75 @@ export class ClaudeAgent extends Disposable implements IAgent { entry?.session.completeClientToolCall(toolCallId, result); } - setClientCustomizations(_session: URI, _clientId: string, _customizations: CustomizationRef[]): Promise { - throw new Error('TODO: Phase 11'); + async setClientCustomizations(session: URI, clientId: string, customizations: CustomizationRef[]): Promise { + const sessionId = AgentSession.id(session); + const sess = this._findAnySession(sessionId); + if (!sess) { + this._logService.warn(`[Claude:${sessionId}] setClientCustomizations: session not found`); + return []; + } + // Run inside the session sequencer so that a fire-and-forget + // `setClientCustomizations` from `AgentSideEffects` cannot race + // ahead of a first `sendMessage`: if `sendMessage` is already + // queued, the sync runs first or queues behind it; either way + // the materialize call reads the most recently adopted plugin + // set, never an empty one mid-sync. + return this._sessionSequencer.queue(sessionId, async () => { + const synced = await this._pluginManager.syncCustomizations( + clientId, + customizations, + status => this._fireCustomizationUpdated(session, { customization: status }), + ); + sess.adoptClientCustomizations(synced); + return synced; + }); + } + + /** + * Project a per-item sync result onto a `SessionCustomizationUpdated` + * action and emit it on {@link onDidSessionProgress}. Lets the workbench + * flip each row to `Loaded` / `Error` as the underlying + * {@link IAgentPluginManager.syncCustomizations} resolves it. + */ + private _fireCustomizationUpdated(session: URI, item: ISyncedCustomization): void { + this._onDidSessionProgress.fire({ + kind: 'action', + session, + action: { + type: ActionType.SessionCustomizationUpdated, + customization: item.customization.customization, + enabled: item.customization.enabled, + ...(item.customization.status !== undefined ? { status: item.customization.status } : {}), + ...(item.customization.statusMessage !== undefined ? { statusMessage: item.customization.statusMessage } : {}), + ...(item.customization.agents !== undefined ? { agents: item.customization.agents } : {}), + }, + }); + } + + setCustomizationEnabled(uri: string, enabled: boolean): void { + for (const entry of this._sessions.values()) { + entry.session.setClientCustomizationEnabled(uri, enabled); + } + } + + getCustomizations(): readonly CustomizationRef[] { + // Provider-level customization catalogue — feeds `AgentInfo.customizations` + // on `RootAgentsChanged`. Should advertise host-configured plugin refs + // (the equivalent of Copilot's `agentHost.customizations` setting). + // Claude has no such surface today; returning `[]` is correct rather + // than aggregating client-pushed refs (those live on + // `activeClient.customizations` per session). + // + // TODO: when host-level customizations become a real concept for the + // agent host, lift `PluginController` out of `copilot/copilotAgent.ts` + // into a shared service so both providers consume the same configured + // host customization list rather than each maintaining their own. + return []; } - setCustomizationEnabled(_uri: string, _enabled: boolean): void { - throw new Error('TODO: Phase 11'); + async getSessionCustomizations(session: URI): Promise { + const sess = this._findAnySession(AgentSession.id(session)); + return sess ? await sess.getSessionCustomizations() : []; } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 33e2a8733074b..bba940328429c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -12,13 +12,14 @@ import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { ISyncedCustomization } from '../../common/agentPluginManager.js'; import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { ClaudeRuntimeEffortLevel, clampEffortForRuntime, resolveClaudeEffort } from '../../common/claudeModelConfig.js'; import { AgentSignal, IAgentSessionProjectInfo } from '../../common/agentService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { PendingMessage, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, ToolCallPendingConfirmationState, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { PendingMessage, SessionCustomization, SessionInputAnswer, SessionInputRequest, SessionInputResponseKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; @@ -26,9 +27,12 @@ import { ClaudeSessionMetadataStore } from './claudeSessionMetadataStore.js'; import { convertToolCallResult } from './clientTools/claudeClientToolResult.js'; import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; +import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js'; +import { projectSessionCustomizations } from './customizations/claudeSessionCustomizationsProjector.js'; +import { ClaudeSdkCustomizationBundler } from './customizations/claudeSdkCustomizationBundler.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle } from './claudeProxyService.js'; -import { ClaudeSdkPipeline, IRematerializer } from './claudeSdkPipeline.js'; +import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } from './claudeSdkPipeline.js'; import { SubagentRegistry } from './claudeSubagentRegistry.js'; import { ClaudePermissionKind } from './claudeToolDisplay.js'; @@ -68,9 +72,20 @@ function resolveCurrentPermissionMode( export class ClaudeAgentSession extends Disposable { private _pipeline: ClaudeSdkPipeline | undefined; + private _sdkBundler: ClaudeSdkCustomizationBundler | undefined; /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ private _provisionalModel: ModelSelection | undefined; + /** + * Pre-materialize custom-agent selection. Mutable; flows into + * `Options.agent` (resolved to the SDK agent name) on materialize + * and on every rematerializer call. Mid-session changes via + * {@link setAgent} flip {@link clientCustomizationsDiff} dirty so the + * next `send()` rebinds and the new agent reaches the SDK on the + * rebuilt `Query`. The SDK's `Options.agent` is captured at startup + * — there is no runtime control-plane equivalent. + */ + private _provisionalAgent: AgentSelection | undefined; /** Pre-materialize `IAgentCreateSessionConfig.config` bag. Read at materialize time. */ readonly provisionalConfig: Record | undefined; /** Resolved project metadata captured at create time (if any). */ @@ -89,6 +104,7 @@ export class ClaudeAgentSession extends Disposable { workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, + agent: AgentSelection | undefined, config: Record | undefined, pendingClientToolCalls: PendingRequestRegistry, permissionModeFallback: ClaudePermissionMode, @@ -102,6 +118,7 @@ export class ClaudeAgentSession extends Disposable { workingDirectory, project, model, + agent, config, new AbortController(), pendingClientToolCalls, @@ -145,6 +162,22 @@ export class ClaudeAgentSession extends Disposable { */ readonly toolDiff: SessionClientToolsDiff; + /** + * Phase 11 — per-session **client-pushed** synced customization + * snapshot + enablement map. Owns the workbench-supplied + * {@link ISyncedCustomization} list, the per-URI enablement bits, + * and the dirty flag drained at the next {@link send} pre-flight. + * Exists from `createProvisional` onward so client-side reads / + * toggles work uniformly before and after materialize. + * + * Server-side (SDK-discovered) customizations are NOT stored here + * — they're fetched on demand from the live `Query` in + * {@link getSessionCustomizations}. + * + * See {@link SessionClientCustomizationsDiff}. + */ + readonly clientCustomizationsDiff: SessionClientCustomizationsDiff = this._register(new SessionClientCustomizationsDiff()); + private readonly _onDidSessionProgress = this._register(new Emitter()); readonly onDidSessionProgress: Event = this._onDidSessionProgress.event; @@ -154,6 +187,7 @@ export class ClaudeAgentSession extends Disposable { readonly workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, + agent: AgentSelection | undefined, config: Record | undefined, abortController: AbortController, private readonly _pendingClientToolCalls: PendingRequestRegistry, @@ -169,9 +203,11 @@ export class ClaudeAgentSession extends Disposable { super(); this.project = project; this._provisionalModel = model; + this._provisionalAgent = agent; this.provisionalConfig = config; this.abortController = abortController; this.toolDiff = this._register(toolDiff); + this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire())); } /** @@ -208,6 +244,8 @@ export class ClaudeAgentSession extends Disposable { canUseTool: ctx.canUseTool, isResume: ctx.isResume, mcpServers, + plugins: this.clientCustomizationsDiff.consume(), + agent: this._resolveAgentName(this._provisionalAgent), }, ctx.proxyHandle, data => this._logService.error(`[Claude SDK stderr] ${data}`), @@ -243,6 +281,15 @@ export class ClaudeAgentSession extends Disposable { } this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(s))); this._pipeline = pipeline; + // On-disk Open Plugin bundle for SDK-discovered customizations. + // The bundle directory is content-addressed by the SDK snapshot + // hash and lives under the plugin manager's user-data tree; + // disposing the bundler does NOT delete the on-disk tree (kept + // as a warm cache across sessions on the same workingDirectory). + this._sdkBundler = this._register(this._instantiationService.createInstance( + ClaudeSdkCustomizationBundler, + this.workingDirectory, + )); // Seed the pipeline's bijective config cache so a rebuild re-applies // the user's last-chosen model / effort without losing the picker @@ -294,19 +341,28 @@ export class ClaudeAgentSession extends Disposable { canUseTool: ctx.canUseTool, isResume: true, mcpServers: rebuildMcp, + plugins: this.clientCustomizationsDiff.consume(), + agent: this._resolveAgentName(this._provisionalAgent), }, ctx.proxyHandle, data => this._logService.error(`[Claude SDK stderr] ${data}`), msg => this._logService.info(`[Claude] declining elicitation from MCP server (Phase 7 stub): ${msg}`), ); - this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild`); + this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild agent=${rebuildOptions.agent ?? '(none)'}`); const rebuildWarm = await this._sdkService.startup({ options: rebuildOptions }); return { warm: rebuildWarm, abortController: rebuildAbort }; } catch (err) { this.toolDiff.markDirty(); + this.clientCustomizationsDiff.markDirty(); throw err; } }); + + // Surface the SDK-resolved customization tier to the workbench. + // Pre-materialize, getSessionCustomizations returns only the + // client-pushed slice; firing here prompts the workbench to refetch + // and pick up the bundled `Discovered in Claude` entry. + this._onDidCustomizationsChange.fire(); } /** True once {@link materialize} has installed the SDK pipeline. */ @@ -342,10 +398,13 @@ export class ClaudeAgentSession extends Disposable { * Send a user prompt. Performs the per-turn pre-flight before * yielding to the pipeline: * - * - If {@link toolDiff} reports the workbench client-tool snapshot has - * diverged from what the live `Query` was started with, yield-restart - * so the SDK picks up the new `Options.mcpServers`. The rebind itself - * re-applies the live `permissionMode` via the rematerializer. + * - If {@link toolDiff} or {@link clientCustomizationsDiff} reports the + * live `Query` is out of sync with the workbench's view, yield-restart + * so the SDK picks up the new `Options.mcpServers` / `Options.plugins`. + * `Query.reloadPlugins()` cannot help here — the SDK's plugin URI set + * is captured at startup, so any add / remove / nonce-bump must go + * through a full rebuild. The rebind itself re-applies the live + * `permissionMode` via the rematerializer. * - Otherwise forward the live `permissionMode` to the bound `Query` so * a `SessionConfigChanged` action that arrived between turns wins. * The pipeline's bijective cache dedupes a no-op `setPermissionMode`, @@ -357,14 +416,32 @@ export class ClaudeAgentSession extends Disposable { */ async send(prompt: SDKUserMessage, turnId: string): Promise { const pipeline = this._requirePipeline(); - if (this.toolDiff.hasDifference) { - await this.rebindForClientTools(); + if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifference) { + await this._rebindForSyncedState(); } else { await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this.sessionUri, this._permissionModeFallback)); } return pipeline.send(prompt, turnId); } + /** + * Single yield-restart that covers both client-tool and + * customization divergence in one trip. Drains the parked + * client-tool MCP handlers (same as the original tool-only + * rebind), then triggers the pipeline rebind — the rematerializer + * reads `toolDiff` and `clientCustomizationsDiff.consume()` while + * building the new `Options`, so the bit on each diff clears in + * lockstep with the SDK actually receiving the new values. Fires + * `_onDidCustomizationsChange` afterwards so the workbench + * refetches `getSessionCustomizations` and picks up any newly + * resolved server-side entries from the rebuilt `Query`. + */ + private async _rebindForSyncedState(): Promise { + this._pendingClientToolCalls.rejectAll(new CancellationError()); + await this._requirePipeline().rebindForRestart(); + this._onDidCustomizationsChange.fire(); + } + /** * Cancel the in-flight SDK turn. Mirrors the production reference; * see {@link ClaudeSdkPipeline.abort}. Also denies any parked @@ -409,6 +486,61 @@ export class ClaudeAgentSession extends Disposable { await this._metadataStore.write(this.sessionUri, { model }); } + /** + * Pre-materialize custom-agent selection accessor. + */ + get provisionalAgent(): AgentSelection | undefined { return this._provisionalAgent; } + + /** + * Change (or clear with `undefined`) the selected custom agent for this + * session. The SDK captures `Options.agent` at startup with no + * working runtime control (`applyFlagSettings({ agent })` exists on + * the SDK surface but doesn't actually swap the live agent), so + * post-materialize calls flip {@link clientCustomizationsDiff} + * dirty and the next `send()` pre-flight rebinds with the new agent + * baked into the rebuilt `Query`. Persisted to the per-session + * metadata overlay so a resume picks up the choice. + */ + async setAgent(agent: AgentSelection | undefined): Promise { + if (this._provisionalAgent === agent) { + return; + } + this._provisionalAgent = agent; + if (this._pipeline) { + // Force a rebind on the next send(); the SDK has no working + // runtime hook to swap the agent in place. + this.clientCustomizationsDiff.markDirty(); + } + await this._metadataStore.write(this.sessionUri, { agent: agent ?? null }); + } + + /** + * Resolve an {@link AgentSelection} URI to the SDK agent name the + * SDK expects on `Options.agent`. Every custom agent the picker can + * surface for a Claude session comes from the SDK side + * ({@link ClaudeSdkCustomizationBundler} populates + * `SessionCustomization.agents` from `Query.supportedAgents()`), + * pointing at on-disk `.../agents/.md` files we wrote + * ourselves, so the name is the file basename. + * + * Returns `undefined` when no agent is selected (or the URI doesn't + * resolve to a known agent file) so the SDK falls back to its default + * (no `--agent` flag). + */ + private _resolveAgentName(agent: AgentSelection | undefined): string | undefined { + if (!agent) { + return undefined; + } + const uri = URI.parse(agent.uri); + const basename = uri.path.split('/').pop() ?? ''; + const name = basename.replace(/\.md$/i, ''); + if (!name) { + this._logService.warn(`[Claude:${this.sessionId}] _resolveAgentName: could not extract agent name from URI '${agent.uri}'`); + return undefined; + } + return name; + } + /** * Inject a steering message. Builds the `priority: 'now'` * {@link SDKUserMessage} and hands it to the pipeline; the pipeline @@ -536,16 +668,97 @@ export class ClaudeAgentSession extends Disposable { } /** - * Drive a yield-restart so the SDK picks up the new client-tool set on - * its next user request. Cancels any in-flight client-tool MCP handlers - * and resets the bridge state before swapping the {@link Query}; the - * agent's rematerializer rebuilds `Options.mcpServers` from - * {@link toolDiff} during the rebind and pins `applied` to the - * build-time snapshot via {@link SessionClientToolsDiff.build}. + * Drive a yield-restart so the SDK picks up the new client-tool set + * on its next user request. Public entry point for callers that need + * to force a tool-only rebind; internal pre-flight goes through + * {@link _rebindForSyncedState}. */ async rebindForClientTools(): Promise { - this._pendingClientToolCalls.rejectAll(new CancellationError()); - await this._requirePipeline().rebindForRestart(); + await this._rebindForSyncedState(); + } + + // #endregion + + // #region Phase 11 — customizations / plugins + + /** + * Merged fire-and-forget signal that this session's customization + * surface changed. Fires from three sources: + * + * 1. Client-side writes (`adoptClientCustomizations` / + * `setClientCustomizationEnabled`) — via the + * {@link SessionClientCustomizationsDiff} observable wired up in the + * constructor. + * 2. Materialize completes — surfaces the server-side + * (SDK-discovered) tier to the workbench for the first time. + * 3. The send() pre-flight rebind completes — the rebuilt SDK's + * resolved set may have changed. + * + * Drives a workbench refetch of {@link getSessionCustomizations}. + * Does NOT itself trigger any SDK action — the dirty bit on + * {@link SessionClientCustomizationsDiff} drives plugin rebinds, + * and only flips on client-side writes. + */ + private readonly _onDidCustomizationsChange = this._register(new Emitter()); + readonly onDidCustomizationsChange: Event = this._onDidCustomizationsChange.event; + + /** + * Adopt the result of a global {@link IAgentPluginManager.syncCustomizations} + * pass (**client-pushed** path). The agent owns the manager (it's + * a process-wide singleton with a shared on-disk cache) and pushes + * the resulting snapshot down here. Flips the client-side dirty bit + * so the next {@link send} pre-flight reloads SDK plugins. + */ + adoptClientCustomizations(synced: readonly ISyncedCustomization[]): void { + this.clientCustomizationsDiff.model.setSyncedCustomizations(synced); + } + + /** Toggle a **client-pushed** customization on/off for this session. */ + setClientCustomizationEnabled(uri: string, enabled: boolean): void { + this.clientCustomizationsDiff.model.setEnabled(uri, enabled); + } + + /** + * Snapshot of the **client-pushed** customizations on this session. + * Does NOT include server-side (SDK-discovered) entries — use + * {@link getSessionCustomizations} for the merged view. + */ + getClientCustomizations(): readonly ISyncedCustomization[] { + return this.clientCustomizationsDiff.model.state.get().synced; + } + + /** + * Project the union of (a) **client-pushed** customizations and + * (b) the **server-side** (SDK-discovered) view (commands / agents + * / MCP servers, including those the SDK discovered on its own + * from `~/.claude/**`) onto the protocol's + * {@link SessionCustomization} surface, with the per-URI enablement + * overlay applied to client-pushed entries. + * + * Pre-materialize sessions return only the client-pushed projection + * — the SDK side has no Query to query yet. A failure to read the + * SDK snapshot is warn-logged and the client-pushed projection is + * still returned, so a transient SDK hiccup doesn't blank the UI. + */ + async getSessionCustomizations(): Promise { + const { synced, enablement } = this.clientCustomizationsDiff.model.state.get(); + let bundled: SessionCustomization | undefined; + if (this._pipeline && this._sdkBundler) { + let sdk: ISdkResolvedCustomizations | undefined; + try { + sdk = await this._pipeline.snapshotResolvedCustomizations(); + } catch (err) { + this._logService.warn(`[Claude:${this.sessionId}] snapshotResolvedCustomizations failed`, err); + } + if (sdk) { + try { + bundled = await this._sdkBundler.bundle(sdk); + } catch (err) { + this._logService.warn(`[Claude:${this.sessionId}] SDK bundle failed`, err); + } + } + } + return projectSessionCustomizations(synced, enablement, bundled); } // #endregion diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts index 0f8f93167ae96..efed231ceca03 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts @@ -32,6 +32,23 @@ export interface IBuildOptionsInput { readonly canUseTool: NonNullable; readonly isResume: boolean; readonly mcpServers: Record | undefined; + /** + * Local plugin directories to load at SDK startup. Projected onto + * `Options.plugins` as `{ type: 'local', path }`. Omitted from the + * returned options entirely when empty so the SDK keeps its default + * (no plugins). Built per-session from + * {@link SessionClientCustomizationsDiff.consume}. + */ + readonly plugins?: readonly URI[]; + /** + * Resolved SDK agent name (matches a key in `Options.agents`, or an + * agent loaded from `~/.claude/agents/**`). Projected onto + * `Options.agent` — the SDK's `--agent` flag. The plugin URI captured + * at startup is the only path the SDK consults, so any `changeAgent` + * after materialize triggers a yield-restart through the rematerializer. + * Omit when no custom agent is selected (SDK default behavior). + */ + readonly agent?: string; } /** @@ -88,6 +105,10 @@ export async function buildOptions( ? { resume: input.sessionId } : { sessionId: input.sessionId }), ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), + ...(input.plugins && input.plugins.length > 0 + ? { plugins: input.plugins.map(p => ({ type: 'local' as const, path: p.fsPath })) } + : {}), + ...(input.agent ? { agent: input.agent } : {}), settingSources: ['user', 'project', 'local'], settings: { env: settingsEnv }, systemPrompt: { type: 'preset', preset: 'claude_code' }, diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index be1b9d32109e5..5ab525211393e 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionMode, Query, SDKUserMessage, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentInfo, McpServerStatus, PermissionMode, Query, SDKUserMessage, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -60,7 +60,61 @@ export interface IRematerializer { * Disposing the pipeline aborts the controller (terminating the SDK * subprocess per `sdk.d.ts:982`) and async-disposes the WarmQuery. */ +/** + * Snapshot of everything the SDK has currently resolved for this + * session. Returned by {@link ClaudeSdkPipeline.snapshotResolvedCustomizations}. + */ +export interface ISdkResolvedCustomizations { + readonly commands: readonly SlashCommand[]; + readonly agents: readonly AgentInfo[]; + readonly mcpServers: readonly McpServerStatus[]; +} + export class ClaudeSdkPipeline extends Disposable { + /** + * Phase 11 — hot-swap the SDK's plugin set in place via + * `Query.reloadPlugins()`. Commands / agents / mcpServers added or + * removed by the new plugin set become visible to the SDK + * immediately, without a session restart. Throws if the query is + * not yet bound (session not materialized). + */ + async reloadPlugins(): Promise { + const query = await this._ensureQueryBound(); + await query.reloadPlugins(); + } + + /** + * Phase 11 — snapshot the SDK's currently-resolved customization + * surface (slash commands / skills, subagents, MCP servers). This + * is the SDK's view of "what does this session actually have + * access to right now" — covers everything the SDK loaded itself + * (`~/.claude/**`, `.claude/agents/`, `settings.json` MCP) AND + * anything we fed in via `Options.plugins`. The host overlays + * client-side enablement separately. + */ + async snapshotResolvedCustomizations(): Promise { + const query = await this._ensureQueryBound(); + const [commands, agents, mcpServers] = await Promise.all([ + query.supportedCommands(), + query.supportedAgents(), + query.mcpServerStatus(), + ]); + return { commands, agents, mcpServers }; + } + + /** + * Bind the SDK Query if the previous one has unwound (e.g. after a + * terminal result message). Mirrors the lazy bind in {@link send} + * so pre-flight helpers can call into the SDK without first having + * to issue a user prompt. + */ + private async _ensureQueryBound(): Promise { + if (!this._query) { + this._query = this._warm.query(this._queue.iterable); + await this._replayCurrentConfig(); + } + return this._query; + } private _query: Query | undefined; private _warm: WarmQuery; diff --git a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts index 39f35cde7896a..048bb0322cc0c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ClaudePermissionMode, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { AgentProvider, AgentSession, IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import type { ModelSelection } from '../../common/state/protocol/state.js'; +import type { AgentSelection, ModelSelection } from '../../common/state/protocol/state.js'; /** * Read view of Claude's per-session DB overlay. SDK-supplied fields @@ -19,16 +19,19 @@ export interface IClaudeSessionOverlay { readonly customizationDirectory?: URI; readonly model?: ModelSelection; readonly permissionMode?: ClaudePermissionMode; + readonly agent?: AgentSelection; } /** * Write view: any subset of the overlay fields. Fields left `undefined` - * are not touched (only-write-on-defined semantics). + * are not touched (only-write-on-defined semantics). Pass `null` for + * `agent` to clear a previously persisted selection. */ export interface IClaudeSessionOverlayUpdate { readonly customizationDirectory?: URI; readonly model?: ModelSelection; readonly permissionMode?: ClaudePermissionMode; + readonly agent?: AgentSelection | null; } /** @@ -54,6 +57,7 @@ export class ClaudeSessionMetadataStore { private static readonly KEY_CUSTOMIZATION_DIRECTORY = 'claude.customizationDirectory'; private static readonly KEY_MODEL = 'claude.model'; private static readonly KEY_PERMISSION_MODE = 'claude.permissionMode'; + private static readonly KEY_AGENT = 'claude.agent'; constructor( private readonly _provider: AgentProvider, @@ -80,6 +84,12 @@ export class ClaudeSessionMetadataStore { if (fields.permissionMode) { work.push(db.setMetadata(ClaudeSessionMetadataStore.KEY_PERMISSION_MODE, fields.permissionMode)); } + if (fields.agent !== undefined) { + work.push(db.setMetadata( + ClaudeSessionMetadataStore.KEY_AGENT, + fields.agent === null ? '' : JSON.stringify({ uri: fields.agent.uri }), + )); + } await Promise.all(work); } finally { dbRef.dispose(); @@ -99,15 +109,17 @@ export class ClaudeSessionMetadataStore { return {}; } try { - const [customizationDirectoryRaw, modelRaw, permissionModeRaw] = await Promise.all([ + const [customizationDirectoryRaw, modelRaw, permissionModeRaw, agentRaw] = await Promise.all([ ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_CUSTOMIZATION_DIRECTORY), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_MODEL), ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_PERMISSION_MODE), + ref.object.getMetadata(ClaudeSessionMetadataStore.KEY_AGENT), ]); return { customizationDirectory: customizationDirectoryRaw ? URI.parse(customizationDirectoryRaw) : undefined, model: parseModelSelection(modelRaw), permissionMode: narrowClaudePermissionMode(permissionModeRaw), + agent: parseAgentSelection(agentRaw), }; } finally { ref.dispose(); @@ -128,10 +140,26 @@ export class ClaudeSessionMetadataStore { workingDirectory: entry.cwd ? URI.file(entry.cwd) : undefined, customizationDirectory: overlay.customizationDirectory, model: overlay.model, + agent: overlay.agent, }; } } +function parseAgentSelection(raw: string | undefined): AgentSelection | undefined { + if (!raw) { + return undefined; + } + try { + const value: { uri?: unknown } = JSON.parse(raw); + if (value && typeof value === 'object' && typeof value.uri === 'string') { + return { uri: value.uri }; + } + } catch { + // fall through + } + return undefined; +} + function serializeModelSelection(model: ModelSelection): string { return JSON.stringify(model); } diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts new file mode 100644 index 0000000000000..b081e966c38e3 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSdkCustomizationBundler.ts @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { hash } from '../../../../../base/common/hash.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { IAgentPluginManager } from '../../../common/agentPluginManager.js'; +import { CustomizationStatus, type CustomizationAgentRef, type SessionCustomization } from '../../../common/state/protocol/state.js'; +import type { ISdkResolvedCustomizations } from '../claudeSdkPipeline.js'; + +const PLUGIN_NAME = 'claude-discovered'; +const DISPLAY_NAME = localize('claude.discovered.displayName', "Discovered in Claude"); +const DISCOVERED_DIR = 'claude-discovered'; + +/** + * The Claude SDK's built-in default agent. Hidden from the picker: + * selecting it would be equivalent to "no selection" since the SDK + * uses it as the fallback when `Options.agent` is omitted. + */ +export const CLAUDE_SDK_DEFAULT_AGENT_NAME = 'general-purpose'; + +/** + * Bundles the Claude SDK's currently-resolved customization view + * (commands + agents from `Query.supportedCommands()` / + * `supportedAgents()` / `mcpServerStatus()`) into a synthetic on-disk + * [Open Plugin](https://open-plugins.com/) layout, so the workbench's + * plugin expander can scan it and emit per-type child items + * (`PromptsType.agent` / `PromptsType.skill` / `PromptsType.prompt`). + * + * Returns a single {@link SessionCustomization} with `displayName = + * "Discovered in Claude"` whose URI points at the on-disk bundle root. + * The `agents` field is populated directly from the SDK snapshot so the + * agent picker can list Claude-native agents without waiting on + * filesystem expansion. + * + * The directory is namespaced by a hash of the working directory so + * concurrent sessions on different folders don't collide. Repeated + * {@link bundle} calls with the same SDK snapshot reuse the prior + * bundle (nonce match) and skip the rewrite. + */ +export class ClaudeSdkCustomizationBundler extends Disposable { + + private readonly _rootUri: URI; + private _lastNonce: string | undefined; + + constructor( + workingDirectory: URI, + @IFileService private readonly _fileService: IFileService, + @IAgentPluginManager pluginManager: IAgentPluginManager, + ) { + super(); + const authority = `claude-${hash(workingDirectory.toString())}`; + this._rootUri = URI.joinPath(pluginManager.basePath, DISCOVERED_DIR, authority); + } + + async bundle(snapshot: ISdkResolvedCustomizations): Promise { + if (snapshot.commands.length === 0 && snapshot.agents.length === 0) { + return undefined; + } + + const hashParts: string[] = []; + for (const agent of snapshot.agents) { + hashParts.push(`agent:${agent.name}\n${agent.description}\n${agent.model ?? ''}`); + } + for (const cmd of snapshot.commands) { + hashParts.push(`command:${cmd.name}\n${cmd.description}\n${cmd.argumentHint ?? ''}`); + } + hashParts.sort(); + const nonce = String(hash(hashParts.join('\n'))); + + if (this._lastNonce !== nonce) { + try { + await this._fileService.del(this._rootUri, { recursive: true }); + } catch { + // First bundle — directory may not exist. + } + // Vendor-neutral manifest path per Open Plugins spec + // (`.plugin/plugin.json`). `name` is the only required field + // and must be lowercase alphanumeric / `-` / `.` only. + const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); + await this._fileService.writeFile(manifestUri, VSBuffer.fromString(JSON.stringify({ + name: PLUGIN_NAME, + description: 'Customizations discovered by the Claude agent', + }, null, '\t'))); + + for (const agent of snapshot.agents) { + const fileUri = URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`); + await this._fileService.writeFile(fileUri, VSBuffer.fromString(agentMarkdown(agent.name, agent.description))); + } + for (const cmd of snapshot.commands) { + // Treat Claude slash commands as skills: each becomes its + // own `skills//SKILL.md` subdirectory per the Agent + // Skills format. Conceptually they're the same thing — + // a named, model-invocable capability — and the workbench + // buckets them under skills. + const dirName = safeName(cmd.name); + const fileUri = URI.joinPath(this._rootUri, 'skills', dirName, 'SKILL.md'); + await this._fileService.writeFile(fileUri, VSBuffer.fromString(skillMarkdown(dirName, cmd.description, cmd.argumentHint))); + } + this._lastNonce = nonce; + } + + // Hide the SDK's built-in default agent — see + // {@link CLAUDE_SDK_DEFAULT_AGENT_NAME} for the full rationale. + // `uri` is the on-disk path of the file we just wrote — the + // workbench's customization harness reads it via `parseNew` to + // hydrate `ICustomAgent`, so a synthetic identity scheme would + // fail to parse and the agents would never reach the picker. + const agentRefs: CustomizationAgentRef[] = snapshot.agents + .filter(agent => agent.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME) + .map(agent => ({ + uri: URI.joinPath(this._rootUri, 'agents', `${safeName(agent.name)}.md`).toString(), + name: agent.name, + description: agent.description, + })); + + return { + customization: { + uri: this._rootUri.toString(), + displayName: DISPLAY_NAME, + description: localize('claude.discovered.description', "{0} customization(s) discovered by the Claude agent", snapshot.agents.length + snapshot.commands.length), + nonce, + }, + enabled: true, + status: CustomizationStatus.Loaded, + agents: agentRefs, + }; + } +} + +function safeName(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'unnamed'; +} + +/** + * Open Plugins agent frontmatter: `name` (1-64 chars, kebab-case) and + * `description` (max 1024 chars). The body is the agent's system + * prompt; the SDK doesn't surface it, so we leave the body empty. + */ +function agentMarkdown(name: string, description: string): string { + return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n`; +} + +/** + * Agent Skills `SKILL.md` frontmatter: `name` (MUST match the + * containing directory name) and `description`. The SDK's + * `argumentHint` is rendered as a `$ARGUMENTS` usage hint in the body. + */ +function skillMarkdown(name: string, description: string, argumentHint: string | undefined): string { + const body = argumentHint ? `\nUsage: \`${argumentHint}\`\n` : ''; + return `---\nname: ${yamlString(name)}\ndescription: ${yamlString(truncate(description, 1024))}\n---\n${body}`; +} + +function yamlString(s: string): string { + // Quote always; escape backslashes and double quotes. Single-line: drop newlines. + const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, ' '); + return `"${escaped}"`; +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : `${s.slice(0, max - 1)}…`; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts new file mode 100644 index 0000000000000..8596df7afe7b3 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { equals as arraysEqual } from '../../../../../base/common/arrays.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { autorun, derivedOpts, IObservable, ISettableObservable, observableValueOpts } from '../../../../../base/common/observable.js'; +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; + +/** + * Per-session **client-pushed** customization snapshot + enablement + * map. "Client" here means the workbench client that called + * `setClientCustomizations` / `setCustomizationEnabled` — server-side + * (SDK-discovered) customizations live separately and are never + * stored in this model. The two fields travel as one value so + * consumers can read both with a single `.get()` and so that an + * update to either is observed as a single change. + */ +export interface ISessionCustomizationsState { + readonly synced: readonly ISyncedCustomization[]; + readonly enablement: ReadonlyMap; +} + +const INITIAL_STATE: ISessionCustomizationsState = { synced: [], enablement: new Map() }; + +/** + * Pure observable state holder for the **client-pushed** + * {@link ISyncedCustomization} list and the per-customization + * enablement map. Exposes a derived `enabledPluginPaths` view used + * to project `Options.plugins` at materialize / rematerialize. + * + * Server-side (SDK-discovered) customizations are NOT in scope here + * — they're fetched on demand from the live `Query` in + * `getSessionCustomizations` and never written into this + * model. + * + * `state` dedupes structurally-equivalent writes: a re-send of the + * same `(synced, enablement)` pair does NOT fire downstream + * subscribers. Knows nothing about diffing or the SDK — pair with + * {@link SessionClientCustomizationsDiff} to track "has the client-pushed + * snapshot changed since the last successful SDK plugin reload". + */ +export class SessionClientCustomizationsModel { + + private readonly _state: ISettableObservable = observableValueOpts( + { owner: this, equalsFn: stateEqual }, + INITIAL_STATE, + ); + readonly state: IObservable = this._state; + + /** + * Resolved local plugin paths for the currently enabled + * **client-pushed** customizations. Customizations without a + * `pluginDir` (still loading or failed sync) are excluded. + * Default enablement is `true` — an absent entry counts as + * enabled. Server-side customizations contribute nothing here. + */ + readonly enabledPluginPaths: IObservable = derivedOpts( + { owner: this, equalsFn: (a, b) => arraysEqual(a, b, (x, y) => x.toString() === y.toString()) }, + reader => { + const s = this._state.read(reader); + const paths: URI[] = []; + for (const synced of s.synced) { + if (!synced.pluginDir) { + continue; + } + const uri = synced.customization.customization.uri.toString(); + if (s.enablement.get(uri) === false) { + continue; + } + paths.push(synced.pluginDir); + } + return paths; + }, + ); + + /** Replace the client-pushed customization snapshot for this session. */ + setSyncedCustomizations(synced: readonly ISyncedCustomization[]): void { + const cur = this._state.get(); + this._state.set({ synced, enablement: cur.enablement }, undefined); + } + + /** Toggle a client-pushed customization on/off for this session. */ + setEnabled(uri: string, enabled: boolean): void { + const cur = this._state.get(); + const current = cur.enablement.get(uri); + if (current === enabled || (enabled && current === undefined)) { + return; + } + const next = new Map(cur.enablement); + if (enabled) { + next.delete(uri); + } else { + next.set(uri, false); + } + this._state.set({ synced: cur.synced, enablement: next }, undefined); + } +} + +/** + * Tracks "has the **client-pushed** customization snapshot changed + * since the SDK was last (re)started against it?". Subscribes to + * {@link SessionClientCustomizationsModel.state}, with the state + * observable's equalsFn structurally comparing the meaningful + * fields (URI list, enablement, nonce, status, user-visible + * metadata). Same race semantics as `SessionClientToolsDiff`: a + * write that lands during an in-flight rebind re-flips dirty via + * the autorun, so callers don't need to snapshot-compare. + * + * Why state and not just `enabledPluginPaths`: the SDK's + * `reloadPlugins()` is parameterless — the plugin URI set is + * captured into `Options.plugins` at startup and is otherwise + * immutable. Any meaningful change (new plugin, toggle, content + * refresh via nonce, metadata refresh) therefore requires the + * yield-restart path to take effect, so we treat every state + * change as SDK-relevant. + * + * Server-side (SDK-discovered) customizations are NOT tracked + * here — the SDK manages its own discovery lifecycle, and + * changes to server-side data flow to the workbench via separate + * event fires (post-materialize, post-rebind). + * + * On rebind throw the bit is left set — the SDK is still running + * with the previous plugin set, so the next sendMessage should + * retry. + */ +export class SessionClientCustomizationsDiff extends Disposable { + + readonly model: SessionClientCustomizationsModel = new SessionClientCustomizationsModel(); + + private _dirty = false; + // `autorun` invokes its callback once at registration for dependency + // tracking. Skip that initial run so a brand-new diff doesn't + // report dirty before any mutation has happened. + private _ignoreNextFire = true; + + /** + * Outward fire-and-forget signal that the underlying state + * changed. Derived from the observable so external listeners + * (e.g. agent-level event aggregation) don't have to subscribe to + * the observable directly. + */ + readonly onDidChange: Event = Event.fromObservableLight(this.model.state); + + constructor() { + super(); + this._register(autorun(reader => { + this.model.state.read(reader); + if (this._ignoreNextFire) { + this._ignoreNextFire = false; + return; + } + this._dirty = true; + })); + } + + get hasDifference(): boolean { + return this._dirty; + } + + /** + * Read the resolved enabled plugin paths and mark the current + * snapshot as applied. A subsequent write that changes any + * meaningful field re-flips dirty via the autorun. If the caller's + * downstream work (e.g. SDK rebind) fails, call {@link markDirty} + * to surface the stale state. + */ + consume(): readonly URI[] { + const paths = this.model.enabledPluginPaths.get(); + this._dirty = false; + return paths; + } + + /** + * Force the dirty bit on. Use when async work that followed + * {@link consume} failed and the SDK is therefore still on the + * previous plugin set. + */ + markDirty(): void { + this._dirty = true; + } +} + +function stateEqual(a: ISessionCustomizationsState, b: ISessionCustomizationsState): boolean { + return syncedListEqual(a.synced, b.synced) && enablementEqual(a.enablement, b.enablement); +} + +function syncedListEqual(a: readonly ISyncedCustomization[], b: readonly ISyncedCustomization[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + const ai = a[i].customization; + const bi = b[i].customization; + if (ai.customization.uri.toString() !== bi.customization.uri.toString()) { + return false; + } + if (ai.customization.nonce !== bi.customization.nonce) { + return false; + } + if (ai.customization.displayName !== bi.customization.displayName) { + return false; + } + if (ai.customization.description !== bi.customization.description) { + return false; + } + if (ai.enabled !== bi.enabled) { + return false; + } + if (ai.status !== bi.status) { + return false; + } + if (ai.statusMessage !== bi.statusMessage) { + return false; + } + if (!agentsEqual(ai.agents, bi.agents)) { + return false; + } + if (a[i].pluginDir?.toString() !== b[i].pluginDir?.toString()) { + return false; + } + } + return true; +} + +function agentsEqual(a: readonly { name: string }[] | undefined, b: readonly { name: string }[] | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b || a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].name !== b[i].name) { + return false; + } + } + return true; +} + +function enablementEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a.size !== b.size) { + return false; + } + for (const [k, v] of a) { + if (b.get(k) !== v) { + return false; + } + } + return true; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts new file mode 100644 index 0000000000000..ca2c3e65e52e7 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsProjector.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import type { SessionCustomization } from '../../../common/state/protocol/state.js'; + +/** + * Project the union of (a) client-pushed customizations and + * (b) the on-disk discovery bundle (server-provided) onto the + * protocol's {@link SessionCustomization} surface. + * + * Client-pushed entries get the per-URI enablement overlay applied + * (`enablement.get(uri) ?? customization.enabled`). The discovery + * bundle is surfaced verbatim — it is a single synthetic plugin URI + * pointing at an on-disk Open Plugin layout (`agents/`, `skills/`, + * `commands/`, `rules/`) the workbench's plugin expander scans to + * emit per-type child items. Per-file enablement happens + * workbench-side; we surface only the bundle URI. + */ +export function projectSessionCustomizations( + synced: readonly ISyncedCustomization[], + enablement: ReadonlyMap, + discovered: SessionCustomization | undefined, +): readonly SessionCustomization[] { + const result: SessionCustomization[] = []; + + for (const item of synced) { + const uri = item.customization.customization.uri.toString(); + const enabled = enablement.get(uri) ?? item.customization.enabled; + result.push({ ...item.customization, enabled }); + } + + if (discovered) { + result.push(discovered); + } + + return result; +} diff --git a/src/vs/platform/agentHost/node/claude/phase11-plan.md b/src/vs/platform/agentHost/node/claude/phase11-plan.md new file mode 100644 index 0000000000000..15828c38dcfe9 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/phase11-plan.md @@ -0,0 +1,168 @@ +# Phase 11 — Customizations / Plugins (full surface) + +> Generated by super-planner. Source: `roadmap.md` (phase 11). +> Last updated: 2026-05-21 after council-plan (GPT-5.5 + Claude Opus 4.6; +> Codex returned a network error — 2-agent council) and structural +> revision: per-session ownership mirroring `clientTools/`. No +> provider-wide controller. + +**Status:** done. Implemented and merged via PR #318113. **The original `reloadPlugins`-as-hot-swap design was abandoned during council review** — the SDK's `Query.reloadPlugins()` is parameterless and cannot change the plugin URI set after startup. Any client-pushed customization change therefore triggers a yield-restart through the same rematerializer path used for client-tool changes; `Query.reloadPlugins()` is no longer called from production. See [Decisions](#decisions) for the revised contract. + +## Goal + +Wire customizations (skills + plugins) end-to-end for the Claude provider so the workbench can sync, enable, and toggle customizations against a live Claude session with the same `IAgent` surface CopilotAgent already implements. The session must accept the synced plugin paths at startup and pick up any later add / remove / toggle / nonce-bump via a yield-restart before the next turn. Customization state lives on the session, mirroring how client tools are held. + +## Scope + +**In scope** + +- Replace the `TODO: Phase 11` throws in `ClaudeAgent.setClientCustomizations` and `ClaudeAgent.setCustomizationEnabled`. +- Add the outbound surface: `onDidCustomizationsChange`, `getCustomizations()`, `getSessionCustomizations(session)`. +- Add a `plugins` input on `IBuildOptionsInput` so `buildOptions()` projects it into `Options.plugins`. +- **All per-session customization state — synced set, enablement map, resolved plugin paths, dirty bit — owned by `ClaudeAgentSession`**, in a new `customizations/` folder parallel to `clientTools/`. +- Drain pending plugin changes at the session's `send()` pre-flight via the existing `rebindForRestart()` path. (Original plan called for `Query.reloadPlugins()`; see [Decisions](#decisions).) +- Server-side (SDK-discovered) customizations surfaced as a single "Discovered in Claude" Open Plugins-conformant on-disk bundle written by `ClaudeSdkCustomizationBundler`. +- Mid-turn-race semantics: a sync or toggle that lands during a live `sendMessage` is visible on the next yield boundary, never mutating the current turn. + +**Out of scope** + +- `IAgent` API shape changes — the protocol surface is fixed. +- A provider-wide `ClaudePluginController` class. Per the session-owned-state intent, each session is responsible for its own customizations, mirroring how `SessionClientToolsDiff` works for client tools. +- Workbench / customizations editor UI changes. +- SDK `initializationResult()` as a probe for `available_plugins` — useful diagnostic, not required for correctness; defer. +- Session-discovered (on-disk) customizations (Copilot's `SessionDiscoveredEntry` pattern). Future phase. +- Hot-swapping customizations mid-turn. SDK has no mid-turn `reloadPlugins` contract; the yield boundary is the only safe point. + +## Prerequisites + +- Phase 10 yield-restart primitive (`SessionClientToolsDiff` + `ClaudeAgentSession.rebindForClientTools()` + `ClaudeSdkPipeline.rebindForRestart()`) is the structural reference for both the per-session ownership pattern AND the restart fallback path. +- Phase 10.5 collapsed materialization into `ClaudeAgentSession.materialize(ctx)`; the session is the natural owner of per-session customization state and the place to drain pending reloads. +- `IAgentPluginManager` DI singleton exists at `src/vs/platform/agentHost/common/agentPluginManager.ts` and ships `syncCustomizations(clientId, customizations, progress?)`. Injected into the session via DI (not the agent), same way `IClaudeAgentSdkService` is injected into the session today. +- SDK `Query.reloadPlugins()`, `Query.supportedCommands()`, and `Options.plugins` are available on the pinned `@anthropic-ai/claude-agent-sdk` version. +- Workspace E2E skills available: `launch` (Playwright/CDP), `code-oss-logs`, `chat-customizations-editor`. + +## Approach + +Mirror the `clientTools/` pattern exactly. Add a sibling `customizations/` folder under `node/claude/` containing two collaborators — `SessionClientCustomizationsDiff` (parallel to `SessionClientToolsDiff`) and `ClaudeSdkCustomizationBundler` (server-side discovery projection) — plus the `projectSessionCustomizations` pure function that merges both tiers. `SessionClientCustomizationsDiff` lives on `ClaudeAgentSession` and owns: the session's synced `ISyncedCustomization[]` snapshot, the per-URI enablement map, the resolved enabled local plugin paths, and a `dirty` reload flag. `ClaudeAgent` becomes a thin dispatcher: `setClientCustomizations` looks up the session and calls `session.adoptClientCustomizations(synced)`; `setCustomizationEnabled` walks `_sessions` and calls `session.setClientCustomizationEnabled(uri, enabled)` on each. The session does the real work — update its own state, flip its dirty bit, and fire its `onDidCustomizationsChange` event. `claudeSdkOptions.buildOptions` gains a `plugins` field; the session passes its own resolved paths into materialize and the rematerializer via `customizationsDiff.consume()`. A client-pushed customization change flips the session's dirty bit; the next `send()` pre-flight runs `rebindForRestart()` (same path the tool diff uses) when either diff is dirty. The agent-level `_sessionSequencer` also wraps `setClientCustomizations` so a fire-and-forget call from `AgentSideEffects` cannot race a first `sendMessage`. + +## Steps + +1. **Add `SessionCustomizationsDiff` collaborator under a new `customizations/` folder.** Mirrors `clientTools/claudeSessionClientToolsModel.ts` shape. Owns: `syncedCustomizations: readonly ISyncedCustomization[]`, `enablement: Map` (per-session), `resolveEnabledPluginPaths(): readonly URI[]` (derived view used at materialize/reload), `consume(): readonly URI[]` (clears dirty + returns current paths), and `onDidChange: Event` fired on any state mutation. + - Files: `src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsModel.ts` (new) + - Depends on: none + - Done when: model compiles with no SDK dependencies; unit tests cover sync update, enablement toggle, dirty lifecycle, event firing. + +2. **Add `plugins` field to `IBuildOptionsInput` and project to `Options.plugins`.** Update materialize + rematerializer call sites in the session to pass `session.customizationsDiff.consume()` so plugins are baked into SDK options on both fresh startup and yield-restart. + - Files: `src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts`, `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: step 1 + - Done when: with non-empty enabled paths, `buildOptions` returns `Options.plugins` as `Record`; empty omits the field; both materialize and the rematerializer closure pass the current snapshot. + +3. **Add the SDK-resolved snapshot helper on the pipeline.** Narrow public method `snapshotResolvedCustomizations(): Promise<{commands, agents, mcpServers}>` that reads the live `Query`'s `supportedCommands` / `supportedAgents` / `mcpServerStatus` in parallel. Used by `getSessionCustomizations` to surface the server-side tier; not used to drive the dirty bit. (Original plan also called for a `reloadPluginsAndSnapshot` helper; cut after council review proved `reloadPlugins` couldn't change the plugin set.) + - Files: `src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts` + - Depends on: none + - Done when: a unit test against the fake `Query` verifies the snapshot returns the three SDK fields verbatim. + +4. **Wire customizations onto the session.** Inject `IAgentPluginManager` into `ClaudeAgentSession` via DI (same pattern as `IClaudeAgentSdkService` after Phase 10.5). Add public methods: + - `setClientCustomizations(clientId, customizations, progress?): Promise` — calls `pluginManager.syncCustomizations`, updates `customizationsDiff.syncedCustomizations`, returns the synced set. + - `setCustomizationEnabled(uri, enabled): void` — flips the per-session enablement bit; the diff recomputes enabled paths and flips dirty. + - `getCustomizations(): readonly ISyncedCustomization[]` — returns `customizationsDiff.syncedCustomizations`. + - `onDidCustomizationsChange: Event` — forwards `customizationsDiff.onDidChange`. + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: step 1 + - Done when: session compiles with the new DI dep; unit test exercises sync to enable to toggle round-trip directly on a session instance. + +5. **`ClaudeAgent` becomes a thin dispatcher.** Replace the four customization stubs with one-line delegations: + - `setClientCustomizations(session, clientId, customizations)` looks up the session via `_findAnySession(id)`, constructs a progress callback that forwards each item via `_onDidSessionProgress.fire(SessionCustomizationUpdated)`, and awaits `session.setClientCustomizations(clientId, customizations, progress)`. + - `setCustomizationEnabled(uri, enabled)` walks `_sessions.values()` and calls `entry.session.setCustomizationEnabled(uri, enabled)`. + - `getCustomizations()` aggregates the union of `session.getCustomizations()` across `_sessions.values()`, deduped by URI. + - `getSessionCustomizations(session)` returns `_findAnySession(id)?.getCustomizations() ?? []` (works for provisional sessions since the diff exists from `createProvisional` onward). + - `onDidCustomizationsChange` is an aggregated event fired when any session's `onDidCustomizationsChange` fires (subscribed via the existing `entry.addDisposable(...)` pattern that already wires per-session signals). + - Files: `src/vs/platform/agentHost/node/claude/claudeAgent.ts` + - Depends on: step 4 + - Done when: all four outbound and two inbound surfaces work end-to-end; the `Phase 11` throws are gone. + +6. **Drain pending plugin changes at `send()` pre-flight.** Inside `ClaudeAgentSession.send()`, AFTER the existing `toolDiff.hasDifference` check, collapse to `if (toolDiff.hasDifference || clientCustomizationsDiff.hasDifference) await rebindForRestart()`. The rematerializer reads `clientCustomizationsDiff.consume()` while building the new `Options`, so the new plugin URI set lands in `Options.plugins` of the rebuilt `Query`. (Original plan called for `reloadPlugins`-then-compare-tools-then-maybe-restart; abandoned because `Query.reloadPlugins()` is parameterless and cannot change the plugin URI set.) + - Files: `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` + - Depends on: steps 1, 3, 4 + - Done when: a client-pushed customization change triggers exactly one `rebindForRestart` and one new `sdk.startup` on the next `send()`; mid-turn writes don't drain into the in-flight turn. + +7. **Tests.** + - Files: `src/vs/platform/agentHost/test/node/customizations/` (new folder mirroring source layout), `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` (new describe block), `src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts` (plugins field). + - Depends on: step 6 + - Done when: model unit tests pass; agent tests cover `setClientCustomizations` action publishing, sequencer serialisation, rebind-on-customization-dirty, provisional-session resolution, mid-turn race, swallowed-SDK-snapshot fallback; bundler tests cover write layout / nonce stability / name sanitisation / namespacing / delete-on-change; options test confirms `plugins` projection. + +## Files to Modify or Create + +| Path | Change | Notes | +|------|--------|-------| +| `src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationsModel.ts` | create | Per-session synced + enablement state, parallel to `clientTools/claudeSessionClientToolsModel.ts` | +| `src/vs/platform/agentHost/node/claude/claudeAgentSession.ts` | modify | Inject `IAgentPluginManager`; own `customizationsDiff`; implement `setClientCustomizations` / `setCustomizationEnabled` / `getCustomizations` / `onDidCustomizationsChange`; drain pending reload at `send()` pre-flight; pass plugins into materialize/rematerializer | +| `src/vs/platform/agentHost/node/claude/claudeAgent.ts` | modify | Replace Phase 11 throws with thin delegations to the session; aggregate outbound surface | +| `src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts` | modify | `IBuildOptionsInput.plugins`; project to `Options.plugins` | +| `src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts` | modify | Public `snapshotResolvedCustomizations()` reading the live `Query`'s commands / agents / MCP servers in parallel | +| `src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsModel.test.ts` | create | Model unit tests | +| `src/vs/platform/agentHost/test/node/claudeAgent.test.ts` | modify | `setClientCustomizations` action publishing; reload-no-restart vs reload-then-restart; provisional and mid-turn | +| `src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts` | modify | `plugins` projection into `Options.plugins` | +| `src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts` | modify | `snapshotResolvedCustomizations` round-trip | + +## Decisions + +- **State location.** Both synced customizations AND enablement live on `ClaudeAgentSession` via `SessionCustomizationsDiff`. No provider-wide controller class. Mirrors how client tools are held on the session. Each session is responsible for its own customizations, matching the new architectural direction. +- **`ClaudeAgent` role.** Thin dispatcher only. Looks up the target session by id and delegates. Aggregates outbound events / lists by iterating `_sessions`. The agent never holds customization state itself. +- **Folder layout.** New `src/vs/platform/agentHost/node/claude/customizations/` folder parallels the existing `clientTools/` folder. One file (`claudeSessionCustomizationsModel.ts`); add more as needs emerge. +- **Plugin-set changes are restart-required, not defer-and-coalesce.** Council review (PR #318113) verified that `Query.reloadPlugins()` in `@anthropic-ai/claude-agent-sdk` is parameterless and only re-reads from the plugin URI set captured into `Options.plugins` at startup. Any add / remove / toggle / nonce-bump therefore requires a full SDK rebuild via `rebindForRestart()`. The single dirty bit + single send() pre-flight branch is the simpler model that this constraint forces. `reloadPlugins` may return as a narrow optimisation for content-only nonce-bumps in a future phase if profiling shows it matters. +- **Tool-set divergence detection.** Not needed under the rebind-always model. Removed from the implementation. +- **Provisional sessions.** `customizationsDiff` exists from `ClaudeAgentSession.createProvisional()` onward, so `getCustomizations()` and `setClientCustomizations`/`setCustomizationEnabled` work uniformly before and after materialize. No special-case branching on `isPipelineReady`. +- **No dedicated sequencer for `setCustomizationEnabled`.** The enablement write is synchronous on the session; the SDK side effect drains inside `send()` pre-flight, which already runs under the per-session sequencer. Rapid toggles coalesce naturally — only the final enablement state matters at the next send. +- **SDK `initializationResult()` for `available_plugins`.** Not wired. `snapshotResolvedCustomizations` provides the same info on demand from the live `Query`. Deferred as a diagnostic. +- **Mid-turn-race semantics.** A sync or toggle that lands while a `sendMessage` is in flight does NOT mutate the current turn. The sync writes plugin files to disk and updates the session's diff; the toggle flips the session's enablement bit and dirty flag. The current turn keeps running with whatever plugin set the SDK already has. The next `send()` pre-flight observes the dirty bit and performs reload (or restart). Matches CONTEXT.md §M11's "no mid-turn mutation path" invariant. + +## Risks + +- **`Query.reloadPlugins()` is parameterless** — verified during council review. Drove the architectural pivot from defer-and-coalesce reload to rebind-always; see Decisions. +- **SDK version variance in `snapshotResolvedCustomizations`** — mitigated by isolating the three calls inside `claudeSdkPipeline.snapshotResolvedCustomizations()` and tolerating a thrown rejection in `getSessionCustomizations` (warn-log and fall through to the client-only projection). +- **Race: sync completes during in-flight materialize** — mitigated by routing `setClientCustomizations` through the per-session sequencer in `ClaudeAgent`, AND by reading plugin paths from `clientCustomizationsDiff.consume()` inside `buildOptions` call sites rather than capturing them earlier. +- **Per-session enablement state diverges across sessions** — accepted by design. Each session owns its own enablement; the workbench is responsible for broadcasting toggles to every session it cares about by calling `setCustomizationEnabled(uri, enabled)`, which the agent fans out to all `_sessions`. + +## Verification + +### Unit / Integration + +- Unit suite per step: + - `./scripts/test.sh --runGlob "**/agentHost/test/**/*.test.js"` +- Targeted Phase 11 cases: + - Model: sync round-trip, enable/disable toggle fires `onDidChange`, nonce-bump and metadata changes flip dirty, `enabledPluginPaths` derivation, `consume()` clears dirty. + - Options: `plugins` non-empty -> `Options.plugins` projection; empty -> field omitted. + - Pipeline: `snapshotResolvedCustomizations` returns the three SDK fields verbatim. + - Bundler: write layout / nonce stability / `safeName` sanitisation / working-directory namespacing / delete-on-change. + - Session: direct `adoptClientCustomizations` then `setClientCustomizationEnabled` then read-back via `getClientCustomizations`; mid-turn toggle does not mutate in-flight turn; `send()` pre-flight rebinds when dirty; swallowed-SDK-snapshot fallback in `getSessionCustomizations`. + - Agent: thin dispatcher correctness — `setClientCustomizations` forwards progress as `SessionCustomizationUpdated` actions and runs inside the per-session sequencer; `setCustomizationEnabled` fans out to all `_sessions`. + +### E2E + +- **Launch skill**: `launch` — Playwright/CDP automation of `./scripts/code.sh --agents`. +- **Log skill**: `code-oss-logs` — read `agenthost.log` and per-session log. +- **Customizations UI skill**: `chat-customizations-editor` — domain expert on the customizations editor surface. +- **Scenario**: + 1. Launch Code OSS with `--agents`; open a Claude session under `Local Agent Host`. + 2. From the chat-customizations editor, add a customization with a simple skill plugin; send a turn that uses the skill; confirm via `code-oss-logs` that `agenthost.log` shows `[Claude] session ...: enableFileCheckpointing=true isResume=false` followed by a successful turn that references the plugin. + 3. Disable the same customization; send another turn; confirm logs show `[Claude] session ...: resume rebuild` (any plugin-set change is a yield-restart — there is no `reloadPlugins` fast path). + 4. Add a second customization; send a turn; confirm logs show another `resume rebuild` and that the new plugin is in `Options.plugins`. + 5. Confirm dirty bit clears between turns (no spurious second rebind) and no Claude subprocess leaks (`ps aux | grep claude | grep -v grep`). + +### Manual + +- If the customization picker has UI affordances that the `launch` skill cannot reliably drive (Monaco-focus issues seen in Phase 10.5 E2E), document manual click-through with screenshots into `/tmp/code-oss-screenshots//` and confirm each scenario above. + +## Open Questions + +None. + +## References + +- Roadmap: `./roadmap.md` (Phase 11) +- Context: `./CONTEXT.md` §M6 (Customizations cluster), §M11 (hot-swap / defer-and-coalesce / restart-required taxonomy) +- Prior plan: `./phase10.5-plan.md` (per-session ownership pattern + yield-restart primitive) +- Reference extension: `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts` — `_pendingPluginReload`, `_toolsMatch`, `_setCustomizations`, `_loadPlugins`, `_destroyAndRecreateQuery` +- CopilotAgent for IAgent surface shape only: `src/vs/platform/agentHost/node/copilot/copilotAgent.ts` lines 311, 315, 998, 1026 (note: Copilot uses a provider-wide `PluginController`; Claude deliberately diverges to per-session ownership) +- E2E skills used: `launch`, `code-oss-logs`, `chat-customizations-editor` diff --git a/src/vs/platform/agentHost/node/claude/roadmap.md b/src/vs/platform/agentHost/node/claude/roadmap.md index 7bcdc4e28b925..1253d47150851 100644 --- a/src/vs/platform/agentHost/node/claude/roadmap.md +++ b/src/vs/platform/agentHost/node/claude/roadmap.md @@ -97,7 +97,7 @@ Phase numbers are stable identifiers — code comments, plan files do **not** renumber. The actual landing order diverges from numeric order to unblock self-hosting sooner: -**1 → 1.5 → 2 → 3 → 4 → 5 → 6 → 9 → 13 → 7 → 8 → 10 → 10.5 → 11 → 12 → 6.5 → 14 → 15** +**1 → 1.5 → 2 → 3 → 4 → 5 → 6 → 9 → 13 → 7 → 8 → 10 → 10.5 → 11 → 12 → 6.5 → 14 → 15 → 16** Phase 13 (session restoration) is pulled forward immediately after Phase 9 because it unlocks two high-leverage capabilities: @@ -1055,46 +1055,60 @@ dispose) clean across the whole session lifecycle. Full step-by-step plan: [phase10.5-plan.md](./phase10.5-plan.md). -### Phase 11 — Customizations / plugins (full surface) +### Phase 11 — Customizations / plugins (full surface) ✅ **DONE** + +Shipped in PR #318113. Two-tier model: **Inbound (host → SDK):** -- `setClientCustomizations(clientId, customizations, progress?)` — call - `agentPluginManager.syncCustomizations` to download `CustomizationRef[]` - to local dirs, get back `ISyncedCustomization[]` with local paths. - Forward incremental results via the `progress` callback - (`agentService.ts:439`) for progressive loading UI. -- Pass the local paths as `options.plugins: [{ type: 'local', path }, ...]` - on the next `query()` call. -- **`setCustomizationEnabled(uri, enabled)` — defer-and-coalesce, NOT - restart.** Set `_pendingPluginReload`; at the next yield boundary, call - `Query.reloadPlugins()` (a cheap runtime SDK setter — bijective per - M11). `reloadPlugins` is in M11's **defer-and-coalesce** bucket, not - restart-required: the running subprocess stays up. Only when the *tool - set* implied by the new plugin list diverges from the live one do we - fall back to the **restart-required** path (yield-restart via - `resume: sessionId`); that's the narrow `_toolsMatch` case from - `claudeCodeAgent.ts`, not the default. The misnamed `_pendingRestart` - flag from the reference impl is a historical artifact — the canonical - taxonomy treats plugin reload as cheap. - -**Outbound (SDK → host) — required for Copilot parity -(`agentService.ts:399–417`):** - -- `onDidCustomizationsChange` event. -- `getCustomizations()` — return host-known customizations (synced + active). -- `getSessionCustomizations(session)` — per-session active list. -- See `copilotAgent.ts:190–205, 232–240` for the wiring pattern. - -Tests: client provides a customization → agent syncs it → next `query()` -includes the local path → SDK init message confirms the plugin loaded; -customization toggle drains via `reloadPlugins` at the next yield (no -subprocess restart) and the new plugin appears in `available_plugins`; a -tool-set diff *does* trigger yield-restart; published events fire correctly. - -Exit criteria: customization round-trip works; toggle is defer-and-coalesce -by default and restart-required only when tool sets diverge; workbench -renders Claude customizations like Copilot's. +- `setClientCustomizations(clientId, customizations, progress?)` — runs inside + the per-session sequencer (so a fire-and-forget call from `AgentSideEffects` + cannot race a first `sendMessage`). Calls + `IAgentPluginManager.syncCustomizations` to download `CustomizationRef[]` to + local dirs, forwards incremental results via the `progress` callback for + progressive loading UI, and adopts the resulting `ISyncedCustomization[]` on + the session. +- `setCustomizationEnabled(uri, enabled)` — flips the per-session enablement + bit. Drains at the next `send()` pre-flight. +- **Both writes → yield-restart, NOT in-place reload.** `Query.reloadPlugins()` + in `@anthropic-ai/claude-agent-sdk` is parameterless: it can only re-read + files at plugin paths captured into `Options.plugins` at startup, so it + cannot add a new plugin, drop a disabled one, or pick up a content refresh + via nonce bump. `send()`'s pre-flight runs a single `rebindForRestart()` + when either `toolDiff` or `clientCustomizationsDiff` is dirty; the + rematerializer reads `clientCustomizationsDiff.consume()` while building + `Options`, so the new plugin URI list lands on the rebuilt `Query`. + +**Outbound (SDK → host):** + +- `onDidCustomizationsChange` event — fires from (1) client-pushed writes via + the diff observable, (2) materialize completion (surfaces the SDK-discovered + tier for the first time), (3) pre-flight rebind completion. +- `getCustomizations()` — provider-level catalogue (host-configured); returns + `[]` for Claude today since there is no host-configured surface yet. +- `getSessionCustomizations(session)` — returns the merged projection of + client-pushed entries (with per-URI enablement overlay) plus the + SDK-discovered bundle from `ClaudeSdkCustomizationBundler`. Server-side + commands / agents / MCP servers from the live `Query` are bundled as a + single "Discovered in Claude" Open Plugins-conformant on-disk tree under + `IAgentPluginManager.basePath`, namespaced by working-directory hash and + nonce-stable across repeated bundles of the same SDK snapshot. + +**Per-session ownership.** All customization state lives on +`ClaudeAgentSession`: + +- `SessionClientCustomizationsModel` + `SessionClientCustomizationsDiff` under + `customizations/` (parallel to `clientTools/`) own the synced list, + enablement map, derived enabled plugin paths, and dirty bit. Dirty is + driven from the model state observable (widened equality covers `nonce`, + `displayName`, `description`, `statusMessage`, `agents`, `pluginDir`, + status, enablement) so same-URI content refreshes correctly flip dirty. +- `ClaudeSdkCustomizationBundler` writes the on-disk Open Plugin tree on + demand from `getSessionCustomizations`. Repeated calls with the same SDK + snapshot skip the rewrite. The tree is intentionally a cross-session warm + cache (not deleted on session dispose). + +Full step-by-step plan: [phase11-plan.md](./phase11-plan.md). ### Phase 12 — Subagents ✅ **DONE** @@ -1322,6 +1336,91 @@ Exit criteria: a fresh VS Code install can use the Claude agent without manually installing the SDK or setting any path. SDK upgrades arrive as marketplace extension updates. +### Phase 16 — Eager session materialization at create time + +**Status:** follow-up to Phase 11. Phase 11's +`getProjectedSessionCustomizations` already returns the SDK-resolved +customization tier when the pipeline is bound, but for provisional +sessions it returns only the client-pushed half. The full picture — +SDK-discovered skills (`~/.claude/skills/**`), agents (`.claude/agents/**`), +and `~/.claude/settings.json` MCP servers — only materializes after the +first `sendMessage`. Workbench UX wants the full list available +immediately on `createSession` so a draft session can show its true +capability surface before the user types. + +**Direction:** collapse the provisional/materialize split for the +non-fork `createSession` path. `createSession` synchronously +materializes (spawns the SDK subprocess, opens the proxy refcount, +runs the metadata write, fires `onDidMaterializeSession`) before +returning. + +**Why this is its own phase, not part of Phase 11.** Phase 11's +projector and SDK snapshot work stand on their own — they make +`getSessionCustomizations` correct *whenever* the pipeline is bound. +The eager-materialize change rewrites the M9 lifecycle contract, +touches the `_sessionSequencer`'s first-send branch, changes +disposable semantics for never-used sessions, and updates CONTEXT.md. +Coupling the two would inflate Phase 11's blast radius for no review +benefit; landing them serially keeps each change small. + +**Scope:** + +- `ClaudeAgent.createSession` calls `_materializeProvisional(sessionId)` + synchronously before returning. Return value's `provisional` flag is + either dropped or redefined ("no on-disk transcript yet" rather than + "no SDK" — settle in the plan). +- `_sessionSequencer`'s "first call materializes" branch in + `sendMessage` is removed; every reachable session has a live pipeline. +- `disposeSession` for a never-sent session now tears down a live + subprocess (the existing teardown handles it but is no longer free — + audit cost). +- Fork path (Phase 6.5, when it lands) already materializes synchronously + on `forkSession` return — semantics align naturally. +- CONTEXT.md M9: revise the "Provisional sessions own no SDK + resources" invariant; relax the "two-phase contract is locked" + framing; update the lifecycle tables to reflect "creation is the + materialize trigger". Phase 16 owns the doc update. +- Tests that exercise the provisional → first-send materialize race + (Phase 10.5 regression coverage, Phase 11 mid-turn toggle race) + reworked against the new contract. +- `getSessionCustomizations` for a freshly-created session now returns + the full SDK-resolved + client-pushed projection without waiting on + a send. + +**Trade-offs accepted (documented for posterity):** + +- Drafting is no longer free — every `createSession` pays a subprocess + spawn, plugin sync, proxy refcount, and metadata write. +- A draft the user cancels without sending costs the same as a session + that runs a turn (minus the actual model call). +- The two-phase model (provisional → materialized) collapses into a + single phase for non-fork creation. Fork already materializes + eagerly; this aligns the two paths. + +**Open design points** (settle in the phase plan when scheduled): + +- Does `IAgentCreateSessionResult.provisional` get dropped, or + redefined to mean "no on-disk SDK transcript yet" (true until the + first message lands and the SDK persists)? Workbench callers may + rely on the flag for deferred-notification semantics. +- `_onDidMaterializeSession` fires from inside `createSession`. The + service-layer deferred `sessionAdded` dispatch (`agentService.ts:412`) + must still see the event between the create and the visibility + window — verify ordering. +- Failure modes: if materialization throws (proxy down, SDK install + broken), does `createSession` reject? Probably yes — the user has + no usable session anyway. Today's lazy path lets the failure surface + on first `sendMessage` instead; eager surfaces it earlier, which is + arguably better UX. +- E2E coverage: a workbench scenario that creates a session and + inspects `getSessionCustomizations` *without* sending a message, + verifies the full SDK-resolved list is present. + +Exit criteria: `getSessionCustomizations(freshlyCreatedSession)` +returns the full SDK + client-pushed projection synchronously after +`createSession` resolves; M9 doc updated; Phase 10.5 / 11 race tests +reworked and green. + --- ## Open questions (to resolve as we go) diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 7ee34eddeec61..fdece7effe14d 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -45,13 +45,14 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ILogService, NullLogService } from '../../../log/common/log.js'; import { type AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolResultContentType, type CustomizationRef } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js'; +import { IAgentPluginManager } from '../../common/agentPluginManager.js'; import { ClaudeProxyService, IClaudeProxyService } from '../../node/claude/claudeProxyService.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions } from '../../node/shared/copilotApiService.js'; import { createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; @@ -580,6 +581,11 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, { + _serviceBrand: undefined, + basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), + async syncCustomizations(_clientId: string, _customizations: CustomizationRef[]) { return []; }, + }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], ); @@ -704,6 +710,11 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, { + _serviceBrand: undefined, + basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), + async syncCustomizations(_clientId: string, _customizations: CustomizationRef[]) { return []; }, + }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], ); @@ -757,6 +768,11 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IClaudeProxyService, realProxy], [ISessionDataService, createSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, { + _serviceBrand: undefined, + basePath: URI.from({ scheme: 'inmemory', path: '/agentPlugins' }), + async syncCustomizations(_clientId: string, _customizations: CustomizationRef[]) { return []; }, + }], [IAgentConfigurationService, configService], [IAgentHostGitService, createNoopGitService()], ); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 29d45161a8816..f2d8977c6b9ef 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -38,13 +38,14 @@ import { FileService } from '../../../files/common/fileService.js'; import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/agentFeedbackAttachments.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolResultContentType, buildSubagentSessionUri, type CustomizationRef, type SessionCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ProtectedResourceMetadata, SessionInputAnswerState, SessionInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type SessionInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { ClaudeAgentSession } from '../../node/claude/claudeAgentSession.js'; import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js'; @@ -62,6 +63,31 @@ interface IStartCall { readonly token: string; } +class FakeAgentPluginManager implements IAgentPluginManager { + declare readonly _serviceBrand: undefined; + readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); + + syncResult: readonly ISyncedCustomization[] | undefined; + syncCalls: { clientId: string; customizations: readonly CustomizationRef[] }[] = []; + + async syncCustomizations( + clientId: string, + customizations: CustomizationRef[], + progress?: (status: SessionCustomization) => void, + ): Promise { + this.syncCalls.push({ clientId, customizations: [...customizations] }); + if (this.syncResult) { + if (progress) { + for (const synced of this.syncResult) { + progress(synced.customization); + } + } + return [...this.syncResult]; + } + return []; + } +} + class FakeClaudeProxyService implements IClaudeProxyService { declare readonly _serviceBrand: undefined; @@ -422,12 +448,29 @@ class FakeQuery implements AsyncGenerator { setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } async applyFlagSettings(s: Settings): Promise { this.recordedFlagSettings.push(s); } initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } - supportedCommands(): never { throw new Error('FakeQuery: supportedCommands not modeled'); } + + supportedCommands(): never { + return Promise.resolve([]) as never; + } supportedModels(): never { throw new Error('FakeQuery: supportedModels not modeled'); } supportedAgents(): never { throw new Error('FakeQuery: supportedAgents not modeled'); } mcpServerStatus(): never { throw new Error('FakeQuery: mcpServerStatus not modeled'); } getContextUsage(): never { throw new Error('FakeQuery: getContextUsage not modeled'); } - reloadPlugins(): never { throw new Error('FakeQuery: reloadPlugins not modeled'); } + /** Phase 11 — programmable tool-name snapshot returned by `reloadPlugins()`. */ + reloadPluginsResults: readonly string[][] = []; + reloadPluginsCallCount = 0; + reloadPlugins(): never { + this.reloadPluginsCallCount++; + const idx = Math.min(this.reloadPluginsCallCount - 1, this.reloadPluginsResults.length - 1); + const names = this.reloadPluginsResults[idx] ?? []; + return Promise.resolve({ + commands: names.map(name => ({ name, description: '', argumentHint: '' })), + agents: [], + plugins: [], + mcpServers: [], + error_count: 0, + }) as never; + } accountInfo(): never { throw new Error('FakeQuery: accountInfo not modeled'); } rewindFiles(): never { throw new Error('FakeQuery: rewindFiles not modeled'); } readFile(): never { throw new Error('FakeQuery: readFile not modeled'); } @@ -591,6 +634,7 @@ function createTestContext( [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], ); @@ -870,6 +914,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); @@ -931,6 +976,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -997,6 +1043,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -1063,6 +1110,7 @@ suite('ClaudeAgent', () => { undefined, undefined, undefined, + undefined, new PendingRequestRegistry(), 'default', instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), @@ -1089,6 +1137,7 @@ suite('ClaudeAgent', () => { undefined, undefined, undefined, + undefined, new PendingRequestRegistry(), 'default', instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), @@ -1928,6 +1977,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], ); @@ -2526,6 +2576,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2595,6 +2646,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2677,6 +2729,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2722,6 +2775,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new FakeClaudeProxyService()], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -3026,6 +3080,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, new RecordingProxyService()], [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], + [IAgentPluginManager, new FakeAgentPluginManager()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -3075,6 +3130,7 @@ suite('ClaudeAgent', () => { [IClaudeProxyService, proxy], [ISessionDataService, sessionData], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], ); @@ -3428,6 +3484,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { [ILogService, new NullLogService()], [IAgentConfigurationService, fakeConfigService], [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], [ISessionDataService, sessionData], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); @@ -3438,6 +3495,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { undefined, undefined, undefined, + undefined, new PendingRequestRegistry(), 'default', instantiationService.createInstance(ClaudeSessionMetadataStore, 'claude'), @@ -4655,5 +4713,298 @@ suite('ClaudeAgent (Phase 13 — getSessionMessages)', () => { // #endregion +// #region Phase 11 — customizations / plugins + +suite('ClaudeAgent — Phase 11 customizations', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeSyncedRef(uri: string, dir: string): ISyncedCustomization { + return { + customization: { + customization: { uri, displayName: uri }, + enabled: true, + }, + pluginDir: URI.file(dir), + }; + } + + function buildCtxWith(pluginManager: FakeAgentPluginManager): ITestContext { + const proxy = new FakeClaudeProxyService(); + const api = new FakeCopilotApiService(); + api.models = async () => [...ALL_MODELS]; + const sdk = new FakeClaudeAgentSdkService(); + const sessionData = new RecordingSessionDataService(createSessionDataService()); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + + const services = new ServiceCollection( + [ILogService, logService], + [ICopilotApiService, api], + [IClaudeProxyService, proxy], + [ISessionDataService, sessionData], + [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, pluginManager], + [IAgentHostGitService, createNoopGitService()], + [IAgentConfigurationService, configService], + ); + const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + return { agent, proxy, api, sdk, sessionData, stateManager, configService, instantiationService }; + } + + test('setClientCustomizations forwards each item as a SessionCustomizationUpdated action', async () => { + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a'), makeSyncedRef('https://b', '/p/b')]; + const { agent } = buildCtxWith(pm); + + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + + const updates: { uri: string }[] = []; + disposables.add(agent.onDidSessionProgress(s => { + if (s.kind === 'action' && s.action.type === ActionType.SessionCustomizationUpdated) { + updates.push({ uri: s.action.customization.uri.toString() }); + } + })); + + const synced = await agent.setClientCustomizations(created.session, 'client-1', [ + { uri: 'https://a', displayName: 'A' }, + { uri: 'https://b', displayName: 'B' }, + ]); + + assert.strictEqual(synced.length, 2); + assert.ok(updates.some(u => u === undefined ? false : u.uri.includes('a')), `expected an update for plugin a; got ${JSON.stringify(updates)}`); + assert.ok(updates.some(u => u === undefined ? false : u.uri.includes('b')), `expected an update for plugin b; got ${JSON.stringify(updates)}`); + }); + + test('setCustomizationEnabled fans out to every in-memory session', async () => { + const pm = new FakeAgentPluginManager(); + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'a'), workingDirectory: URI.file('/work') }); + const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'b'), workingDirectory: URI.file('/work') }); + + pm.syncResult = [makeSyncedRef('https://shared', '/p/shared')]; + await agent.setClientCustomizations(s1.session, 'c', [{ uri: 'https://shared', displayName: 'S' }]); + await agent.setClientCustomizations(s2.session, 'c', [{ uri: 'https://shared', displayName: 'S' }]); + + // One fire per per-session diff change confirms fan-out. + let changes = 0; + disposables.add(agent.onDidCustomizationsChange(() => changes++)); + agent.setCustomizationEnabled('https://shared', false); + + assert.strictEqual(changes, 2); + }); + + test('getCustomizations returns [] — provider-level catalogue, not a cross-session aggregator', async () => { + const pm = new FakeAgentPluginManager(); + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const s1 = await agent.createSession({ session: AgentSession.uri('claude', 'one'), workingDirectory: URI.file('/work') }); + const s2 = await agent.createSession({ session: AgentSession.uri('claude', 'two'), workingDirectory: URI.file('/work') }); + + pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://a', '/p/a')]; + await agent.setClientCustomizations(s1.session, 'c', []); + pm.syncResult = [makeSyncedRef('https://shared', '/p/shared'), makeSyncedRef('https://b', '/p/b')]; + await agent.setClientCustomizations(s2.session, 'c', []); + + // `IAgent.getCustomizations()` is the provider-level catalogue + // (host-configured), NOT an aggregator across sessions. Claude has + // no host-configured customizations today, so [] is the contract. + // Client-pushed refs flow through `getSessionCustomizations` instead. + assert.deepStrictEqual(agent.getCustomizations(), []); + }); + + test('getSessionCustomizations resolves against a provisional session', async () => { + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + const { agent } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + assert.strictEqual(created.provisional, true); + + await agent.setClientCustomizations(created.session, 'c', [{ uri: 'https://a', displayName: 'A' }]); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.length, 1); + }); + + test('send pre-flight: dirty customizations triggers a rebind (SDK plugin URI set is captured at startup, so any change must restart the Query)', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Stage 2 turns and park the iterator after turn 1's `result` so + // `_query` stays bound (mirroring the "reuse query" pattern). + const advance = new DeferredPromise(); + sdk.queryAdvance = async (idx: number) => { if (idx === 2) { await advance.p; } }; + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.startupCallCount, 1); + + // Customization sync flips dirty; the next sendMessage's + // pre-flight rebinds so `Options.plugins` on the new Query + // includes the new path. + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + await agent.setClientCustomizations(created.session, 'c', [{ uri: 'https://a', displayName: 'A' }]); + const firstQuery = sdk.warmQueries[0].produced!; + + const p2 = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await tick(); + advance.complete(); + await p2; + + assert.deepStrictEqual({ + reloadsOnFirstQuery: firstQuery.reloadPluginsCallCount, + startups: sdk.startupCallCount, + warmQueries: sdk.warmQueries.length, + }, { reloadsOnFirstQuery: 0, startups: 2, warmQueries: 2 }); + }); + + test('mid-turn setCustomizationEnabled does not affect the in-flight send (race coverage)', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + // Materialize, then drain the dirty bit from a customization + // sync so the pre-flight for the SECOND turn is clean. + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + pm.syncResult = [makeSyncedRef('https://x', '/p/x')]; + await agent.setClientCustomizations(created.session, 'c', [{ uri: 'https://x', displayName: 'X' }]); + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + const session = agent.getSessionForTesting(created.session)!; + // First-turn materialize consumed the dirty bit from the sync + // above (plugin path baked into `Options.plugins` of the + // startup `Query`), so the pre-flight for the second turn + // starts clean. + assert.strictEqual(session.clientCustomizationsDiff.hasDifference, false); + + // Block the SECOND turn mid-iterator so a toggle can land while + // the SDK is mid-yield. + const gate = new DeferredPromise(); + sdk.queryAdvance = async (i: number) => { if (i === 2) { await gate.p; } }; + + const inflight = agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + await new Promise(r => setImmediate(r)); + + // Toggle a SYNCED customization during the in-flight turn. The + // diff flips dirty (state changed) but no SDK action drains + // during the current send — its pre-flight already passed. + const startupsBefore = sdk.startupCallCount; + agent.setCustomizationEnabled('https://x', false); + assert.strictEqual(session.clientCustomizationsDiff.hasDifference, true); + assert.strictEqual(sdk.startupCallCount, startupsBefore, 'no rebind during the in-flight turn'); + + gate.complete(); + await inflight; + }); + + test('getSessionCustomizations swallows SDK snapshot failure and returns the client-pushed projection', async () => { + // `snapshotResolvedCustomizations` calls `supportedAgents()` and + // `mcpServerStatus()` in `Promise.all`; the FakeQuery throws on + // both. The session should warn-log and still return the + // client-pushed slice rather than blanking the UI. + const pm = new FakeAgentPluginManager(); + pm.syncResult = [makeSyncedRef('https://a', '/p/a')]; + const { agent, sdk } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.setClientCustomizations(created.session, 'c', [{ uri: 'https://a', displayName: 'A' }]); + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + + const customizations = await agent.getSessionCustomizations!(created.session); + assert.strictEqual(customizations.length, 1, 'client-pushed projection survives SDK snapshot failure'); + assert.strictEqual(customizations[0].customization.uri, 'https://a'); + }); + + test('changeAgent on a provisional session stashes the selection (no SDK contact) and lands on Options.agent at materialize', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/code-reviewer.md' }); + assert.strictEqual(sdk.startupCallCount, 0, 'no SDK startup from changeAgent on provisional'); + + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'code-reviewer', 'agent name resolved from file URI basename'); + }); + + test('changeAgent on a materialized session triggers a rebind with the new Options.agent on the rebuilt Query', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, undefined, 'no agent on first startup'); + + // Mid-session agent change: flips dirty, next send rebinds + // (SDK has no working runtime hook to swap the agent in place). + await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/planner.md' }); + await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + + assert.strictEqual(sdk.startupCallCount, 2, 'rebind on agent change'); + assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, 'planner', 'agent baked into rebuilt Options'); + }); + + test('changeAgent(undefined) clears the selection: rebind, Options.agent omitted', async () => { + const pm = new FakeAgentPluginManager(); + const ctx = buildCtxWith(pm); + const { agent, sdk } = ctx; + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await agent.createSession({ + workingDirectory: URI.file('/work'), + agent: { uri: 'file:///foo/agents/planner.md' }, + }); + const sessionId = AgentSession.id(created.session); + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), + ]; + await agent.sendMessage(created.session, 'first', undefined, 'turn-1'); + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); + + await agent.changeAgent!(created.session, undefined); + await agent.sendMessage(created.session, 'second', undefined, 'turn-2'); + + assert.strictEqual(sdk.startupCallCount, 2); + assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, undefined, 'cleared agent omitted from rebuilt Options'); + }); +}); + +// #endregion + + diff --git a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts index 87d9a269de3fb..e76fe8a63f49c 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkOptions.test.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { buildSubprocessEnv } from '../../node/claude/claudeSdkOptions.js'; +import { buildOptions, buildSubprocessEnv } from '../../node/claude/claudeSdkOptions.js'; +import type { IClaudeProxyHandle } from '../../node/claude/claudeProxyService.js'; suite('claudeSdkOptions / buildSubprocessEnv', () => { @@ -77,3 +79,51 @@ suite('claudeSdkOptions / buildSubprocessEnv', () => { assert.strictEqual(env.ELECTRON_RUN_AS_NODE, '1'); }); }); + +suite('claudeSdkOptions / buildOptions plugins projection', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const proxyHandle: IClaudeProxyHandle = { + baseUrl: 'http://127.0.0.1:0', + nonce: 'n', + dispose: () => { }, + }; + + function input(plugins: readonly URI[] | undefined) { + return { + sessionId: 's1', + workingDirectory: URI.file('/tmp/x'), + model: undefined, + abortController: new AbortController(), + permissionMode: 'default' as const, + canUseTool: async () => ({ behavior: 'allow' as const, updatedInput: {} }), + isResume: false, + mcpServers: undefined, + ...(plugins !== undefined ? { plugins } : {}), + }; + } + + test('non-empty plugins project to Options.plugins as local entries', async () => { + const opts = await buildOptions( + input([URI.file('/p/a'), URI.file('/p/b')]), + proxyHandle, + () => { }, + () => { }, + ); + assert.deepStrictEqual(opts.plugins, [ + { type: 'local', path: URI.file('/p/a').fsPath }, + { type: 'local', path: URI.file('/p/b').fsPath }, + ]); + }); + + test('empty plugins array omits Options.plugins', async () => { + const opts = await buildOptions(input([]), proxyHandle, () => { }, () => { }); + assert.strictEqual(opts.plugins, undefined); + }); + + test('undefined plugins omits Options.plugins', async () => { + const opts = await buildOptions(input(undefined), proxyHandle, () => { }, () => { }); + assert.strictEqual(opts.plugins, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index 4356b7dd69810..8d98c2bc3f4d1 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -141,6 +141,52 @@ suite('ClaudeSdkPipeline', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + suite('reloadPlugins', () => { + + test('forwards to the SDK Query', async () => { + let reloadCallCount = 0; + class WarmWithReload extends FakeWarmQuery { + override query(_prompt: string | AsyncIterable): Query { + this.queryCallCount++; + const q = new ImmediatelyDoneQuery(); + (q as unknown as { reloadPlugins: () => Promise<{ commands: { name: string }[] }> }).reloadPlugins = + async () => { reloadCallCount++; return { commands: [] }; }; + return q; + } + } + const controller = new AbortController(); + const warm = new WarmWithReload(); + const fileService = disposables.add(new FileService(new NullLogService())); + const fs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('file', fs)); + const db = new TestSessionDatabase(); + const dbRef: IReference = { object: db, dispose: () => { } }; + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [IFileService, fileService], + [IDiffComputeService, createZeroDiffComputeService()], + ); + const inst: IInstantiationService = disposables.add(new InstantiationService(services)); + const subagents = disposables.add(new SubagentRegistry()); + const pipeline = disposables.add(inst.createInstance( + ClaudeSdkPipeline, + 'sess-2', + URI.parse('claude:/sess-2'), + warm, + controller, + dbRef, + subagents, + undefined, + )); + // Bind the query by issuing a send (iterator closes immediately). + pipeline.send(makePrompt('p1'), 'turn-A').catch(() => { /* expected */ }); + await Promise.resolve(); + + await pipeline.reloadPlugins(); + assert.strictEqual(reloadCallCount, 1); + }); + }); + suite('initial state', () => { test('isResumed starts false and isAborted starts false', () => { diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts new file mode 100644 index 0000000000000..f20a1ae2602a9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSdkCustomizationBundler.test.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../instantiation/test/common/instantiationServiceMock.js'; +import { FileService } from '../../../../files/common/fileService.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../../../common/state/protocol/state.js'; +import type { ISdkResolvedCustomizations } from '../../../node/claude/claudeSdkPipeline.js'; +import { ClaudeSdkCustomizationBundler } from '../../../node/claude/customizations/claudeSdkCustomizationBundler.js'; + +suite('ClaudeSdkCustomizationBundler', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let bundler: ClaudeSdkCustomizationBundler; + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + const workingDir = URI.file('/work'); + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const inst = disposables.add(new TestInstantiationService()); + inst.stub(IFileService, fileService); + inst.stub(IAgentPluginManager, { + basePath, + syncCustomizations: async (_clientId: string, _refs: readonly CustomizationRef[]): Promise => [], + } satisfies Partial as unknown as IAgentPluginManager); + bundler = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, workingDir)); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function snapshot(overrides: Partial = {}): ISdkResolvedCustomizations { + return { + commands: [], + agents: [], + mcpServers: [], + ...overrides, + }; + } + + test('returns undefined when SDK snapshot has no commands or agents', async () => { + const result = await bundler.bundle(snapshot()); + assert.strictEqual(result, undefined); + }); + + test('writes manifest, agent files, and skill subdirs for a snapshot with agents and commands', async () => { + const result = await bundler.bundle(snapshot({ + agents: [{ name: 'planner', description: 'Plans things', model: 'claude' }], + commands: [{ name: 'doit', description: 'Does it', argumentHint: '' }], + })); + + assert.ok(result, 'should produce a bundle'); + const rootUri = URI.parse(result!.customization.uri); + const manifest = await fileService.readFile(URI.joinPath(rootUri, '.plugin', 'plugin.json')); + const manifestJson = JSON.parse(manifest.value.toString()); + assert.strictEqual(manifestJson.name, 'claude-discovered'); + const agentFile = await fileService.readFile(URI.joinPath(rootUri, 'agents', 'planner.md')); + assert.match(agentFile.value.toString(), /name: "planner"/); + assert.match(agentFile.value.toString(), /description: "Plans things"/); + const skillFile = await fileService.readFile(URI.joinPath(rootUri, 'skills', 'doit', 'SKILL.md')); + assert.match(skillFile.value.toString(), /name: "doit"/); + assert.match(skillFile.value.toString(), /Usage: ``/); + }); + + test('agents field is populated from the SDK snapshot with on-disk file URIs', async () => { + const result = await bundler.bundle(snapshot({ + agents: [ + { name: 'a1', description: 'one', model: 'm' }, + { name: 'a2', description: 'two', model: 'm' }, + ], + })); + const agents = result!.agents!; + assert.deepStrictEqual(agents.map(a => a.name), ['a1', 'a2']); + assert.ok(agents[0].uri.endsWith('/agents/a1.md'), `expected on-disk path, got ${agents[0].uri}`); + assert.ok(agents[1].uri.endsWith('/agents/a2.md')); + }); + + test('repeated bundle with same snapshot is nonce-stable and does not rewrite', async () => { + const r1 = await bundler.bundle(snapshot({ + agents: [{ name: 'p', description: 'd', model: 'm' }], + })); + const rootUri = URI.parse(r1!.customization.uri); + const agentUri = URI.joinPath(rootUri, 'agents', 'p.md'); + const stat1 = await fileService.stat(agentUri); + + const r2 = await bundler.bundle(snapshot({ + agents: [{ name: 'p', description: 'd', model: 'm' }], + })); + assert.strictEqual(r1!.customization.nonce, r2!.customization.nonce); + const stat2 = await fileService.stat(agentUri); + assert.strictEqual(stat1.mtime, stat2.mtime, 'unchanged snapshot must not rewrite the on-disk tree'); + }); + + test('changed snapshot deletes prior bundle tree before writing the new one', async () => { + await bundler.bundle(snapshot({ + agents: [{ name: 'old', description: 'd', model: 'm' }], + })); + const result = await bundler.bundle(snapshot({ + agents: [{ name: 'new', description: 'd', model: 'm' }], + })); + const rootUri = URI.parse(result!.customization.uri); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'new.md'))); + assert.ok(!(await fileService.exists(URI.joinPath(rootUri, 'agents', 'old.md'))), 'previous agent file should be deleted'); + }); + + test('sanitises agent and command names — invalid chars replaced, length capped, empty falls back to "unnamed"', async () => { + const longName = 'a'.repeat(200); + const result = await bundler.bundle(snapshot({ + agents: [ + { name: 'has spaces & slashes/here', description: 'd', model: 'm' }, + { name: longName, description: 'd', model: 'm' }, + { name: '!!!', description: 'd', model: 'm' }, + ], + })); + const rootUri = URI.parse(result!.customization.uri); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', 'has_spaces___slashes_here.md'))); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', `${'a'.repeat(128)}.md`))); + assert.ok(await fileService.exists(URI.joinPath(rootUri, 'agents', '___.md'))); + }); + + test('discoverable bundles for different working directories namespace by hash so they do not collide', async () => { + const inst = disposables.add(new TestInstantiationService()); + inst.stub(IFileService, fileService); + inst.stub(IAgentPluginManager, { + basePath, + } satisfies Partial as unknown as IAgentPluginManager); + const other = disposables.add(inst.createInstance(ClaudeSdkCustomizationBundler, URI.file('/other-work'))); + + const a = await bundler.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); + const b = await other.bundle(snapshot({ agents: [{ name: 'x', description: 'd', model: 'm' }] })); + assert.notStrictEqual(a!.customization.uri, b!.customization.uri); + }); + + test('returned SessionCustomization carries the expected shape (status Loaded, enabled true, displayName, description)', async () => { + const result = await bundler.bundle(snapshot({ + agents: [{ name: 'a', description: 'd', model: 'm' }], + commands: [{ name: 'c', description: 'd', argumentHint: '' }], + })); + assert.deepStrictEqual({ + enabled: result!.enabled, + status: result!.status, + }, { + enabled: true, + status: CustomizationStatus.Loaded, + }); + assert.ok(typeof result!.customization.displayName === 'string' && result!.customization.displayName.length > 0); + assert.ok(typeof result!.customization.description === 'string' && result!.customization.description!.length > 0); + }); + + // Smoke: ensure return type compiles against SessionCustomization + function _typeCheck(): SessionCustomization | undefined { + return undefined; + } + void _typeCheck; +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts new file mode 100644 index 0000000000000..70b6e1adb7685 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionClientCustomizationsModel.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import { CustomizationStatus } from '../../../common/state/protocol/state.js'; +import { SessionClientCustomizationsDiff } from '../../../node/claude/customizations/claudeSessionClientCustomizationsModel.js'; + +function synced(uri: string, opts: { dir?: string; enabled?: boolean; nonce?: string; displayName?: string } = {}): ISyncedCustomization { + return { + customization: { + customization: { + uri, + displayName: opts.displayName ?? uri, + ...(opts.nonce !== undefined ? { nonce: opts.nonce } : {}), + }, + enabled: opts.enabled ?? true, + status: CustomizationStatus.Loaded, + }, + ...(opts.dir !== undefined ? { pluginDir: URI.file(opts.dir) } : {}), + }; +} + +suite('SessionClientCustomizationsDiff', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('fresh diff: empty, not dirty, no enabled paths', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + assert.deepStrictEqual(diff.model.state.get().synced, []); + assert.strictEqual(diff.hasDifference, false); + assert.deepStrictEqual(diff.model.enabledPluginPaths.get(), []); + }); + + test('setSyncedCustomizations flips dirty and fires onDidChange', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + assert.strictEqual(diff.hasDifference, true); + assert.strictEqual(fires, 1); + }); + + test('enabledPluginPaths excludes entries without pluginDir', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([ + synced('https://a', { dir: '/p/a' }), + synced('https://b'), + ]); + assert.deepStrictEqual(diff.model.enabledPluginPaths.get().map(u => u.fsPath), [URI.file('/p/a').fsPath]); + }); + + test('setEnabled(false) removes from enabled paths and flips dirty exactly when value changes', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + assert.strictEqual(diff.hasDifference, false); + + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + + const uri = 'https://a'; + diff.model.setEnabled(uri, false); + assert.deepStrictEqual(diff.model.enabledPluginPaths.get(), []); + assert.strictEqual(diff.hasDifference, true); + assert.strictEqual(fires, 1); + + diff.model.setEnabled(uri, false); // no change → no fire, stays dirty + assert.strictEqual(fires, 1); + }); + + test('default enablement is true (absent entry counts as enabled)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + assert.strictEqual(diff.model.enabledPluginPaths.get().length, 1); + }); + + test('setEnabled(true) is a no-op for default-enabled entries', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setEnabled('https://a', true); + assert.strictEqual(fires, 0); + assert.strictEqual(diff.hasDifference, false); + }); + + test('consume returns current paths and clears dirty', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + const paths = diff.consume(); + assert.strictEqual(paths.length, 1); + assert.strictEqual(diff.hasDifference, false); + }); + + test('markDirty re-flips after failed downstream reload', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + assert.strictEqual(diff.hasDifference, false); + diff.markDirty(); + assert.strictEqual(diff.hasDifference, true); + }); + + test('structurally-equivalent re-send is deduped (no fire, no dirty)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + diff.consume(); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a' })]); + assert.strictEqual(fires, 0); + assert.strictEqual(diff.hasDifference, false); + }); + + test('toggling enablement of customization without pluginDir still flips dirty (no-restart optimisation intentionally given up: rebind is cheap and correctness > efficiency)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a')]); + diff.consume(); + diff.model.setEnabled('https://a', false); + assert.strictEqual(diff.hasDifference, true); + }); + + test('nonce change at same URI / pluginDir flips dirty', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', nonce: 'v1' })]); + diff.consume(); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', nonce: 'v2' })]); + assert.strictEqual(diff.hasDifference, true); + }); + + test('displayName change at same URI flips dirty (state observable fires for workbench refetch)', () => { + const diff = disposables.add(new SessionClientCustomizationsDiff()); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', displayName: 'A' })]); + diff.consume(); + let fires = 0; + disposables.add(diff.onDidChange(() => fires++)); + diff.model.setSyncedCustomizations([synced('https://a', { dir: '/p/a', displayName: 'A renamed' })]); + assert.strictEqual(fires, 1); + assert.strictEqual(diff.hasDifference, true); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts new file mode 100644 index 0000000000000..ecae4400c1780 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationsProjector.test.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { ISyncedCustomization } from '../../../common/agentPluginManager.js'; +import { CustomizationStatus, type SessionCustomization } from '../../../common/state/protocol/state.js'; +import { projectSessionCustomizations } from '../../../node/claude/customizations/claudeSessionCustomizationsProjector.js'; + +function client(uri: string, enabled = true): ISyncedCustomization { + return { + customization: { + customization: { uri, displayName: uri }, + enabled, + status: CustomizationStatus.Loaded, + }, + }; +} + +function discoveredBundle(uri: string): SessionCustomization { + return { + customization: { uri, displayName: 'VS Code Synced Data' }, + enabled: true, + status: CustomizationStatus.Loaded, + }; +} + +suite('projectSessionCustomizations', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns only client-pushed entries when no discovery bundle', () => { + const result = projectSessionCustomizations([client('https://a')], new Map(), undefined); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].customization.uri.toString(), 'https://a'); + assert.strictEqual(result[0].enabled, true); + }); + + test('overlays enablement map on client-pushed entries', () => { + const result = projectSessionCustomizations( + [client('https://a'), client('https://b')], + new Map([['https://a', false]]), + undefined, + ); + assert.strictEqual(result.find(c => c.customization.uri.toString() === 'https://a')?.enabled, false); + assert.strictEqual(result.find(c => c.customization.uri.toString() === 'https://b')?.enabled, true); + }); + + test('appends the discovery bundle verbatim', () => { + const bundleUri = URI.file('/tmp/host-discovery/x').toString(); + const result = projectSessionCustomizations( + [client('https://a')], + new Map(), + discoveredBundle(bundleUri), + ); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[1].customization.uri.toString(), bundleUri); + assert.strictEqual(result[1].enabled, true); + }); + + test('discovery bundle enablement is not overlaid from the map', () => { + const bundleUri = URI.file('/tmp/host-discovery/x').toString(); + const result = projectSessionCustomizations( + [], + new Map([[bundleUri, false]]), + discoveredBundle(bundleUri), + ); + assert.strictEqual(result[0].enabled, true); + }); +}); From 9f475b05d08013b0a4d54f58dc0ce1e14c5afd14 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 27 May 2026 15:43:16 -0700 Subject: [PATCH 09/18] Sync quota snapshots (#318460) --- .../vscode-node/chatQuota.contribution.ts | 39 +- .../chatInputNotification.contribution.ts | 381 --------- ...chatInputNotification.contribution.spec.ts | 733 ------------------ .../extension/vscode-node/contributions.ts | 2 - .../extension/prompt/node/chatMLFetcher.ts | 4 + src/vs/sessions/test/web.test.ts | 2 + .../api/browser/extensionHost.contribution.ts | 1 + .../api/browser/mainThreadChatQuota.ts | 24 + .../workbench/api/common/extHost.api.impl.ts | 6 + .../workbench/api/common/extHost.protocol.ts | 39 + .../workbench/api/common/extHostChatQuota.ts | 23 + .../chat/browser/chat.shared.contribution.ts | 2 + .../chat/browser/chatQuotaNotification.ts | 385 +++++++++ .../contrib/chat/browser/chatTipService.ts | 22 +- .../contrib/chat/common/chatSelectedModel.ts | 103 +++ .../browser/chatQuotaNotification.test.ts | 619 +++++++++++++++ .../test/browser/chatStatusDashboard.test.ts | 2 + .../chat/common/chatEntitlementService.ts | 21 +- .../test/common/workbenchTestServices.ts | 2 + ...scode.proposed.chatParticipantPrivate.d.ts | 51 ++ 20 files changed, 1322 insertions(+), 1139 deletions(-) delete mode 100644 extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts delete mode 100644 extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts create mode 100644 src/vs/workbench/api/browser/mainThreadChatQuota.ts create mode 100644 src/vs/workbench/api/common/extHostChatQuota.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts create mode 100644 src/vs/workbench/contrib/chat/common/chatSelectedModel.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts b/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts index d62999e40bbfa..1e916c5eda139 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatQuota.contribution.ts @@ -2,7 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { commands, env, Uri } from 'vscode'; +import { chat, commands, env, Uri } from 'vscode'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IChatQuotaService } from '../../../platform/chat/common/chatQuotaService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IExtensionContribution } from '../../common/contributions'; @@ -10,7 +11,10 @@ import { IExtensionContribution } from '../../common/contributions'; export class ChatQuotaContribution extends Disposable implements IExtensionContribution { public readonly id = 'chat.quota'; - constructor(@IChatQuotaService chatQuotaService: IChatQuotaService) { + constructor( + @IChatQuotaService chatQuotaService: IChatQuotaService, + @IAuthenticationService authService: IAuthenticationService, + ) { super(); this._register(commands.registerCommand('chat.enableAdditionalUsage', () => { // Clear quota before opening the page to ensure that if the user enabled additional usage, @@ -18,5 +22,36 @@ export class ChatQuotaContribution extends Disposable implements IExtensionContr chatQuotaService.clearQuota(); env.openExternal(Uri.parse('https://aka.ms/github-copilot-manage-overage')); })); + + // Extension → Core: push updated quota state to core whenever it changes + // (e.g. from response headers, quota snapshots, or copilot token refresh). + this._register(chatQuotaService.onDidChange(() => { + const info = chatQuotaService.quotaInfo; + if (!info) { + return; + } + + const isFree = !!authService.copilotToken?.isFreeUser; + const snapshot = { + percentRemaining: info.percentRemaining, + unlimited: info.unlimited, + hasQuota: info.hasQuota, + entitlement: info.quota, + }; + + const { session, weekly } = chatQuotaService.rateLimitInfo; + + const quotas = { + usageBasedBilling: !!authService.copilotToken?.isUsageBasedBilling, + chat: isFree ? snapshot : undefined, + premiumChat: isFree ? undefined : snapshot, + additionalUsageEnabled: info.additionalUsageEnabled, + additionalUsageCount: info.additionalUsageUsed, + sessionRateLimit: session ? { percentRemaining: session.percentRemaining, unlimited: session.unlimited, resetDate: session.resetDate.toISOString() } : undefined, + weeklyRateLimit: weekly ? { percentRemaining: weekly.percentRemaining, unlimited: weekly.unlimited, resetDate: weekly.resetDate.toISOString() } : undefined, + }; + + chat.updateQuotas(quotas); + })); } } diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts deleted file mode 100644 index d2503875573a2..0000000000000 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/chatInputNotification.contribution.ts +++ /dev/null @@ -1,381 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; -import { IChatQuota, IChatQuotaService } from '../../../platform/chat/common/chatQuotaService'; -import { Disposable } from '../../../util/vs/base/common/lifecycle'; - -const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; -const THRESHOLDS = [50, 75, 90, 95]; - -interface IRateLimitWarning { - percentUsed: number; - type: 'session' | 'weekly'; - resetDate: Date; -} - -interface IQuotaWarning { - percentUsed: number; - resetDate: Date; -} - -/** - * Manages a single chat input notification for quota and rate limit status. - * - * Listens to `IChatQuotaService.onDidChange` and determines whether a - * new threshold has been crossed, then shows the highest-priority notification: - * - * 1. **Quota exhausted** — info, not auto-dismissed, only dismissible via X. - * 2. **Quota approaching** — info, auto-dismissed on next message. - * 3. **Rate-limit warning** — info, auto-dismissed on next message. - */ -export class ChatInputNotificationContribution extends Disposable { - - private _notification: vscode.ChatInputNotification | undefined; - /** Tracks whether the current notification is the quota-exhausted variant. */ - private _showingExhausted = false; - /** Whether a copilot token was present on the last {@link _update} call. */ - private _hadCopilotToken = false; - - /** - * Previous percent-used values for threshold crossing detection. - * `undefined` means no data has been seen yet — the first value - * establishes a baseline without triggering a notification. - */ - private _prevQuotaPercentUsed: number | undefined; - private _prevSessionPercentUsed: number | undefined; - private _prevWeeklyPercentUsed: number | undefined; - private _prevAdditionalUsageEnabled: boolean | undefined; - - private get _quotaUsedUp(): boolean { - const info = this._chatQuotaService.quotaInfo; - if (!info) { - return false; - } - if (info.unlimited) { - return !info.hasQuota; - } - return info.percentRemaining <= 0; - } - - constructor( - @IAuthenticationService private readonly _authService: IAuthenticationService, - @IChatQuotaService private readonly _chatQuotaService: IChatQuotaService, - ) { - super(); - this._register(this._authService.onDidAuthenticationChange(() => this._update())); - this._register(this._chatQuotaService.onDidChange(() => this._update())); - } - - /** - * Single entry point that determines the highest-priority notification - * to show (or whether to hide). - */ - private _update(): void { - const hasCopilotToken = !!this._authService.copilotToken; - const wasSignedIn = this._hadCopilotToken; - this._hadCopilotToken = hasCopilotToken; - - // Detect signed-in → signed-out transition: clear state and hide. - if (wasSignedIn && !hasCopilotToken) { - this._prevQuotaPercentUsed = undefined; - this._prevSessionPercentUsed = undefined; - this._prevWeeklyPercentUsed = undefined; - this._prevAdditionalUsageEnabled = undefined; - this._hideNotification(); - this._showingExhausted = false; - return; - } - - // Skip quota notifications for PRU users — only show for UBB. - const isQuotaNotificationEligible = !hasCopilotToken - || !!this._authService.copilotToken?.isUsageBasedBilling; - - // Priority 1: Quota exhausted or fully used — sticky info notification - if (isQuotaNotificationEligible && this._quotaUsedUp) { - const additionalUsageEnabled = this._chatQuotaService.additionalUsageEnabled; - const wasAdditionalUsageEnabled = this._prevAdditionalUsageEnabled; - this._prevAdditionalUsageEnabled = additionalUsageEnabled; - - if (additionalUsageEnabled) { - // Show overage notification on a live transition to 100%, - // or when overages are enabled while already at 100%. - if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { - this._showOverageActivationNotification(); - } - } else { - this._showExhaustedNotification(); - } - return; - } - - // Priority 2: Quota approaching threshold - if (isQuotaNotificationEligible) { - const quotaWarning = this._computeQuotaWarning(); - if (quotaWarning) { - this._fetchAndShowQuotaWarning(quotaWarning); - return; - } - } - - // Priority 3: Rate-limit warning (session > weekly) - const rateLimitWarning = this._computeRateLimitWarning(); - if (rateLimitWarning) { - this._showRateLimitWarning(rateLimitWarning); - return; - } - - // Nothing new to show — only hide if the exhausted notification is - // active and the quota is no longer exhausted (state-driven). - if (this._showingExhausted && !this._quotaUsedUp) { - this._hideNotification(); - } - } - - // --- Fetch and show quota warning ---------------------------------------- - - /** - * Fetches up-to-date quota data before showing a threshold notification, - * ensuring the displayed percentage reflects the latest server state. - */ - private async _fetchAndShowQuotaWarning(fallbackWarning: IQuotaWarning): Promise { - try { - await this._chatQuotaService.refreshQuota(); - // After the async refresh, quota may have become exhausted or - // fully used (a re-entrant _update() from onDidChange may have - // already shown the exhausted notification). - if (this._quotaUsedUp) { - return; - } - - const freshInfo = this._chatQuotaService.quotaInfo; - if (freshInfo && !freshInfo.unlimited) { - this._showQuotaApproachingWarning({ - percentUsed: Math.floor(100 - freshInfo.percentRemaining), - resetDate: freshInfo.resetDate, - }); - } else { - this._showQuotaApproachingWarning(fallbackWarning); - } - } catch { - this._showQuotaApproachingWarning(fallbackWarning); - } - } - - // --- Threshold crossing detection ---------------------------------------- - - private _computeQuotaWarning(): IQuotaWarning | undefined { - const info = this._chatQuotaService.quotaInfo; - if (!info || info.unlimited) { - this._prevQuotaPercentUsed = undefined; - return undefined; - } - const percentUsed = 100 - info.percentRemaining; - const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); - this._prevQuotaPercentUsed = percentUsed; - if (crossed !== undefined) { - return { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate }; - } - return undefined; - } - - private _computeRateLimitWarning(): IRateLimitWarning | undefined { - const { session, weekly } = this._chatQuotaService.rateLimitInfo; - - // Always update both prev values so neither becomes stale. - const sessionWarning = this._checkCrossing(session, this._prevSessionPercentUsed); - this._prevSessionPercentUsed = sessionWarning.newPrev; - - const weeklyWarning = this._checkCrossing(weekly, this._prevWeeklyPercentUsed); - this._prevWeeklyPercentUsed = weeklyWarning.newPrev; - - if (sessionWarning.warning) { - return { ...sessionWarning.warning, type: 'session' }; - } - if (weeklyWarning.warning) { - return { ...weeklyWarning.warning, type: 'weekly' }; - } - return undefined; - } - - private _checkCrossing( - info: IChatQuota | undefined, - prevPercentUsed: number | undefined, - ): { newPrev: number | undefined; warning?: { percentUsed: number; resetDate: Date } } { - if (!info || info.unlimited) { - return { newPrev: undefined }; - } - const percentUsed = 100 - info.percentRemaining; - const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed); - return { - newPrev: percentUsed, - warning: crossed !== undefined - ? { percentUsed: Math.floor(percentUsed), resetDate: info.resetDate } - : undefined, - }; - } - - /** - * Returns the highest threshold that was newly crossed, or `undefined`. - * A threshold is "crossed" when the previous value was below it and the - * current value is at or above it. When `previous` is `undefined` (first - * data arrival), no crossing is detected — only the baseline is stored. - */ - private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined { - if (previous === undefined) { - return undefined; - } - for (let i = THRESHOLDS.length - 1; i >= 0; i--) { - const threshold = THRESHOLDS[i]; - if (previous < threshold && current >= threshold) { - return threshold; - } - } - return undefined; - } - - // --- Quota exhausted --------------------------------------------------- - - private _showExhaustedNotification(): void { - const notification = this._ensureNotification(); - this._showingExhausted = true; - - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - notification.message = vscode.l10n.t('Credit Limit Reached'); - - const isAnonymous = !!this._authService.copilotToken?.isNoAuthUser; - const isFree = !!this._authService.copilotToken?.isFreeUser; - const isManagedPlan = !!this._authService.copilotToken?.isManagedPlan; - const quotaInfo = this._chatQuotaService.quotaInfo; - const hadOverage = quotaInfo ? quotaInfo.additionalUsageUsed > 0 : false; - - if (isAnonymous) { - notification.description = vscode.l10n.t('Sign in to keep going.'); - notification.actions = [ - { label: vscode.l10n.t('Sign In'), commandId: 'workbench.action.chat.triggerSetup' }, - ]; - } else if (isFree) { - notification.description = vscode.l10n.t('Upgrade to keep going.'); - notification.actions = [ - { label: vscode.l10n.t('Upgrade'), commandId: 'workbench.action.chat.upgradePlan' }, - ]; - } else if (isManagedPlan) { - notification.description = vscode.l10n.t('Contact your admin to increase your limits.'); - notification.actions = []; - } else if (hadOverage) { - notification.description = vscode.l10n.t('Increase your budget to keep building.'); - notification.actions = [ - { label: vscode.l10n.t('Manage Budget'), commandId: 'workbench.action.chat.manageAdditionalSpend' }, - ]; - } else { - notification.description = vscode.l10n.t('Manage your budget to keep building.'); - notification.actions = [ - { label: vscode.l10n.t('Manage Budget'), commandId: 'workbench.action.chat.manageAdditionalSpend' }, - ]; - } - - notification.show(); - } - - // --- Overage notification ----------------------------------------------- - - private _showOverageActivationNotification(): void { - const notification = this._ensureNotification(); - this._showingExhausted = true; - - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - notification.message = vscode.l10n.t('Credit Limit Reached'); - notification.description = vscode.l10n.t('Additional budget is now covering extra usage.'); - notification.actions = []; - - notification.show(); - } - - // --- Quota approaching -------------------------------------------------- - - private _showQuotaApproachingWarning(warning: IQuotaWarning): void { - const notification = this._ensureNotification(); - this._showingExhausted = false; - - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - notification.message = vscode.l10n.t('Credits at {0}%', warning.percentUsed); - - const isAnonymous = !!this._authService.copilotToken?.isNoAuthUser; - const isFree = !!this._authService.copilotToken?.isFreeUser; - const isManagedPlan = !!this._authService.copilotToken?.isManagedPlan; - - if (isAnonymous || isFree) { - notification.description = vscode.l10n.t('Upgrade to continue past the limit.'); - notification.actions = [ - { label: vscode.l10n.t('Upgrade'), commandId: 'workbench.action.chat.upgradePlan' }, - ]; - } else if (isManagedPlan) { - notification.description = vscode.l10n.t('Contact your admin to increase your limits.'); - notification.actions = []; - } else if (this._chatQuotaService.additionalUsageEnabled) { - notification.description = vscode.l10n.t('Additional budget is enabled to cover extra usage.'); - notification.actions = []; - } else { - notification.description = vscode.l10n.t('Set additional budget to cover extra usage.'); - notification.actions = [ - { label: vscode.l10n.t('Manage Budget'), commandId: 'workbench.action.chat.manageAdditionalSpend' }, - ]; - } - - notification.show(); - } - - // --- Rate limit warning ------------------------------------------------- - - private _showRateLimitWarning(warning: IRateLimitWarning): void { - const notification = this._ensureNotification(); - this._showingExhausted = false; - - const dateStr = this._formatResetDate(warning.resetDate); - notification.severity = vscode.ChatInputNotificationSeverity.Info; - notification.dismissible = true; - notification.autoDismissOnMessage = true; - - notification.message = warning.type === 'session' - ? vscode.l10n.t("You've used {0}% of your session rate limit.", warning.percentUsed) - : vscode.l10n.t("You've used {0}% of your weekly rate limit.", warning.percentUsed); - notification.description = vscode.l10n.t('Resets on {0}.', dateStr); - notification.actions = []; - - notification.show(); - } - - // --- Helpers ------------------------------------------------------------ - - private _formatResetDate(resetDate: Date): string { - const now = new Date(); - const includeYear = resetDate.getFullYear() !== now.getFullYear(); - return new Intl.DateTimeFormat(undefined, includeYear - ? { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' } - : { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' } - ).format(resetDate); - } - - private _ensureNotification(): vscode.ChatInputNotification { - if (!this._notification) { - this._notification = vscode.chat.createInputNotification(QUOTA_NOTIFICATION_ID); - this._register({ dispose: () => this._notification?.dispose() }); - } - return this._notification; - } - - private _hideNotification(): void { - if (this._notification) { - this._notification.hide(); - } - } -} diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts deleted file mode 100644 index 034a3ff4e186a..0000000000000 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/chatInputNotification.contribution.spec.ts +++ /dev/null @@ -1,733 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { Emitter } from '../../../../util/vs/base/common/event'; -import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; -import { IChatQuota, IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService'; - -// ---- vscode mock ----------------------------------------------------------- - -const mockNotification = { - severity: 0, - dismissible: false, - autoDismissOnMessage: false, - message: '', - description: '', - actions: [] as { label: string; commandId: string }[], - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), -}; - -vi.mock('vscode', () => ({ - ChatInputNotificationSeverity: { Info: 1 }, - chat: { - createInputNotification: vi.fn(() => mockNotification), - }, - l10n: { t: (str: string, ...args: unknown[]) => str.replace(/\{(\d+)\}/g, (_, i) => String(args[Number(i)])) }, -})); - -import { ChatInputNotificationContribution } from '../chatInputNotification.contribution'; - -// ---- helpers --------------------------------------------------------------- - -function createAuthService(opts?: { anyGitHubSession?: unknown; copilotToken?: unknown }) { - const emitter = new Emitter(); - const hasSession = opts && 'anyGitHubSession' in opts; - const hasToken = opts && 'copilotToken' in opts; - const authService = { - _serviceBrand: undefined, - anyGitHubSession: hasSession ? opts.anyGitHubSession : { accessToken: 'tok' }, - copilotToken: hasToken ? opts.copilotToken : { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }, - onDidAuthenticationChange: emitter.event, - } as unknown as IAuthenticationService; - return { authService, emitter }; -} - -function makeQuota(percentRemaining: number, opts?: Partial): IChatQuota { - return { - quota: 100, - percentRemaining, - unlimited: false, - hasQuota: true, - additionalUsageUsed: 0, - additionalUsageEnabled: false, - resetDate: new Date('2026-06-01T00:00:00Z'), - ...opts, - }; -} - -function createQuotaService(opts?: { - quotaExhausted?: boolean; - quotaInfo?: IChatQuota; - session?: IChatQuota; - weekly?: IChatQuota; - additionalUsageEnabled?: boolean; -}) { - const emitter = new Emitter(); - const quotaService = { - _serviceBrand: undefined, - onDidChange: emitter.event, - quotaExhausted: opts?.quotaExhausted ?? false, - quotaInfo: opts?.quotaInfo, - rateLimitInfo: { session: opts?.session, weekly: opts?.weekly }, - additionalUsageEnabled: opts?.additionalUsageEnabled ?? false, - getCreditsForTurn: () => undefined, - processQuotaHeaders: vi.fn(), - processQuotaSnapshots: vi.fn(), - setLastCopilotUsage: vi.fn(), - resetTurnCredits: vi.fn(), - clearQuota: vi.fn(), - refreshQuota: vi.fn().mockResolvedValue(undefined), - } as unknown as IChatQuotaService; - return { quotaService, emitter }; -} - -// ---- tests ----------------------------------------------------------------- - -describe('ChatInputNotificationContribution', () => { - let authEmitter: Emitter; - let authService: IAuthenticationService; - let quotaEmitter: Emitter; - let quotaService: IChatQuotaService; - let contribution: ChatInputNotificationContribution; - - function setup(authOpts?: Parameters[0], quotaOpts?: Parameters[0]) { - const auth = createAuthService(authOpts); - const quota = createQuotaService(quotaOpts); - authEmitter = auth.emitter; - authService = auth.authService; - quotaEmitter = quota.emitter; - quotaService = quota.quotaService; - contribution = new ChatInputNotificationContribution(authService, quotaService); - } - - beforeEach(() => { - vi.clearAllMocks(); - mockNotification.show.mockClear(); - mockNotification.hide.mockClear(); - mockNotification.message = ''; - mockNotification.description = ''; - mockNotification.actions = []; - }); - - afterEach(() => { - contribution?.dispose(); - }); - - // --- sign-out behaviour -------------------------------------------------- - - describe('sign-out clears state and hides notification', () => { - test('hides notification when copilot token disappears (sign out)', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - // Trigger _update with exhausted quota → shows notification - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - - // User signs out — copilot token cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - expect(mockNotification.hide).toHaveBeenCalled(); - }); - - test('shows newly crossed threshold after sign-out + sign-in', async () => { - setup({}, { quotaInfo: makeQuota(60) }); // 40% used — baseline - - // Establish baseline - quotaEmitter.fire(); - - // Cross 50% threshold → notification shown - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalledTimes(1); - mockNotification.show.mockClear(); - - // Sign out → prev values cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign back in — quota still at 50% → baseline stored, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage increases past 75% → new threshold fires - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('sign-out resets showingExhausted flag', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - mockNotification.show.mockClear(); - - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign back in, quota no longer exhausted - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = undefined; - (quotaService as any).rateLimitInfo = { session: undefined, weekly: undefined }; - authEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('sign-out while no notification was active is harmless', () => { - setup(); - - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - expect(mockNotification.hide).not.toHaveBeenCalled(); - }); - - test('anonymous UBB user with no GitHub session still sees quota notifications', () => { - setup( - { anyGitHubSession: undefined, copilotToken: { isNoAuthUser: true, isFreeUser: false, isUsageBasedBilling: true } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - expect(mockNotification.description).toBe('Sign in to keep going.'); - }); - - test('anonymous PRU user does not see quota notifications', () => { - setup( - { anyGitHubSession: undefined, copilotToken: { isNoAuthUser: true, isFreeUser: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - }); - - // --- threshold crossing (window reload / sign-in) ------------------------ - - describe('threshold crossing on reload and sign-in', () => { - test('first data arrival stores baseline without notification', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(25) }, // 75% used — already above 50% and 75% - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('notifies when crossing a new threshold after baseline', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(40) }, // 60% used — baseline - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage crosses 75% - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('first rate limit data stores baseline without notification', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { session: makeQuota(10) }, // 90% session used - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('notifies when crossing a threshold from below', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — below all thresholds - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - (quotaService as any).quotaInfo = makeQuota(50); // 50% used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 50%'); - }); - - test('sign-out clears baseline so next sign-in re-establishes it', () => { - setup( - {}, - { quotaInfo: makeQuota(25) }, // 75% used - ); - - // Establish baseline - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Sign out → prev values cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign back in — first data stores new baseline, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('late sign-in stores baseline then fires on new crossing', async () => { - setup({ copilotToken: undefined }, {}); - - // Sign in — quota data arrives at 60% - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(40); // 60% used - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage crosses 75% → notification fires - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('not signed in → 0% → sign out → 60% does not fire 50% threshold', async () => { - setup({ copilotToken: undefined }, {}); - - // Sign in at 0% - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(100); // 0% used - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Sign out → prev cleared - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign in at 60% — baseline stored, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(40); // 60% used - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Usage crosses 75% → notification fires - (quotaService as any).quotaInfo = makeQuota(25); - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 75%'); - }); - - test('sign-out + sign-in at higher level does not fire stale crossing', () => { - setup( - {}, - { quotaInfo: makeQuota(60) }, // 40% used - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Sign out - (authService as any).copilotToken = undefined; - authEmitter.fire(); - - // Sign into different account at 75% — baseline stored, no notification - (authService as any).copilotToken = { isFreeUser: false, isNoAuthUser: false, isUsageBasedBilling: true }; - (quotaService as any).quotaInfo = makeQuota(25); // 75% - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - }); - - // --- basic notification lifecycle ---------------------------------------- - - describe('quota exhausted', () => { - test('shows exhausted notification', () => { - setup( - { copilotToken: { isFreeUser: true, isNoAuthUser: false, isUsageBasedBilling: true } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - - test('hides exhausted when quota is no longer exhausted', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - - expect(mockNotification.hide).toHaveBeenCalled(); - }); - }); - - describe('quota approaching threshold', () => { - test('shows warning when crossing 50% threshold', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — baseline - ); - - quotaEmitter.fire(); - - (quotaService as any).quotaInfo = makeQuota(50); // 50% used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 50%'); - }); - - test('does not re-show the same threshold', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalledTimes(1); - - mockNotification.show.mockClear(); - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('shows higher threshold when usage increases', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60) }, // 40% used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).quotaInfo = makeQuota(50); // 50% used - quotaEmitter.fire(); - await Promise.resolve(); - expect(mockNotification.show).toHaveBeenCalledTimes(1); - - mockNotification.show.mockClear(); - (quotaService as any).quotaInfo = makeQuota(10); // 90% used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credits at 90%'); - }); - }); - - describe('rate limit warning', () => { - test('shows session rate limit warning', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { session: makeQuota(60) }, // 40% session used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% used - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toContain('75%'); - expect(mockNotification.message).toContain('session'); - }); - - test('shows weekly rate limit warning', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { weekly: makeQuota(60) }, // 40% weekly used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).rateLimitInfo = { session: undefined, weekly: makeQuota(10) }; // 90% used - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toContain('90%'); - expect(mockNotification.message).toContain('weekly'); - }); - }); - - describe('priority ordering', () => { - test('exhausted takes priority over threshold warning', () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - - test('threshold warning takes priority over rate limit', async () => { - setup( - { anyGitHubSession: { accessToken: 'tok' } }, - { quotaInfo: makeQuota(60), session: makeQuota(60) }, // 40% used — baselines - ); - - quotaEmitter.fire(); - (quotaService as any).quotaInfo = makeQuota(10); // 90% quota used - (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% session used - quotaEmitter.fire(); - await Promise.resolve(); - - expect(mockNotification.message).toBe('Credits at 90%'); - }); - }); - - describe('never-signed-in user still gets notifications', () => { - test('shows exhausted notification even with no copilot token initially', () => { - setup( - { copilotToken: undefined }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - }); - - describe('PRU users do not see quota notifications', () => { - test('does not show exhausted notification for individual PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('does not show exhausted notification for free PRU user', () => { - setup( - { copilotToken: { isFreeUser: true, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('does not show exhausted notification for managed plan PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: true, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('does not show approaching notification for PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { quotaInfo: makeQuota(5) }, // 95% used - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('still shows rate limit warning for PRU user', () => { - setup( - { copilotToken: { isFreeUser: false, isNoAuthUser: false, isManagedPlan: false, isUsageBasedBilling: false } }, - { session: makeQuota(60) }, // 40% session used — baseline - ); - - quotaEmitter.fire(); - (quotaService as any).rateLimitInfo = { session: makeQuota(25), weekly: undefined }; // 75% used - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toContain('session'); - }); - }); - - // --- quota used up (percentRemaining <= 0) -------------------------------- - - describe('quota fully used (percentRemaining <= 0)', () => { - test('shows exhausted notification when percentRemaining hits 0', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - - test('hides exhausted notification when percentRemaining recovers above 0', () => { - setup( - {}, - { quotaInfo: makeQuota(0) }, - ); - - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - - (quotaService as any).quotaInfo = makeQuota(50); - quotaEmitter.fire(); - - expect(mockNotification.hide).toHaveBeenCalled(); - }); - - test('does not show exhausted for unlimited quota at 0 percentRemaining', () => { - setup( - {}, - { quotaInfo: makeQuota(0, { unlimited: true, hasQuota: true }) }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - }); - - // --- overage activation notification ------------------------------------ - - describe('overage activation notification', () => { - test('shows overage notification on live transition to 100%', () => { - setup( - {}, - { quotaInfo: makeQuota(10), additionalUsageEnabled: true }, // 90% used — baseline - ); - - quotaEmitter.fire(); - expect(mockNotification.show).not.toHaveBeenCalled(); - - // Cross to 100% - (quotaService as any).quotaInfo = makeQuota(0); - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - expect(mockNotification.description).toBe('Additional budget is now covering extra usage.'); - }); - - test('does not show overage notification on reload when already at 100%', () => { - setup( - {}, - { quotaInfo: makeQuota(0), additionalUsageEnabled: true }, - ); - - // First data arrival at 100% — baseline, no notification - quotaEmitter.fire(); - - expect(mockNotification.show).not.toHaveBeenCalled(); - }); - - test('shows standard exhausted notification on reload at 100% without overages', () => { - setup( - {}, - { quotaInfo: makeQuota(0), additionalUsageEnabled: false }, - ); - - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - expect(mockNotification.description).not.toBe('Additional budget is now covering extra usage.'); - }); - - test('shows overage notification when overages are enabled while already at 100%', () => { - setup( - {}, - { quotaInfo: makeQuota(0), additionalUsageEnabled: false }, - ); - - // First update: exhausted without overages - quotaEmitter.fire(); - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.message).toBe('Credit Limit Reached'); - mockNotification.show.mockClear(); - - // User enables overages in settings — next API response updates state - (quotaService as any).additionalUsageEnabled = true; - quotaEmitter.fire(); - - expect(mockNotification.show).toHaveBeenCalled(); - expect(mockNotification.description).toBe('Additional budget is now covering extra usage.'); - }); - }); - - // --- _fetchAndShowQuotaWarning race guard -------------------------------- - - describe('fetchAndShowQuotaWarning race guard', () => { - test('does not overwrite exhausted notification after refreshQuota', async () => { - setup( - {}, - { quotaInfo: makeQuota(10) }, // 90% used — baseline - ); - - quotaEmitter.fire(); - - // refreshQuota will make quota exhausted during the await - (quotaService as any).refreshQuota = vi.fn(async () => { - (quotaService as any).quotaInfo = makeQuota(0); - // Simulate the re-entrant _update from onDidChange - quotaEmitter.fire(); - }); - - // Cross 95% → triggers _fetchAndShowQuotaWarning - (quotaService as any).quotaInfo = makeQuota(5); - quotaEmitter.fire(); - await Promise.resolve(); - - // The exhausted notification from the re-entrant _update should win - expect(mockNotification.message).toBe('Credit Limit Reached'); - }); - }); -}); diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 0c4579083e3c2..57d18656c6bbf 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -18,7 +18,6 @@ import { CompletionsUnificationContribution } from '../../completions/vscode-nod import { ConfigurationMigrationContribution } from '../../configuration/vscode-node/configurationMigration'; import { ContextKeysContribution } from '../../contextKeys/vscode-node/contextKeys.contribution'; import { ByokUtilityModelNotificationContribution } from '../../chatInputNotification/vscode-node/byokUtilityModel.contribution'; -import { ChatInputNotificationContribution } from '../../chatInputNotification/vscode-node/chatInputNotification.contribution'; import { AiMappedEditsContrib } from '../../conversation/vscode-node/aiMappedEditsContrib'; import { ConversationFeature } from '../../conversation/vscode-node/conversationFeature'; import { FeedbackCommandContribution } from '../../conversation/vscode-node/feedbackContribution'; @@ -76,7 +75,6 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(FetcherTelemetryContribution), asContributionFactory(PowerStateLogger), asContributionFactory(ContextKeysContribution), - asContributionFactory(ChatInputNotificationContribution), asContributionFactory(ByokUtilityModelNotificationContribution), asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index dca451ee09325..1c17b756a905e 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -2024,6 +2024,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { return { type: ChatFetchResponseType.RateLimited, reason, requestId, serverRequestId, retryAfter: response.data?.retryAfter, rateLimitKey: (response.data?.rateLimitKey || ''), isAuto, capiError: response.data?.capiError }; } if (response.failKind === ChatFailKind.QuotaExceeded) { + // Refresh quota state so the ext→core sync picks up the exhaustion + this._chatQuotaService.refreshQuota(); return { type: ChatFetchResponseType.QuotaExceeded, reason, requestId, serverRequestId, retryAfter: response.data?.retryAfter, capiError: response.data?.capiError }; } if (response.failKind === ChatFailKind.OffTopic) { @@ -2217,6 +2219,8 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { return { type: ChatFetchResponseType.RateLimited, reason: message, requestId, serverRequestId, retryAfter: undefined, rateLimitKey: '', isAuto, capiError }; } if (codePrefix === 'quota_exceeded' || codePrefix === 'free_quota_exceeded' || codePrefix === 'overage_limit_reached' || codePrefix === 'billing_not_configured') { + // Refresh quota state so the ext→core sync picks up the exhaustion + this._chatQuotaService.refreshQuota(); return { type: ChatFetchResponseType.QuotaExceeded, reason: message, requestId, serverRequestId, capiError, retryAfter: undefined }; } if (code === 'content_filter') { diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index ea004302999f8..cc1c5e1812d42 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -109,6 +109,8 @@ class MockChatEntitlementService implements IChatEntitlementService { readonly anonymous = false; readonly anonymousObs: IObservable = observableValue('anonymous', false); + acceptQuotas(): void { } + clearQuotas(): void { } markAnonymousRateLimited(): void { } markSetupCompleted(): void { } setForceHidden(_hidden: boolean): void { } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index c5bc15deaf9e1..e3c082b0ff46e 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -96,6 +96,7 @@ import './mainThreadMcp.js'; import './mainThreadChatContext.js'; import './mainThreadChatDebug.js'; import './mainThreadChatStatus.js'; +import './mainThreadChatQuota.js'; import './mainThreadChatInputNotification.js'; import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatQuota.ts b/src/vs/workbench/api/browser/mainThreadChatQuota.ts new file mode 100644 index 0000000000000..e9a49eccccbaf --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatQuota.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IChatEntitlementService } from '../../services/chat/common/chatEntitlementService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { IQuotaSnapshotsDto, MainContext, MainThreadChatQuotaShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadChatQuota) +export class MainThreadChatQuota extends Disposable implements MainThreadChatQuotaShape { + + constructor( + extHostContext: IExtHostContext, + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + ) { + super(); + } + + $updateQuotas(quotas: IQuotaSnapshotsDto): void { + this._chatEntitlementService.acceptQuotas({ ...this._chatEntitlementService.quotas, ...quotas }); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 7420337083012..a4ce4c5b0e43b 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -41,6 +41,7 @@ import { ExtHostChatAgents2 } from './extHostChatAgents2.js'; import { ExtHostChatOutputRenderer } from './extHostChatOutputRenderer.js'; import { ExtHostChatSessions } from './extHostChatSessions.js'; import { ExtHostChatStatus } from './extHostChatStatus.js'; +import { ExtHostChatQuota } from './extHostChatQuota.js'; import { ExtHostChatInputNotification } from './extHostChatInputNotification.js'; import { ExtHostClipboard } from './extHostClipboard.js'; import { ExtHostEditorInsets } from './extHostCodeInsets.js'; @@ -251,6 +252,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostSpeech = rpcProtocol.set(ExtHostContext.ExtHostSpeech, new ExtHostSpeech(rpcProtocol)); const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); const extHostBrowsers = rpcProtocol.set(ExtHostContext.ExtHostBrowsers, new ExtHostBrowsers(rpcProtocol)); + const extHostChatQuota = rpcProtocol.set(ExtHostContext.ExtHostChatQuota, new ExtHostChatQuota(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); @@ -1709,6 +1711,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, + updateQuotas: (quotas: vscode.ChatQuotaSnapshots) => { + checkProposedApiEnabled(extension, 'chatParticipantPrivate'); + extHostChatQuota.updateQuotas(quotas); + }, registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); extHostApiDeprecation.report('chat.registerChatSessionItemProvider', extension, `Please migrate to the new chat session controller API`, { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 5d3895b7706d0..18b9d89ac0611 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2731,6 +2731,36 @@ export interface IChatUsageDto { promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]; } +export interface IQuotaSnapshotDto { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly hasQuota?: boolean; + readonly resetAt?: number; + readonly usageBasedBilling?: boolean; + readonly entitlement?: number; + readonly quotaRemaining?: number; +} + +export interface IRateLimitSnapshotDto { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; +} + +export interface IQuotaSnapshotsDto { + readonly resetDate?: string; + readonly resetDateHasTime?: boolean; + readonly usageBasedBilling?: boolean; + readonly canUpgradePlan?: boolean; + readonly chat?: IQuotaSnapshotDto; + readonly completions?: IQuotaSnapshotDto; + readonly premiumChat?: IQuotaSnapshotDto; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly sessionRateLimit?: IRateLimitSnapshotDto; + readonly weeklyRateLimit?: IRateLimitSnapshotDto; +} + export type ICellEditOperationDto = notebookCommon.ICellMetadataEdit | notebookCommon.IDocumentMetadataEdit @@ -3742,6 +3772,13 @@ export interface MainThreadChatStatusShape { $disposeEntry(id: string): void; } +export interface MainThreadChatQuotaShape extends IDisposable { + $updateQuotas(quotas: IQuotaSnapshotsDto): void; +} + +export interface ExtHostChatQuotaShape { +} + export const enum ChatInputNotificationSeverityDto { Info = 0, Warning = 1, @@ -4012,6 +4049,7 @@ export const MainContext = { MainThreadAiRelatedInformation: createProxyIdentifier('MainThreadAiRelatedInformation'), MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector'), MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), + MainThreadChatQuota: createProxyIdentifier('MainThreadChatQuota'), MainThreadChatInputNotification: createProxyIdentifier('MainThreadChatInputNotification'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), @@ -4099,6 +4137,7 @@ export const ExtHostContext = { ExtHostMcp: createProxyIdentifier('ExtHostMcp'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), + ExtHostChatQuota: createProxyIdentifier('ExtHostChatQuota'), ExtHostGitExtension: createProxyIdentifier('ExtHostGitExtension'), ExtHostBrowsers: createProxyIdentifier('ExtHostBrowsers'), }; diff --git a/src/vs/workbench/api/common/extHostChatQuota.ts b/src/vs/workbench/api/common/extHostChatQuota.ts new file mode 100644 index 0000000000000..38e037ada815c --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatQuota.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ExtHostChatQuotaShape, IMainContext, IQuotaSnapshotsDto, MainContext, MainThreadChatQuotaShape } from './extHost.protocol.js'; + +export class ExtHostChatQuota extends Disposable implements ExtHostChatQuotaShape { + + private readonly _proxy: MainThreadChatQuotaShape; + + constructor( + mainContext: IMainContext, + ) { + super(); + this._proxy = mainContext.getProxy(MainContext.MainThreadChatQuota); + } + + updateQuotas(quotas: IQuotaSnapshotsDto): void { + this._proxy.$updateQuotas(quotas); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 41e637987d958..c1b8b0e64744d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -147,6 +147,7 @@ import { QuickChatService } from './widgetHosts/chatQuick.js'; import { ChatResponseAccessibleView } from './accessibility/chatResponseAccessibleView.js'; import { ChatTerminalOutputAccessibleView } from './accessibility/chatTerminalOutputAccessibleView.js'; import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup/chatSetupContributions.js'; +import { ChatQuotaNotificationContribution } from './chatQuotaNotification.js'; import { HasByokModelsContribution } from './hasByokModelsContribution.js'; import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; import { ChatVariablesService } from './attachments/chatVariables.js'; @@ -2261,6 +2262,7 @@ registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitC registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatQuotaNotificationContribution.ID, ChatQuotaNotificationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(HasByokModelsContribution.ID, HasByokModelsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts new file mode 100644 index 0000000000000..82812e8ca7fb0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { safeIntl } from '../../../../base/common/date.js'; +import { localize } from '../../../../nls.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { ChatEntitlement, IChatEntitlementService, IQuotaSnapshot, IRateLimitSnapshot } from '../../../services/chat/common/chatEntitlementService.js'; +import { getSelectedModelVendor, SELECTED_MODEL_STORAGE_KEY_PREFIX } from '../common/chatSelectedModel.js'; +import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../common/languageModels.js'; +import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationService } from './widget/input/chatInputNotificationService.js'; + +const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; +const THRESHOLDS = [50, 75, 90, 95]; + +/** + * Core-side workbench contribution that shows chat input notifications for + * quota exhaustion and quota-approaching thresholds. + * + * Listens to `IChatEntitlementService` quota change events and determines + * whether a new threshold has been crossed, then shows the highest-priority + * notification: + * + * 1. **Quota exhausted** — info, auto-dismissed on next message. + * 2. **Quota approaching** — info, auto-dismissed on next message. + * 3. **Rate-limit warning** — info, auto-dismissed on next message. + */ +export class ChatQuotaNotificationContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatQuotaNotification'; + + /** Tracks whether the current notification is the quota-exhausted variant. */ + private _showingExhausted = false; + + /** + * Previous percent-used for threshold crossing detection. + * `undefined` means no data has been seen yet — the first value + * establishes a baseline without triggering a notification. + */ + private _prevQuotaPercentUsed: number | undefined; + private _prevAdditionalUsageEnabled: boolean | undefined; + private _prevSessionPercentUsed: number | undefined; + private _prevWeeklyPercentUsed: number | undefined; + + constructor( + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + this._register(this._chatEntitlementService.onDidChangeQuotaRemaining(() => this._update())); + this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); + this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); + + // Re-evaluate when the selected model changes (e.g. switching between Copilot and BYOK). + // The chatModelId context key is widget-scoped and may not bubble to the global + // service, so we also listen for storage changes on the persisted model selection key. + const storageListener = this._register(new DisposableStore()); + this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, undefined, storageListener)(e => { + if (e.key.startsWith(SELECTED_MODEL_STORAGE_KEY_PREFIX)) { + this._update(); + } + })); + + // Check initial state in case quota is already exhausted at startup + this._update(); + } + + private _getRelevantSnapshot(): IQuotaSnapshot | undefined { + const quotas = this._chatEntitlementService.quotas; + const entitlement = this._chatEntitlementService.entitlement; + if (entitlement === ChatEntitlement.Unknown || entitlement === ChatEntitlement.Free) { + return quotas.chat ?? quotas.premiumChat; + } + return quotas.premiumChat; + } + + private _isQuotaUsedUp(): boolean { + const snapshot = this._getRelevantSnapshot(); + if (!snapshot) { + return false; + } + if (snapshot.unlimited) { + return snapshot.hasQuota === false; + } + return snapshot.percentRemaining <= 0; + } + + private _isUBBEligible(): boolean { + return this._chatEntitlementService.quotas.usageBasedBilling === true; + } + + private _update(): void { + const entitlement = this._chatEntitlementService.entitlement; + const isCopilot = this._isCopilotModelSelected(); + + // Defer new notifications when a BYOK model is selected or the model + // selection hasn't loaded yet — quota only applies to Copilot models. + // Already-shown notifications stay visible. + if (!isCopilot) { + return; + } + + // Skip quota notifications for PRU users — only show for UBB. + const isQuotaNotificationEligible = entitlement === ChatEntitlement.Unknown || this._isUBBEligible(); + + // Priority 1: Quota exhausted or fully used + if (isQuotaNotificationEligible && this._isQuotaUsedUp()) { + const quotas = this._chatEntitlementService.quotas; + const additionalUsageEnabled = quotas.additionalUsageEnabled ?? false; + const wasAdditionalUsageEnabled = this._prevAdditionalUsageEnabled; + this._prevAdditionalUsageEnabled = additionalUsageEnabled; + + if (additionalUsageEnabled) { + // Show overage notification on a live transition to 100%, + // or when overages are enabled while already at 100%. + if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { + this._showOverageActivationNotification(); + } + } else { + this._showExhaustedNotification(); + } + + // Keep the baseline up-to-date so that recovery from exhaustion + // does not trigger a spurious threshold notification. + const exhaustedSnapshot = this._getRelevantSnapshot(); + if (exhaustedSnapshot && !exhaustedSnapshot.unlimited) { + this._prevQuotaPercentUsed = 100 - exhaustedSnapshot.percentRemaining; + } + + return; + } + + // Priority 2: Quota approaching threshold + if (isQuotaNotificationEligible) { + const quotaWarning = this._computeQuotaWarning(); + if (quotaWarning) { + this._showQuotaApproachingWarning(quotaWarning); + return; + } + } + + // Priority 3: Rate-limit warning (session > weekly) + const rateLimitWarning = this._computeRateLimitWarning(); + if (rateLimitWarning) { + this._showRateLimitWarning(rateLimitWarning); + return; + } + + // Nothing new to show — only hide if the exhausted notification is + // active and the quota is no longer exhausted (state-driven). + if (this._showingExhausted && !this._isQuotaUsedUp()) { + this._hideNotification(); + } + } + + // --- Threshold crossing detection ---------------------------------------- + + private _computeQuotaWarning(): { percentUsed: number } | undefined { + const snapshot = this._getRelevantSnapshot(); + if (!snapshot || snapshot.unlimited) { + this._prevQuotaPercentUsed = undefined; + return undefined; + } + const percentUsed = 100 - snapshot.percentRemaining; + const crossed = this._findCrossedThreshold(percentUsed, this._prevQuotaPercentUsed); + this._prevQuotaPercentUsed = percentUsed; + if (crossed !== undefined) { + return { percentUsed: Math.floor(percentUsed) }; + } + return undefined; + } + + /** + * Returns the highest threshold that was newly crossed, or `undefined`. + */ + private _findCrossedThreshold(current: number, previous: number | undefined): number | undefined { + if (previous === undefined) { + return undefined; + } + for (let i = THRESHOLDS.length - 1; i >= 0; i--) { + const threshold = THRESHOLDS[i]; + if (previous < threshold && current >= threshold) { + return threshold; + } + } + return undefined; + } + + // --- Quota exhausted --------------------------------------------------- + + private _showExhaustedNotification(): void { + this._showingExhausted = true; + + const entitlement = this._chatEntitlementService.entitlement; + const quotas = this._chatEntitlementService.quotas; + const hadOverage = (quotas.additionalUsageCount ?? 0) > 0; + + let description: string; + let actions: IChatInputNotification['actions']; + + if (entitlement === ChatEntitlement.Unknown) { + description = localize('quota.exhausted.anonymous', "Sign in to keep going."); + actions = [{ label: localize('signIn', "Sign In"), commandId: 'workbench.action.chat.triggerSetup' }]; + } else if (entitlement === ChatEntitlement.Free) { + description = localize('quota.exhausted.free', "Upgrade to keep going."); + actions = [{ label: localize('upgrade', "Upgrade"), commandId: 'workbench.action.chat.upgradePlan' }]; + } else if (this._isManagedPlan(entitlement)) { + description = localize('quota.exhausted.managed', "Contact your admin to increase your limits."); + actions = []; + } else if (hadOverage) { + description = localize('quota.exhausted.hadOverage', "Increase your budget to keep building."); + actions = [{ label: localize('manageBudget', "Manage Budget"), commandId: 'workbench.action.chat.manageAdditionalSpend' }]; + } else { + description = localize('quota.exhausted.default', "Manage your budget to keep building."); + actions = [{ label: localize('manageBudget2', "Manage Budget"), commandId: 'workbench.action.chat.manageAdditionalSpend' }]; + } + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.exhausted.title', "Credit Limit Reached"), + description, + actions, + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Overage notification ----------------------------------------------- + + private _showOverageActivationNotification(): void { + this._showingExhausted = true; + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.overage.title', "Credit Limit Reached"), + description: localize('quota.overage.desc', "Additional budget is now covering extra usage."), + actions: [], + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Quota approaching -------------------------------------------------- + + private _showQuotaApproachingWarning(warning: { percentUsed: number }): void { + this._showingExhausted = false; + + const entitlement = this._chatEntitlementService.entitlement; + const quotas = this._chatEntitlementService.quotas; + + let description: string; + let actions: IChatInputNotification['actions']; + + if (entitlement === ChatEntitlement.Unknown || entitlement === ChatEntitlement.Free) { + description = localize('quota.approaching.free', "Upgrade to continue past the limit."); + actions = [{ label: localize('upgrade2', "Upgrade"), commandId: 'workbench.action.chat.upgradePlan' }]; + } else if (this._isManagedPlan(entitlement)) { + description = localize('quota.approaching.managed', "Contact your admin to increase your limits."); + actions = []; + } else if (quotas.additionalUsageEnabled) { + description = localize('quota.approaching.overageEnabled', "Additional budget is enabled to cover extra usage."); + actions = []; + } else { + description = localize('quota.approaching.default', "Set additional budget to cover extra usage."); + actions = [{ label: localize('manageBudget3', "Manage Budget"), commandId: 'workbench.action.chat.manageAdditionalSpend' }]; + } + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message: localize('quota.approaching.title', "Credits at {0}%", warning.percentUsed), + description, + actions, + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Rate-limit warning ------------------------------------------------- + + private _computeRateLimitWarning(): { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined } | undefined { + const quotas = this._chatEntitlementService.quotas; + + const sessionResult = this._checkRateLimitCrossing(quotas.sessionRateLimit, this._prevSessionPercentUsed); + this._prevSessionPercentUsed = sessionResult.newPrev; + + const weeklyResult = this._checkRateLimitCrossing(quotas.weeklyRateLimit, this._prevWeeklyPercentUsed); + this._prevWeeklyPercentUsed = weeklyResult.newPrev; + + if (sessionResult.warning) { + return { ...sessionResult.warning, type: 'session' }; + } + if (weeklyResult.warning) { + return { ...weeklyResult.warning, type: 'weekly' }; + } + return undefined; + } + + private _checkRateLimitCrossing( + snapshot: IRateLimitSnapshot | undefined, + prevPercentUsed: number | undefined, + ): { newPrev: number | undefined; warning?: { percentUsed: number; resetDate: string | undefined } } { + if (!snapshot || snapshot.unlimited) { + return { newPrev: undefined }; + } + const percentUsed = 100 - snapshot.percentRemaining; + const crossed = this._findCrossedThreshold(percentUsed, prevPercentUsed); + return { + newPrev: percentUsed, + warning: crossed !== undefined + ? { percentUsed: Math.floor(percentUsed), resetDate: snapshot.resetDate } + : undefined, + }; + } + + private _showRateLimitWarning(warning: { percentUsed: number; type: 'session' | 'weekly'; resetDate: string | undefined }): void { + this._showingExhausted = false; + + const message = warning.type === 'session' + ? localize('rateLimit.session', "You've used {0}% of your session rate limit.", warning.percentUsed) + : localize('rateLimit.weekly', "You've used {0}% of your weekly rate limit.", warning.percentUsed); + + const description = warning.resetDate + ? localize('rateLimit.resets', "Resets on {0}.", this._formatResetDate(warning.resetDate)) + : undefined; + + this._setNotification({ + id: QUOTA_NOTIFICATION_ID, + severity: ChatInputNotificationSeverity.Info, + message, + description, + actions: [], + dismissible: true, + autoDismissOnMessage: true, + }); + } + + // --- Helpers ------------------------------------------------------------ + + /** + * Returns `true` only when a Copilot model is actively selected. + * Returns `false` if no model is selected yet (widget not initialized) + * or if the selected model is from a non-Copilot vendor (BYOK). + */ + private _isCopilotModelSelected(): boolean { + const vendor = getSelectedModelVendor(this._contextKeyService, this._storageService, this._languageModelsService); + if (!vendor) { + return true; + } + return vendor === COPILOT_VENDOR_ID; + } + + private _isManagedPlan(entitlement: ChatEntitlement): boolean { + return entitlement === ChatEntitlement.Business || entitlement === ChatEntitlement.Enterprise; + } + + private _formatResetDate(isoDate: string): string { + const resetDate = new Date(isoDate); + const now = new Date(); + const includeYear = resetDate.getFullYear() !== now.getFullYear(); + return safeIntl.DateTimeFormat(undefined, includeYear + ? { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' } + : { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' } + ).value.format(resetDate); + } + + private _setNotification(notification: IChatInputNotification): void { + this._chatInputNotificationService.setNotification(notification); + } + + private _hideNotification(): void { + this._showingExhausted = false; + this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 5ad2fe60724e3..889ae29b56490 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -9,6 +9,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { getSelectedModelIdentifier } from '../common/chatSelectedModel.js'; import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; @@ -788,26 +789,7 @@ export class ChatTipService extends Disposable implements IChatTipService { return normalizedModelId; }; - const contextKeyModelId = normalize(contextKeyService.getContextKeyValue(ChatContextKeys.chatModelId.key)); - if (contextKeyModelId) { - return contextKeyModelId; - } - - const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key) ?? ChatAgentLocation.Chat; - const sessionType = contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key) ?? ''; - const candidateStorageKeys = sessionType - ? [`chat.currentLanguageModel.${location}.${sessionType}`, `chat.currentLanguageModel.${location}`] - : [`chat.currentLanguageModel.${location}`]; - - for (const storageKey of candidateStorageKeys) { - const persistedModelIdentifier = this._storageService.get(storageKey, StorageScope.APPLICATION); - const persistedModelId = normalize(persistedModelIdentifier); - if (persistedModelId) { - return persistedModelId; - } - } - - return ''; + return normalize(getSelectedModelIdentifier(contextKeyService, this._storageService)); } private _isChatLocation(contextKeyService: IContextKeyService): boolean { diff --git a/src/vs/workbench/contrib/chat/common/chatSelectedModel.ts b/src/vs/workbench/contrib/chat/common/chatSelectedModel.ts new file mode 100644 index 0000000000000..cac84c3c5b87f --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatSelectedModel.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { ChatContextKeys } from './actions/chatContextKeys.js'; +import { ILanguageModelsService } from './languageModels.js'; + +/** + * Storage key prefix for persisted model selections. + * Full key format: `chat.currentLanguageModel.{location}[.{sessionType}]` + */ +export const SELECTED_MODEL_STORAGE_KEY_PREFIX = 'chat.currentLanguageModel.'; + +/** + * Builds the storage key used to persist the selected language model for a + * given chat location and optional session type. + * + * Matches the keys written by `chatInputPart.ts` so that other consumers + * can read the persisted model selection without depending on widget internals. + */ +export function getSelectedModelStorageKey(location: string, sessionType?: string): string { + if (sessionType) { + return `${SELECTED_MODEL_STORAGE_KEY_PREFIX}${location}.${sessionType}`; + } + return `${SELECTED_MODEL_STORAGE_KEY_PREFIX}${location}`; +} + +/** + * Resolves the currently selected chat model identifier using a two-step + * strategy: + * + * 1. Read the `chatModelId` context key (set when a chat widget is active). + * 2. Fall back to the persisted storage value written by `chatInputPart`. + * + * Returns the raw model identifier string (may include a vendor prefix like + * `"copilot/gpt-4.1"` from storage, or a short id like `"gpt-4.1"` from + * the context key), or `undefined` if no selection is available. + */ +export function getSelectedModelIdentifier( + contextKeyService: IContextKeyService, + storageService: IStorageService, +): string | undefined { + // Step 1: Context key (live, widget-scoped) + const contextKeyModelId = contextKeyService.getContextKeyValue(ChatContextKeys.chatModelId.key); + if (contextKeyModelId) { + return contextKeyModelId; + } + + // Step 2: Persisted storage (survives reload, written by chatInputPart) + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key) ?? 'panel'; + const sessionType = contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key) ?? ''; + const candidateKeys = sessionType + ? [getSelectedModelStorageKey(location, sessionType), getSelectedModelStorageKey(location)] + : [getSelectedModelStorageKey(location)]; + + for (const key of candidateKeys) { + const persisted = storageService.get(key, StorageScope.APPLICATION); + if (persisted) { + return persisted; + } + } + + return undefined; +} + +/** + * Resolves the vendor of the currently selected chat model. + * + * Tries the language model registry first (authoritative when models are + * registered), then falls back to extracting the vendor prefix from the + * persisted model identifier (e.g. `"copilot/gpt-4.1"` → `"copilot"`). + * + * Returns `undefined` if no model selection is available. + */ +export function getSelectedModelVendor( + contextKeyService: IContextKeyService, + storageService: IStorageService, + languageModelsService: ILanguageModelsService, +): string | undefined { + const modelId = getSelectedModelIdentifier(contextKeyService, storageService); + if (!modelId) { + return undefined; + } + + // Try registry lookup first (handles both short and qualified IDs) + const shortId = modelId.includes('/') ? modelId.split('/').pop()! : modelId; + const metadata = languageModelsService.lookupLanguageModel(shortId) + ?? languageModelsService.lookupLanguageModel(modelId); + if (metadata) { + return metadata.vendor; + } + + // Fall back to vendor prefix from the persisted identifier + // (e.g. "copilot/gpt-4.1" or "customendpoint/ANT/claude-sonnet-4-6") + if (modelId.includes('/')) { + return modelId.split('/')[0]; + } + + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts new file mode 100644 index 0000000000000..3a6c569f5cd4b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -0,0 +1,619 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment, IQuotaSnapshot, IRateLimitSnapshot } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatQuotaNotificationContribution } from '../../browser/chatQuotaNotification.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../common/languageModels.js'; +import { IChatInputNotification, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; + +// --- Mock IChatEntitlementService ------------------------------------------- + +interface IMockQuotas { + usageBasedBilling?: boolean; + chat?: IQuotaSnapshot; + completions?: IQuotaSnapshot; + premiumChat?: IQuotaSnapshot; + additionalUsageEnabled?: boolean; + additionalUsageCount?: number; + sessionRateLimit?: IRateLimitSnapshot; + weeklyRateLimit?: IRateLimitSnapshot; +} + +function createMockEntitlementService(opts?: { + entitlement?: ChatEntitlement; + quotas?: IMockQuotas; +}) { + const onDidChangeQuotaRemaining = new Emitter(); + const onDidChangeQuotaExceeded = new Emitter(); + const onDidChangeEntitlement = new Emitter(); + + const service: IChatEntitlementService = { + _serviceBrand: undefined, + entitlement: opts?.entitlement ?? ChatEntitlement.Pro, + entitlementObs: observableValue({}, opts?.entitlement ?? ChatEntitlement.Pro), + onDidChangeEntitlement: onDidChangeEntitlement.event, + onDidChangeQuotaExceeded: onDidChangeQuotaExceeded.event, + onDidChangeQuotaRemaining: onDidChangeQuotaRemaining.event, + onDidChangeUsageBasedBilling: Event.None, + quotas: { + usageBasedBilling: opts?.quotas?.usageBasedBilling ?? true, + chat: opts?.quotas?.chat, + completions: opts?.quotas?.completions, + premiumChat: opts?.quotas?.premiumChat, + additionalUsageEnabled: opts?.quotas?.additionalUsageEnabled, + additionalUsageCount: opts?.quotas?.additionalUsageCount, + sessionRateLimit: opts?.quotas?.sessionRateLimit, + weeklyRateLimit: opts?.quotas?.weeklyRateLimit, + }, + organisations: undefined, + isInternal: false, + sku: undefined, + copilotTrackingId: undefined, + previewFeaturesDisabled: false, + clientByokEnabled: false, + hasByokModels: false, + onDidChangeSentiment: Event.None, + sentiment: {} as IChatSentiment, + sentimentObs: observableValue({}, {} as IChatSentiment) as IObservable, + onDidChangeAnonymous: Event.None, + anonymous: false, + anonymousObs: observableValue({}, false), + acceptQuotas() { }, + clearQuotas() { }, + markAnonymousRateLimited() { }, + markSetupCompleted() { }, + setForceHidden() { }, + update() { return Promise.resolve(); }, + }; + + return { service, onDidChangeQuotaRemaining, onDidChangeQuotaExceeded, onDidChangeEntitlement }; +} + +// --- Mock IChatInputNotificationService ------------------------------------ + +function createMockNotificationService() { + let lastNotification: IChatInputNotification | undefined = undefined; + let deleted = false; + let setCount = 0; + + const onDidChange = new Emitter(); + + const service: IChatInputNotificationService = { + _serviceBrand: undefined, + onDidChange: onDidChange.event, + setNotification(notification: IChatInputNotification) { + lastNotification = notification; + deleted = false; + setCount++; + }, + deleteNotification(_id: string) { + deleted = true; + }, + dismissNotification() { }, + getActiveNotification() { return deleted ? undefined : lastNotification; }, + handleMessageSent() { }, + }; + + return { + service, + getNotification(): IChatInputNotification | undefined { return deleted ? undefined : lastNotification; }, + get wasDeleted() { return deleted; }, + get setCount() { return setCount; }, + reset() { lastNotification = undefined; deleted = false; setCount = 0; }, + }; +} + +// --- Helpers --------------------------------------------------------------- + +function makeQuotaSnapshot(percentRemaining: number, opts?: Partial): IQuotaSnapshot { + return { + percentRemaining, + unlimited: false, + ...opts, + }; +} + +function makeRateLimitSnapshot(percentRemaining: number, opts?: Partial): IRateLimitSnapshot { + return { + percentRemaining, + unlimited: false, + resetDate: '2026-06-01T00:00:00Z', + ...opts, + }; +} + +// --- Tests ----------------------------------------------------------------- + +suite('ChatQuotaNotificationContribution', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }) { + const entitlementMock = createMockEntitlementService(entitlementOpts); + const notificationMock = createMockNotificationService(); + const contextKeyService = store.add(new MockContextKeyService()); + const storageService = store.add(new InMemoryStorageService()); + const vendor = modelOpts?.vendor ?? 'copilot'; + // Persist model selection in storage (used by getSelectedModelVendor) + storageService.store('chat.currentLanguageModel.panel', `${vendor}/test-model`, StorageScope.APPLICATION, StorageTarget.USER); + const languageModelsService = { + _serviceBrand: undefined, + onDidChangeLanguageModelVendors: Event.None, + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => ['test-model'], + getVendors: () => [], + lookupLanguageModel: (_id: string): ILanguageModelChatMetadata | undefined => ({ vendor } as ILanguageModelChatMetadata), + lookupLanguageModelByQualifiedName: () => undefined, + } as unknown as ILanguageModelsService; + + // Track disposables for emitters + store.add(entitlementMock.onDidChangeQuotaRemaining); + store.add(entitlementMock.onDidChangeQuotaExceeded); + store.add(entitlementMock.onDidChangeEntitlement); + + const contribution = store.add(new ChatQuotaNotificationContribution( + entitlementMock.service, + notificationMock.service, + contextKeyService as IContextKeyService, + languageModelsService, + storageService, + )); + + return { contribution, entitlementMock, notificationMock, storageService }; + } + + function updateQuotas( + entitlementMock: ReturnType, + quotas: IMockQuotas, + opts?: { entitlement?: ChatEntitlement }, + ) { + const svc: { entitlement: ChatEntitlement; quotas: IMockQuotas } = entitlementMock.service as IChatEntitlementService & { entitlement: ChatEntitlement; quotas: IMockQuotas }; + if (opts?.entitlement !== undefined) { + svc.entitlement = opts.entitlement; + } + svc.quotas = { ...svc.quotas, ...quotas }; + entitlementMock.onDidChangeQuotaRemaining.fire(); + } + + // --- Quota exhausted --------------------------------------------------- + + suite('quota exhausted', () => { + test('shows exhausted notification at startup when premiumChat is at 0%', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('shows exhausted notification for free user via chat snapshot', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Free, + quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('hides exhausted notification when quota recovers', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.wasDeleted); + }); + + test('does not show spurious threshold notification after exhaustion recovery', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, // 40% used baseline + }); + + // Exhaust quota + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(0) }); + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + + notificationMock.reset(); + + // Recover to 55% used — should NOT trigger "Credits at 50%" from stale baseline + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(45) }); + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('does not show exhausted for unlimited quota with hasQuota=true', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0, { unlimited: true, hasQuota: true }) }, + }); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows exhausted for unlimited quota with hasQuota=false', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0, { unlimited: true, hasQuota: false }) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + }); + + // --- Exhausted notification descriptions -------------------------------- + + suite('exhausted notification descriptions', () => { + test('anonymous user gets sign-in action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Unknown, + quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Sign in to keep going.'); + assert.strictEqual(notificationMock.getNotification()!.actions.length, 1); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.triggerSetup'); + }); + + test('free user gets upgrade action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Free, + quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Upgrade to keep going.'); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.upgradePlan'); + }); + + test('managed plan user gets admin message', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Business, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Contact your admin to increase your limits.'); + assert.strictEqual(notificationMock.getNotification()!.actions.length, 0); + }); + + test('paid user with overage gets increase budget action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageCount: 5 }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Increase your budget to keep building.'); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.manageAdditionalSpend'); + }); + + test('paid user without overage gets manage budget action', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Manage your budget to keep building.'); + }); + }); + + // --- Quota approaching threshold ---------------------------------------- + + suite('quota approaching threshold', () => { + test('first data arrival stores baseline without notification', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(25) }, // 75% used + }); + + // Initial _update runs in constructor but 75% is baseline, no crossing + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('notifies when crossing 50% threshold', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, // 40% used baseline + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); // 50% used + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 50%'); + }); + + test('does not re-show the same threshold', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + assert.ok(notificationMock.getNotification()); + + notificationMock.reset(); + + // Fire again at the same level + entitlementMock.onDidChangeQuotaRemaining.fire(); + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows higher threshold when usage increases', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); // 50% + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 50%'); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(10) }); // 90% + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 90%'); + }); + }); + + // --- PRU users ---------------------------------------------------------- + + suite('PRU users do not see quota notifications', () => { + test('does not show exhausted notification for PRU user', () => { + const { notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('does not show approaching notification for PRU user', () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Pro, + quotas: { usageBasedBilling: false, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(5) }); + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + }); + + // --- Overage activation ------------------------------------------------- + + suite('overage activation notification', () => { + test('shows overage notification on live transition to 100%', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(10), additionalUsageEnabled: true }, + }); + + // Transition to 100% + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: true }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is now covering extra usage.'); + }); + + test('does not show overage notification at startup when already at 100%', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: true }, + }); + + // At startup with overages enabled and already at 0%, no notification + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows standard exhausted on startup at 100% without overages', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: false }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + assert.notStrictEqual(notificationMock.getNotification()!.description, 'Additional budget is now covering extra usage.'); + }); + + test('shows overage notification when overages are enabled while already at 100%', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0), additionalUsageEnabled: false }, + }); + + assert.ok(notificationMock.getNotification()); + + // Enable overages while still at 0% + updateQuotas(entitlementMock, { additionalUsageEnabled: true, premiumChat: makeQuotaSnapshot(0) }); + + assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is now covering extra usage.'); + }); + }); + + // --- Rate-limit warnings ------------------------------------------------ + + suite('rate-limit warnings', () => { + test('shows session rate limit warning on threshold crossing', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, sessionRateLimit: makeRateLimitSnapshot(60) }, // baseline + }); + + updateQuotas(entitlementMock, { sessionRateLimit: makeRateLimitSnapshot(25) }); // 75% used + + assert.ok(notificationMock.getNotification()); + assert.ok((notificationMock.getNotification()!.message as string).includes('75%')); + assert.ok((notificationMock.getNotification()!.message as string).includes('session')); + }); + + test('shows weekly rate limit warning on threshold crossing', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, weeklyRateLimit: makeRateLimitSnapshot(60) }, // baseline + }); + + updateQuotas(entitlementMock, { weeklyRateLimit: makeRateLimitSnapshot(10) }); // 90% used + + assert.ok(notificationMock.getNotification()); + assert.ok((notificationMock.getNotification()!.message as string).includes('90%')); + assert.ok((notificationMock.getNotification()!.message as string).includes('weekly')); + }); + + test('first rate limit data stores baseline without notification', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, sessionRateLimit: makeRateLimitSnapshot(10) }, // 90% used + }); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + }); + + // --- Priority ordering -------------------------------------------------- + + suite('priority ordering', () => { + test('exhausted takes priority over approaching threshold', () => { + const { notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('approaching threshold takes priority over rate limit', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { + usageBasedBilling: true, + premiumChat: makeQuotaSnapshot(60), // 40% — baseline + sessionRateLimit: makeRateLimitSnapshot(60), // 40% — baseline + }, + }); + + updateQuotas(entitlementMock, { + premiumChat: makeQuotaSnapshot(10), // 90% — crosses threshold + sessionRateLimit: makeRateLimitSnapshot(25), // 75% — crosses threshold + }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.message, 'Credits at 90%'); + }); + }); + + // --- Approaching notification descriptions ------------------------------ + + suite('approaching notification descriptions', () => { + test('free user gets upgrade action', () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Free, + quotas: { usageBasedBilling: true, chat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { chat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Upgrade to continue past the limit.'); + }); + + test('managed plan user gets admin message', () => { + const { entitlementMock, notificationMock } = createContribution({ + entitlement: ChatEntitlement.Enterprise, + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Contact your admin to increase your limits.'); + }); + + test('paid user with overages enabled gets budget message', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60), additionalUsageEnabled: true }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Additional budget is enabled to cover extra usage.'); + }); + + test('paid user without overages gets set budget action', () => { + const { entitlementMock, notificationMock } = createContribution({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(60) }, + }); + + updateQuotas(entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()!.description, 'Set additional budget to cover extra usage.'); + assert.strictEqual(notificationMock.getNotification()!.actions[0].commandId, 'workbench.action.chat.manageAdditionalSpend'); + }); + }); + + // --- BYOK model suppression --------------------------------------------- + + suite('BYOK model suppression', () => { + test('defers notifications when BYOK model is selected', () => { + const { notificationMock } = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + { vendor: 'customendpoint' }, + ); + + assert.strictEqual(notificationMock.getNotification(), undefined); + }); + + test('shows notification when Copilot model is selected', () => { + const { notificationMock } = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + { vendor: 'copilot' }, + ); + + assert.ok(notificationMock.getNotification()); + assert.strictEqual(notificationMock.getNotification()?.message, 'Credit Limit Reached'); + }); + + test('shows notification when switching from BYOK to Copilot model', () => { + const entitlementMock = createMockEntitlementService({ + quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) }, + }); + const notificationMock = createMockNotificationService(); + const contextKeyService = store.add(new MockContextKeyService()); + const storageService = store.add(new InMemoryStorageService()); + // Start with BYOK model + storageService.store('chat.currentLanguageModel.panel', 'customendpoint/ANT/claude-sonnet-4-6', StorageScope.APPLICATION, StorageTarget.USER); + // Registry returns undefined — vendor detection relies on prefix extraction + const languageModelsService = { + _serviceBrand: undefined, + onDidChangeLanguageModelVendors: Event.None, + onDidChangeLanguageModels: Event.None, + getLanguageModelIds: () => [], + getVendors: () => [], + lookupLanguageModel: (): ILanguageModelChatMetadata | undefined => undefined, + lookupLanguageModelByQualifiedName: () => undefined, + } as unknown as ILanguageModelsService; + + store.add(entitlementMock.onDidChangeQuotaRemaining); + store.add(entitlementMock.onDidChangeQuotaExceeded); + store.add(entitlementMock.onDidChangeEntitlement); + + store.add(new ChatQuotaNotificationContribution( + entitlementMock.service, + notificationMock.service, + contextKeyService as IContextKeyService, + languageModelsService, + storageService, + )); + + // Initially deferred — BYOK model + assert.strictEqual(notificationMock.getNotification(), undefined); + + // Switch to Copilot model via storage — triggers storage listener + storageService.store('chat.currentLanguageModel.panel', 'copilot/gpt-4.1', StorageScope.APPLICATION, StorageTarget.USER); + + assert.strictEqual(notificationMock.getNotification()?.message, 'Credit Limit Reached'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index 0df98d208b8ae..36db072515018 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -62,6 +62,8 @@ function createEntitlementService(opts: { anonymous: false, onDidChangeAnonymous: Event.None, anonymousObs: observableValue({}, false), + acceptQuotas: () => { }, + clearQuotas: () => { }, markAnonymousRateLimited: () => { }, markSetupCompleted: () => { }, setForceHidden: () => { }, diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index f9110d37144df..21d7f8667dd1e 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -193,6 +193,13 @@ export interface IChatEntitlementService { readonly anonymous: boolean; readonly anonymousObs: IObservable; + acceptQuotas(quotas: IQuotas): void; + + /** + * Clear all quota state. + */ + clearQuotas(): void; + markAnonymousRateLimited(): void; /** @@ -567,7 +574,10 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this._onDidChangeQuotaExceeded.fire(); } - if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining || oldQuota.usageBasedBilling !== quotas.usageBasedBilling) { + const sessionRateLimitChanged = oldQuota.sessionRateLimit?.percentRemaining !== quotas.sessionRateLimit?.percentRemaining; + const weeklyRateLimitChanged = oldQuota.weeklyRateLimit?.percentRemaining !== quotas.weeklyRateLimit?.percentRemaining; + + if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining || sessionRateLimitChanged || weeklyRateLimitChanged || oldQuota.usageBasedBilling !== quotas.usageBasedBilling) { this._onDidChangeQuotaRemaining.fire(); } @@ -753,6 +763,12 @@ export interface IQuotaSnapshot { readonly quotaRemaining?: number; } +export interface IRateLimitSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; +} + interface IQuotas { readonly resetDate?: string; readonly resetDateHasTime?: boolean; @@ -765,6 +781,9 @@ interface IQuotas { readonly premiumChat?: IQuotaSnapshot; readonly additionalUsageEnabled?: boolean; readonly additionalUsageCount?: number; + + readonly sessionRateLimit?: IRateLimitSnapshot; + readonly weeklyRateLimit?: IRateLimitSnapshot; } export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index b8557cefb0ec8..70ffa107d85ec 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -815,6 +815,8 @@ export class TestChatEntitlementService implements IChatEntitlementService { onDidChangeAnonymous = Event.None; readonly anonymousObs = observableValue({}, false); + acceptQuotas(): void { } + clearQuotas(): void { } markAnonymousRateLimited(): void { } markSetupCompleted(): void { } setForceHidden(_hidden: boolean): void { } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index e5a1abb3ccdd2..9cbc18c074aaa 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -468,4 +468,55 @@ declare module 'vscode' { */ readonly fullReferenceName?: string; } + + // #region Quota Sync + + /** + * A snapshot of quota usage for a single category (chat, completions, premium chat). + */ + export interface ChatQuotaSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly hasQuota?: boolean; + readonly resetAt?: number; + readonly usageBasedBilling?: boolean; + readonly entitlement?: number; + readonly quotaRemaining?: number; + } + + /** + * A snapshot of rate limit usage for a category (session or weekly). + */ + export interface ChatRateLimitSnapshot { + readonly percentRemaining: number; + readonly unlimited: boolean; + readonly resetDate?: string; + } + + /** + * Quota snapshot data covering all categories. + * Accepted by {@link chat.updateQuotas} for extension-to-core sync. + */ + export interface ChatQuotaSnapshots { + readonly resetDate?: string; + readonly resetDateHasTime?: boolean; + readonly usageBasedBilling?: boolean; + readonly canUpgradePlan?: boolean; + readonly chat?: ChatQuotaSnapshot; + readonly completions?: ChatQuotaSnapshot; + readonly premiumChat?: ChatQuotaSnapshot; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly sessionRateLimit?: ChatRateLimitSnapshot; + readonly weeklyRateLimit?: ChatRateLimitSnapshot; + } + + export namespace chat { + /** + * Push quota snapshot data from the extension to the core workbench. + */ + export function updateQuotas(quotas: ChatQuotaSnapshots): void; + } + + // #endregion } From 0245c88390aebd12884aae79caf4494b5b006d04 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 27 May 2026 16:02:21 -0700 Subject: [PATCH 10/18] mcp: prefer announced server title for tool prefixes and picker labels (#318638) * mcp: prefer announced server title for tool prefixes and picker labels Reworks how MCP tool prefixes are generated and how MCP servers are labelled in the Configure Tools picker so that the server-announced serverInfo.title (falling back to serverInfo.name) is preferred over the mcp.json key. - Introduces a shared McpPrefixGenerator with a ake(name): IReference API that hands out collision-resolved tool prefixes and releases them on dispose. Replaces the eager one-shot generator that used to live in McpService. - McpServer now derives its tool prefix from its preferred name (announced title when known, otherwise the mcp.json label) so registry-style names like io.github.upstash/context7 no longer end up as truncated mcp_io_github_ups_* prefixes. - Configure Tools picker and ToolDataSource.classify for MCP sources now prefer serverLabel (the announced title) over label . - Adds unit tests for McpPrefixGenerator covering collisions, slot reuse on dispose, bucket cleanup, and name sanitization. Fixes https://github.com/microsoft/vscode/issues/299749 Fixes https://github.com/microsoft/vscode/issues/299787 (Commit message generated by Copilot) * pr comments --- .../chat/browser/actions/chatToolPicker.ts | 2 +- .../common/tools/languageModelToolsService.ts | 2 +- .../workbench/contrib/mcp/common/mcpServer.ts | 84 +++++++++++-- .../contrib/mcp/common/mcpService.ts | 33 ++--- .../test/common/mcpPrefixGenerator.test.ts | 118 ++++++++++++++++++ 5 files changed, 200 insertions(+), 39 deletions(-) create mode 100644 src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 7d8e0512eafb1..fd82cc29067ab 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -319,7 +319,7 @@ export async function showToolsPicker( itemType: 'bucket', ordinal: BucketOrdinal.Mcp, id: key, - label: source.label, + label: source.serverLabel || source.label, checked: undefined, collapsed, children, diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 71709dc6a4ce4..a91dae48ddd65 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -154,7 +154,7 @@ export namespace ToolDataSource { if (source.type === 'internal') { return { ordinal: 1, label: localize('builtin', 'Built-In') }; } else if (source.type === 'mcp') { - return { ordinal: 2, label: source.label }; + return { ordinal: 2, label: source.serverLabel || source.label }; } else if (source.type === 'user') { return { ordinal: 0, label: localize('user', 'User Defined') }; } else { diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 679bfefeba8a0..a97a32290e221 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -8,11 +8,11 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Iterable } from '../../../../base/common/iterator.js'; import * as json from '../../../../base/common/json.js'; import { normalizeDriveLetter } from '../../../../base/common/labels.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { mapValues } from '../../../../base/common/objects.js'; -import { autorun, autorunSelfDisposable, derived, disposableObservableValue, IDerivedReader, IObservable, IReader, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, autorunSelfDisposable, derived, derivedDisposable, disposableObservableValue, IDerivedReader, IObservable, IReader, ITransaction, observableFromEvent, ObservablePromise, observableValue, transaction } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { createURITransformer } from '../../../../base/common/uriTransformer.js'; @@ -214,6 +214,53 @@ export class McpServerMetadataCache extends Disposable { } } +/** + * Shared across all {@link McpServer}s. Each server `take`s the name it wants + * to base its tool prefix on (announced `serverInfo.title`/`name` when known, + * otherwise the mcp.json key) and gets back a stable, collision-resolved prefix + * observable. When a server's preferred name changes (e.g. after the live + * `serverInfo` arrives), it simply takes again and disposes the previous + * reference; other servers that share the name keep the suffix they were + * already assigned. See #299749. + */ +export class McpPrefixGenerator { + private readonly _buckets = new Map; size: number }>(); + + take(name: string): IReference { + const safeName = name.toLowerCase().replace(/[^a-z0-9_.-]+/g, '_').slice(0, McpToolName.MaxPrefixLen - McpToolName.Prefix.length - 1); + let bucket = this._buckets.get(safeName); + if (!bucket) { + bucket = { usedIndexes: new Set(), size: 0 }; + this._buckets.set(safeName, bucket); + } + + let index = 1; + while (bucket.usedIndexes.has(index)) { + index++; + } + bucket.usedIndexes.add(index); + bucket.size++; + + // Trim safeName for this output if a multi-digit suffix would push us past + // MaxPrefixLen. The bucket is keyed on the un-trimmed safeName so collisions + // are still detected consistently across indexes. + const suffix = (index === 1 ? '' : String(index)) + '_'; + const maxNameLen = McpToolName.MaxPrefixLen - McpToolName.Prefix.length - suffix.length; + const prefix = McpToolName.Prefix + safeName.slice(0, maxNameLen) + suffix; + + return { + object: prefix, + dispose: () => { + bucket!.usedIndexes.delete(index); + bucket!.size--; + if (bucket!.size === 0) { + this._buckets.delete(safeName); + } + }, + }; + } +} + type ValidatedMcpTool = MCP.Tool & { _icons: StoredMcpIcons; @@ -433,7 +480,7 @@ export class McpServer extends Disposable implements IMcpServer { explicitRoots: URI[] | undefined, private readonly _requiresExtensionActivation: boolean | undefined, private readonly _primitiveCache: McpServerMetadataCache, - toolPrefix: string, + prefixGenerator: McpPrefixGenerator, enablementModel: IEnablementModel, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IWorkspaceContextService workspacesService: IWorkspaceContextService, @@ -516,6 +563,22 @@ export class McpServer extends Disposable implements IMcpServer { return def && def.cacheNonce !== this._tools.fromCache?.nonce ? def.staticMetadata : undefined; }); + this._serverMetadata = new CachedPrimitive( + this.definition.id, + this._primitiveCache, + staticMetadata.map(m => m ? this._toStoredMetadata(m?.serverInfo, m?.instructions) : undefined), + (entry) => ({ serverName: entry.serverName, serverInstructions: entry.serverInstructions, serverIcons: entry.serverIcons }), + (entry) => ({ serverName: entry?.serverName, serverInstructions: entry?.serverInstructions, icons: McpIcons.fromStored(entry?.serverIcons) }), + undefined, + ); + + // Form the tool prefix from the server-announced name when known so that + // registry-style mcp.json keys like `io.github.upstash/context7` don't end + // up in `mcp_io_github_ups_*` truncated names. See #299749. + const preferredName = derived(reader => this._serverMetadata.value.read(reader)?.serverName || this.definition.label); + const prefixRef = derivedDisposable(reader => prefixGenerator.take(preferredName.read(reader))); + const toolPrefix = prefixRef.map(ref => ref.object); + // 3. Publish tools this._tools = new CachedPrimitive( this.definition.id, @@ -527,7 +590,7 @@ export class McpServer extends Disposable implements IMcpServer { }) .map((o, reader) => o?.promiseResult.read(reader)?.data), (entry) => entry.tools, - (entry) => entry.map(def => this._instantiationService.createInstance(McpTool, this, toolPrefix, def)).sort((a, b) => a.compare(b)), + (entry, reader) => entry.map(def => this._instantiationService.createInstance(McpTool, this, toolPrefix.read(reader), def)).sort((a, b) => a.compare(b)), [], ); @@ -541,15 +604,6 @@ export class McpServer extends Disposable implements IMcpServer { [], ); - this._serverMetadata = new CachedPrimitive( - this.definition.id, - this._primitiveCache, - staticMetadata.map(m => m ? this._toStoredMetadata(m?.serverInfo, m?.instructions) : undefined), - (entry) => ({ serverName: entry.serverName, serverInstructions: entry.serverInstructions, serverIcons: entry.serverIcons }), - (entry) => ({ serverName: entry?.serverName, serverInstructions: entry?.serverInstructions, icons: McpIcons.fromStored(entry?.serverIcons) }), - undefined, - ); - this._capabilities = new CachedPrimitive( this.definition.id, this._primitiveCache, @@ -558,6 +612,10 @@ export class McpServer extends Disposable implements IMcpServer { (entry) => entry, undefined, ); + + // Hold the prefix for the lifetime of the server so its tool name stays + // stable even when no one is currently observing the tools list. + prefixRef.recomputeInitiallyAndOnChange(this._store); } public readDefinitions(): IObservable<{ server: McpServerDefinition | undefined; collection: McpCollectionDefinition | undefined }> { diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index 4fa9f3430ab26..4c28260a133a8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -16,11 +16,11 @@ import { IStorageService, StorageScope } from '../../../../platform/storage/comm import { ContributionEnablementState, EnablementModel, IEnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; import { McpCollisionBehavior, mcpServerCollisionBehaviorSection } from './mcpConfiguration.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; -import { McpServer, McpServerMetadataCache } from './mcpServer.js'; -import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, McpToolName, UserInteractionRequiredError } from './mcpTypes.js'; +import { McpPrefixGenerator, McpServer, McpServerMetadataCache } from './mcpServer.js'; +import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, UserInteractionRequiredError } from './mcpTypes.js'; import { startServerAndWaitForLiveTools } from './mcpTypesUtils.js'; -type IMcpServerRec = { object: IMcpServer; toolPrefix: string }; +type IMcpServerRec = { object: IMcpServer }; export class McpService extends Disposable implements IMcpService { @@ -30,6 +30,8 @@ export class McpService extends Disposable implements IMcpService { private readonly _servers = observableValue(this, []); public readonly servers: IObservable = this._servers.map(servers => servers.map(s => s.object)); + private readonly _prefixGenerator = new McpPrefixGenerator(); + public get lazyCollectionState() { return this._mcpRegistry.lazyCollectionState; } public readonly enablementModel: McpCollisionEnablementModel; @@ -174,11 +176,9 @@ export class McpService extends Disposable implements IMcpService { } public updateCollectedServers() { - const prefixGenerator = new McpPrefixGenerator(); const definitions = this._mcpRegistry.collections.get().flatMap(collectionDefinition => collectionDefinition.serverDefinitions.get().map(serverDefinition => { - const toolPrefix = prefixGenerator.generate(serverDefinition.label); - return { serverDefinition, collectionDefinition, toolPrefix }; + return { serverDefinition, collectionDefinition }; }) ); @@ -198,7 +198,7 @@ export class McpService extends Disposable implements IMcpService { // Transfer over any servers that are still valid. for (const server of currentServers) { - const match = definitions.find(d => defsEqual(server.object, d) && server.toolPrefix === d.toolPrefix); + const match = definitions.find(d => defsEqual(server.object, d)); if (match) { pushMatch(match, server); } else { @@ -215,11 +215,11 @@ export class McpService extends Disposable implements IMcpService { def.serverDefinition.roots, !!def.collectionDefinition.lazy, def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache, - def.toolPrefix, + this._prefixGenerator, this.enablementModel, ); - nextServers.push({ object, toolPrefix: def.toolPrefix }); + nextServers.push({ object }); } transaction(tx => { @@ -237,21 +237,6 @@ function defsEqual(server: IMcpServer, def: { serverDefinition: McpServerDefinit return server.collection.id === def.collectionDefinition.id && server.definition.id === def.serverDefinition.id; } -// Helper class for generating unique MCP tool prefixes -class McpPrefixGenerator { - private readonly seenPrefixes = new Set(); - - generate(label: string): string { - const baseToolPrefix = McpToolName.Prefix + label.toLowerCase().replace(/[^a-z0-9_.-]+/g, '_').slice(0, McpToolName.MaxPrefixLen - McpToolName.Prefix.length - 1); - let toolPrefix = baseToolPrefix + '_'; - for (let i = 2; this.seenPrefixes.has(toolPrefix); i++) { - toolPrefix = baseToolPrefix + i + '_'; - } - this.seenPrefixes.add(toolPrefix); - return toolPrefix; - } -} - /** * Wraps an {@link EnablementModel} with collision-aware defaults and * mutual-exclusion logic for MCP servers with the same label. diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts new file mode 100644 index 0000000000000..09063c7ebad79 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpPrefixGenerator.test.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { IReference } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { McpPrefixGenerator } from '../../common/mcpServer.js'; +import { McpToolName } from '../../common/mcpTypes.js'; + +suite('McpPrefixGenerator', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('basic prefix uses mcp_ + safe lower-case name', () => { + const gen = new McpPrefixGenerator(); + const ref = gen.take('Context7'); + assert.strictEqual(ref.object, `${McpToolName.Prefix}context7_`); + ref.dispose(); + }); + + test('long registry-style names are clamped to MaxPrefixLen', () => { + const gen = new McpPrefixGenerator(); + // Repro for #299749: a long registry-style key should at least produce a + // valid (length-bounded) prefix. The actual readability fix comes from + // callers taking with the announced server title instead of this key. + const ref = gen.take('io.github.upstash/context7'); + assert.ok(ref.object.startsWith(McpToolName.Prefix)); + assert.ok(ref.object.length <= McpToolName.MaxPrefixLen); + ref.dispose(); + }); + + test('collisions add numeric suffixes', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('foo'); + const c = gen.take('foo'); + assert.strictEqual(a.object, `${McpToolName.Prefix}foo_`); + assert.strictEqual(b.object, `${McpToolName.Prefix}foo2_`); + assert.strictEqual(c.object, `${McpToolName.Prefix}foo3_`); + a.dispose(); + b.dispose(); + c.dispose(); + }); + + test('dispose releases the slot and the next take reuses the lowest index', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('foo'); + const c = gen.take('foo'); + assert.strictEqual(b.object, `${McpToolName.Prefix}foo2_`); + + b.dispose(); + const d = gen.take('foo'); + assert.strictEqual(d.object, `${McpToolName.Prefix}foo2_`, 'reuses freed slot'); + + a.dispose(); + c.dispose(); + d.dispose(); + }); + + test('disposing only consumer cleans up the bucket so the next take starts at index 1', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('foo'); + a.dispose(); + b.dispose(); + + const c = gen.take('foo'); + assert.strictEqual(c.object, `${McpToolName.Prefix}foo_`); + c.dispose(); + }); + + test('different names live in independent buckets', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('foo'); + const b = gen.take('bar'); + assert.strictEqual(a.object, `${McpToolName.Prefix}foo_`); + assert.strictEqual(b.object, `${McpToolName.Prefix}bar_`); + a.dispose(); + b.dispose(); + }); + + test('names are sanitized and lower-cased the same way before bucketing', () => { + const gen = new McpPrefixGenerator(); + const a = gen.take('My Server'); + const b = gen.take('my server'); + const c = gen.take('my/server'); + // All collapse to the same safe name `my_server`, so they collide. + assert.strictEqual(a.object, `${McpToolName.Prefix}my_server_`); + assert.strictEqual(b.object, `${McpToolName.Prefix}my_server2_`); + assert.strictEqual(c.object, `${McpToolName.Prefix}my_server3_`); + a.dispose(); + b.dispose(); + c.dispose(); + }); + + test('prefix length never exceeds MaxPrefixLen, including for multi-digit collision suffixes', () => { + const gen = new McpPrefixGenerator(); + // Pick a name that, once sanitized, sits right at the per-bucket length cap + // so that adding a numeric suffix would otherwise blow past MaxPrefixLen. + const longName = 'a'.repeat(McpToolName.MaxPrefixLen); + const refs: IReference[] = []; + for (let i = 0; i < 12; i++) { + refs.push(gen.take(longName)); + } + for (const ref of refs) { + assert.ok(ref.object.startsWith(McpToolName.Prefix)); + assert.ok(ref.object.endsWith('_')); + assert.ok(ref.object.length <= McpToolName.MaxPrefixLen, `prefix ${ref.object} (length ${ref.object.length}) exceeds MaxPrefixLen ${McpToolName.MaxPrefixLen}`); + } + // All 12 must be unique so they remain distinguishable. + assert.strictEqual(new Set(refs.map(r => r.object)).size, refs.length); + for (const ref of refs) { + ref.dispose(); + } + }); +}); From c7d320d48ee06bed77567bf969896b4a6906e83f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 27 May 2026 17:06:56 -0700 Subject: [PATCH 11/18] agentHost: dedupe concurrent _resumeSession calls per sessionId (#318636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * agentHost: dedupe concurrent _resumeSession calls per sessionId Concurrent _resumeSession(id) callers (e.g. an outdated-config refresh in sendMessage plus a getSessionMessages subscribe) each constructed a fresh CopilotAgentSession and ran it through _createAgentSession, whose this._sessions.set(id, …) on a DisposableMap synchronously disposed the first entry mid-initializeSession(). The result was a tight loop of 'Trying to add a disposable to a DisposableStore that has already been disposed' warnings (~550 in one repro) and a half-initialised session with no event subscriptions — the chat widget opens, but sending a message goes nowhere. Fix: - Add an _resumingSessions: Map>; _resumeSession is now a thin wrapper that returns the in-flight promise when one exists, else delegates to a new _doResumeSession and memoizes the promise until it settles. - Stop registering sessions in _sessions inside _createAgentSession. Both callers (_doResumeSession and _materializeProvisional) now set _sessions only after initializeSession() succeeds, and dispose the freshly-constructed agentSession if it throws. This removes the DisposableMap.set footgun that disposed an in-flight entry from under its own init. Adds focused tests for the dedup contract: - concurrent calls for the same id share one promise + one _doResumeSession - inflight entry is cleared after resolution (next call re-invokes) - inflight entry is cleared after rejection (next call retries) - different ids resolve independently (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: guard post-init _sessions.set against shutdown race Address Copilot review on #318636: now that _sessions.set is deferred until after initializeSession() resolves, an in-flight _resumeSession / _materializeProvisional whose init resolves AFTER dispose() -> shutdown() -> super.dispose() has run would call _sessions.set(...) on a disposed DisposableMap, leaking the session and reproducing the 'Trying to add a disposable to a DisposableStore that has already been disposed' warning this PR exists to eliminate. Extract a small _registerInitializedSession helper that bails (dispose + CancellationError) when _shutdownPromise is already set, and route both call sites through it. The provisional path additionally still removes its created worktree on cancel via the existing catch block. Add a focused unit test that exercises the helper directly with a fake session, simulating the shutdown-started state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * launch skill: call out the transpile-client → preLaunch-skips-compile trap When 'out/' already exists from a prior 'npm run transpile-client', 'node build/lib/preLaunch.ts' will skip 'npm run compile' and you'll get a launched window whose built-in extensions fail to activate with 'Cannot find module .../extensions/.../out/...'. Document the trap in both prerequisites and troubleshooting, and add a 'node_modules / npm install' prereq. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * launch skill: tighten compile prereq + sign-in nudge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/launch/SKILL.md | 6 +- .../agentHost/node/copilot/copilotAgent.ts | 69 +++++++++- .../agentHost/test/node/copilotAgent.test.ts | 129 ++++++++++++++++++ 3 files changed, 195 insertions(+), 9 deletions(-) diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md index 6f3e9c75d47d3..0992f5e3e9075 100644 --- a/.agents/skills/launch/SKILL.md +++ b/.agents/skills/launch/SKILL.md @@ -19,8 +19,10 @@ The clone is **slim**: workspace storage, browser caches, file history, cached V ## Prerequisites - macOS or Linux. The launcher is a bash script and depends on `rsync`, `curl`, `nohup`, and Node on `PATH`. The example caller snippets below also use `jq` (parse the JSON output) and `lsof` (kill-by-port fallback) — install those if you plan to use them, but the launcher itself does not require them. -- A VS Code checkout with sources built. Run `npm run compile` once (one-shot) or `npm run watch` for incremental rebuilds. Both build the full client **and** the Copilot extension. The launcher also runs `node build/lib/preLaunch.ts` before starting Code OSS, which auto-runs `npm run compile` if `out/` is missing and downloads Electron + built-in extensions. +- A VS Code checkout with `node_modules/` installed (`npm install` if missing — do **not** symlink from a sibling worktree; that breaks builds in subtle ways). +- A VS Code checkout with sources built. Run `npm run compile` once (one-shot) or `npm run watch` for incremental rebuilds. Both build the full client **and** all built-in extensions under `extensions/`. You must build the full product to run successfully, building just the client is not enough. - An **authenticated** Code OSS profile to seed from. By default the launcher uses `~/.vscode-oss-dev`, which is the user-data-dir the repo's `launch.json` configs use - if the user has ever signed in to Copilot in a dev build, this should work. Only pass `--source-user-data-dir ` (or set `$CODE_OSS_DEV_AUTHED_USER_DATA_DIR`) when you specifically want to seed from a different profile (e.g. your regular `~/Library/Application Support/Code` install). + - If Code OSS launches and needs a sign-in, don't give up! Use the questions tool to ask the user to sign in. - `@playwright/cli` available (it's a devDependency in the vscode repo - `npm install` then use `npx @playwright/cli`). - For debugger work: `dap-cli` on `PATH`. If debugger support would be useful but the `dap-cli` skill is not present, prompt the user to install it from https://github.com/roblourens/dap-cli. - CSS selectors are internal implementation details. If a selector-based `eval` stops working, take a fresh `snapshot`, inspect the current DOM, and update the selector rather than assuming an old one still applies. @@ -329,7 +331,7 @@ Code OSS is a full Electron app and easily eats 1-4 GB. Always clean up. - **"Sent env to running instance. Terminating..."** - The dynamic `--user-data-dir` should prevent this. If you see it, another Code OSS is using the same profile path; pass `--source-user-data-dir` to a different source or check that the temp copy actually happened (`ls "$(jq -r .userDataDir <<<"$INFO")"`). - **Renderer ESM errors / `import { Menu } from 'electron'`** - `ELECTRON_RUN_AS_NODE` is set in your env. The launcher unsets it for the child, but if you spawn `code.sh` yourself, do the same. -- **Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`)** - extensions weren't compiled. Run `npm run compile` (one-shot, also rebuilds the Copilot extension) or `npm run watch` (incremental). +- **Built-in extension fails to load (`Cannot find module .../extensions/.../out/extension.js`)** - extensions weren't compiled. Run `npm run compile` (one-shot, also rebuilds all built-in extensions) or `npm run watch` (incremental). A common cause: you ran `npm run transpile-client` to satisfy unit tests, which populated `out/` but not `extensions/*/out/`, so preLaunch's "is `out/` missing?" check skipped the compile. - **`launch.sh` exits non-zero with a log tail** - either pre-launch failed, `code.sh` died before CDP came up, or CDP never opened within 90s. The tail printed to stderr is from `runDir/code.log` - read it to diagnose. - **Snapshot shows the wrong page or no expected controls** - use `tab-list`, switch with `tab-select ` if needed, then re-snapshot before interacting. - **CLI typing commands complete but the input stays empty** - focus chat with the platform shortcut, use `press` or clipboard paste rather than `fill` / `type`, then verify the input state before sending. diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index a8f41ea317999..89783daf45ef2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -7,6 +7,7 @@ import { CopilotClient, ResumeSessionConfig, type CopilotClientOptions, type Ses import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { rgDiskPath } from '../../../../base/node/ripgrep.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -245,6 +246,15 @@ export class CopilotAgent extends Disposable implements IAgent { private _clientStarting: Promise | undefined; private _githubToken: string | undefined; private readonly _sessions = this._register(new DisposableMap()); + /** + * In-flight {@link _resumeSession} promises, keyed by sessionId. Used to + * deduplicate concurrent resume requests for the same session so that + * we never construct two {@link CopilotAgentSession} entries for the + * same id — `_sessions` is a {@link DisposableMap} whose `set()` would + * dispose the in-flight first entry mid-{@link CopilotAgentSession.initializeSession}, + * leaving the second caller with a half-initialised, eventless session. + */ + private readonly _resumingSessions = new Map>(); /** * Sessions created by a client but not yet materialized into a Copilot * SDK session + worktree + on-disk metadata. Materialization is deferred @@ -919,11 +929,13 @@ export class CopilotAgent extends Disposable implements IAgent { return new CopilotSessionWrapper(raw); }; - let agentSession: CopilotAgentSession; + let agentSession: CopilotAgentSession | undefined; try { agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, customizationDirectory, snapshot); await agentSession.initializeSession(); + this._registerInitializedSession(sessionId, agentSession); } catch (error) { + agentSession?.dispose(); await this._removeCreatedWorktree(sessionId); throw error; } @@ -1434,9 +1446,12 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Creates a {@link CopilotAgentSession}, registers it in the sessions map, - * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} - * to wire up the SDK session. + * Instantiates a {@link CopilotAgentSession} for the given session id. + * The caller is responsible for awaiting {@link CopilotAgentSession.initializeSession} + * and, on success, registering the entry in {@link _sessions}. The + * session is intentionally **not** registered here so a concurrent + * {@link _resumeSession} for the same id cannot dispose this entry mid-init + * via {@link DisposableMap.set}. */ private _createAgentSession(wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, snapshot?: IActiveClientSnapshot): CopilotAgentSession { const sessionUri = AgentSession.uri(this.id, sessionId); @@ -1455,10 +1470,28 @@ export class CopilotAgent extends Disposable implements IAgent { }, ); - this._sessions.set(sessionId, agentSession); return agentSession; } + /** + * Register a freshly initialised session in `_sessions`, or — if + * shutdown has already started between init beginning and resolving — + * dispose the session and throw {@link CancellationError}. Without this + * guard an in-flight `_resumeSession` / `_materializeProvisional` whose + * `initializeSession()` resolves after `dispose()` has run would call + * `_sessions.set(...)` on a disposed `DisposableMap`, leaking the + * session and reproducing the very 'Trying to add a disposable to a + * DisposableStore that has already been disposed' warning this fix + * exists to prevent. + */ + private _registerInitializedSession(sessionId: string, agentSession: CopilotAgentSession): void { + if (this._shutdownPromise) { + agentSession.dispose(); + throw new CancellationError(); + } + this._sessions.set(sessionId, agentSession); + } + private async _destroyAndDisposeSession(sessionId: string): Promise { // Provisional sessions have no SDK session, no worktree, and no // on-disk metadata — drop the in-memory record and clean up the @@ -1518,7 +1551,23 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - protected async _resumeSession(sessionId: string): Promise { + protected _resumeSession(sessionId: string): Promise { + const existing = this._resumingSessions.get(sessionId); + if (existing) { + return existing; + } + const promise = this._doResumeSession(sessionId); + this._resumingSessions.set(sessionId, promise); + const cleanup = () => { + if (this._resumingSessions.get(sessionId) === promise) { + this._resumingSessions.delete(sessionId); + } + }; + promise.then(cleanup, cleanup); + return promise; + } + + private async _doResumeSession(sessionId: string): Promise { this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`); const client = await this._ensureClient(); @@ -1582,7 +1631,13 @@ export class CopilotAgent extends Disposable implements IAgent { }; const agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, customizationDirectory, snapshot); - await agentSession.initializeSession(); + try { + await agentSession.initializeSession(); + } catch (err) { + agentSession.dispose(); + throw err; + } + this._registerInitializedSession(sessionId, agentSession); return agentSession; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5445ad9cdddf9..58930f2272a4e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -8,6 +8,8 @@ import assert from 'assert'; import * as fs from 'fs/promises'; import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -946,6 +948,133 @@ suite('CopilotAgent', () => { }); }); + suite('_resumeSession dedup', () => { + // Regression: two concurrent paths (e.g. an outdated-config refresh in + // `sendMessage` and a `getSessionMessages` subscribe) each calling + // `_resumeSession(id)` used to construct two `CopilotAgentSession` + // entries for the same id; the second `_sessions.set(id, …)` on the + // underlying `DisposableMap` disposed the first one mid + // `initializeSession()`, producing 'Trying to add a disposable to a + // DisposableStore that has already been disposed' warnings and a + // half-initialised session with no event subscriptions. + + type AgentInternals = { + _resumeSession: (id: string) => Promise; + _doResumeSession: (id: string) => Promise; + }; + const makeFakeSession = () => ({ dispose: () => { } } as unknown as CopilotAgentSession); + + test('dedupes concurrent calls for the same sessionId', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + const deferred = new DeferredPromise(); + let doResumeCalls = 0; + internals._doResumeSession = () => { + doResumeCalls++; + return deferred.p; + }; + try { + const p1 = internals._resumeSession('s1'); + const p2 = internals._resumeSession('s1'); + assert.strictEqual(p1, p2); + assert.strictEqual(doResumeCalls, 1); + + const session = makeFakeSession(); + deferred.complete(session); + assert.strictEqual(await p1, session); + assert.strictEqual(await p2, session); + } finally { + await disposeAgent(agent); + } + }); + + test('clears inflight entry after resolution so the next call re-invokes _doResumeSession', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + let doResumeCalls = 0; + internals._doResumeSession = async () => { + doResumeCalls++; + return makeFakeSession(); + }; + try { + await internals._resumeSession('s1'); + await internals._resumeSession('s1'); + assert.strictEqual(doResumeCalls, 2); + } finally { + await disposeAgent(agent); + } + }); + + test('clears inflight entry on rejection so the next call retries', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + let attempt = 0; + internals._doResumeSession = async () => { + attempt++; + if (attempt === 1) { + throw new Error('first failed'); + } + return makeFakeSession(); + }; + try { + await assert.rejects(() => internals._resumeSession('s1'), /first failed/); + await internals._resumeSession('s1'); + assert.strictEqual(attempt, 2); + } finally { + await disposeAgent(agent); + } + }); + + test('does not dedupe across different sessionIds', async () => { + const agent = createTestAgent(disposables); + const internals = agent as unknown as AgentInternals; + const ids: string[] = []; + internals._doResumeSession = async (id: string) => { + ids.push(id); + return makeFakeSession(); + }; + try { + await Promise.all([ + internals._resumeSession('s1'), + internals._resumeSession('s2'), + ]); + assert.deepStrictEqual([...ids].sort(), ['s1', 's2']); + } finally { + await disposeAgent(agent); + } + }); + + test('post-init shutdown race: disposes the session and throws CancellationError instead of registering on a disposed _sessions map', async () => { + // Without this guard an in-flight `_resumeSession` / + // `_materializeProvisional` whose `initializeSession()` + // resolves AFTER `dispose()` -> `shutdown()` -> `super.dispose()` + // has run would call `_sessions.set(...)` on a disposed + // DisposableMap, leaking the session and reproducing the + // 'Trying to add a disposable to a DisposableStore that has + // already been disposed' warning this PR exists to eliminate. + const agent = createTestAgent(disposables); + const internals = agent as unknown as { + _registerInitializedSession: (id: string, s: CopilotAgentSession) => void; + _shutdownPromise: Promise | undefined; + }; + let disposed = 0; + const fakeSession = { dispose: () => { disposed++; } } as unknown as CopilotAgentSession; + internals._shutdownPromise = Promise.resolve(); + try { + assert.throws( + () => internals._registerInitializedSession('s1', fakeSession), + (err: unknown) => isCancellationError(err), + ); + assert.strictEqual(disposed, 1, 'session should be disposed by the guard'); + } finally { + // Clear the fake shutdown promise so disposeAgent doesn't + // short-circuit and leave real state behind. + internals._shutdownPromise = undefined; + await disposeAgent(agent); + } + }); + }); + suite('worktree announcement', () => { // Drives a real session through worktree creation (calling the // agent's _resolveSessionWorkingDirectory via a test seam so we don't From 0ee1ce21073e02290d3b9b6ef333a9279bfec0b2 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 27 May 2026 17:30:47 -0700 Subject: [PATCH 12/18] Refactor browser error page to a feature contribution (#318637) --- .../electron-browser/browserEditor.ts | 362 ++++++---------- .../browserView.contribution.ts | 1 + .../features/browserEditorChatFeatures.ts | 6 +- .../browserEditorEmulationFeatures.ts | 6 +- .../features/browserEditorErrorFeatures.ts | 387 ++++++++++++++++++ .../features/browserEditorFindFeature.ts | 6 +- .../features/browserEditorZoomFeature.ts | 6 +- .../features/browserTabManagementFeatures.ts | 6 +- .../webContentsViewRendererFeature.ts | 13 +- .../electron-browser/siteInfoWidget.ts | 120 ------ 10 files changed, 533 insertions(+), 380 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts delete mode 100644 src/vs/workbench/contrib/browserView/electron-browser/siteInfoWidget.ts diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 9574fa766b888..c46fd387d4a20 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -6,8 +6,6 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; import { $, addDisposableListener, Dimension, EventType, IDomPosition } from '../../../../base/browser/dom.js'; -import { ButtonBar } from '../../../../base/browser/ui/button/button.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -21,19 +19,17 @@ import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewNavigationEvent, IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; -import { isMacintosh, isLinux } from '../../../../base/common/platform.js'; import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { SiteInfoWidget } from './siteInfoWidget.js'; -import { Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); @@ -82,17 +78,20 @@ export abstract class BrowserEditorContribution extends Disposable { onModelDetached(): void { } /** - * Optional widgets to display inside the URL bar (on the right side of the URL input, - * before the actions toolbar). - * Contributions can override this getter to provide widgets. + * Widgets contributed by this feature. Each widget declares its target + * {@link BrowserWidgetLocation}; the editor groups widgets by location + * and stacks them in {@link IBrowserEditorWidget.order} order. */ - get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } + get widgets(): readonly IBrowserEditorWidget[] { return []; } /** - * Optional toolbar-like elements to insert into the editor root between the navbar and the - * browser container. Contributions can override this getter to provide elements. + * Optional renderers for the URL displayed in the navbar. Each renderer is + * given the URL and a container; the first to return `true` claims the + * render. If none claim it, the navbar falls back to plain text. Used to + * decorate URLs for special conditions (e.g. red strikethrough on the + * `https:` prefix when a certificate error is active). */ - get toolbarElements(): readonly HTMLElement[] { return []; } + get urlRenderers(): readonly IBrowserUrlRenderer[] { return []; } /** * Called when the editor is laid out with a new dimension. @@ -137,14 +136,6 @@ export abstract class BrowserEditorContribution extends Disposable { * and centers the viewport, then pixel-snap aligns it). */ beforeContainerLayout(): IContainerLayoutOverride | undefined { return undefined; } - - /** - * Content elements to mount inside the browser container's placeholder - * area (welcome screen, error page, overlay-pause message, etc.). The - * editor stacks them in {@link IBrowserContainerContent.order} order; - * each content manages its own visibility. - */ - get containerContents(): readonly IBrowserContainerContent[] { return []; } } /** Customization returned by {@link BrowserEditorContribution.beforeContainerLayout}. */ @@ -206,29 +197,55 @@ export interface IContainerLayout { }; } -/** A widget that can be contributed to the browser editor URL bar. */ -export interface IBrowserEditorWidgetContribution { - readonly element: HTMLElement; - /** Ordering value — lower numbers appear first (left). */ - readonly order: number; +/** Where a contributed widget mounts within the browser editor. */ +export const enum BrowserWidgetLocation { + /** Inside the navbar, before the URL input (e.g. site/security indicators). */ + PreUrl = 'preUrl', + /** Inside the navbar, after the URL input (e.g. zoom pill, share toggle). */ + PostUrl = 'postUrl', + /** Between the navbar and the browser container (e.g. find / emulation toolbars). */ + Toolbar = 'toolbar', + /** Inside the browser container (placeholder screenshot, error overlay, etc.). */ + ContentArea = 'contentArea', } /** - * Content that sits inside the browser container's placeholder area (welcome - * screen, error page, overlay-pause message, etc.). Each content owns its own - * visibility — the editor only stacks elements by {@link order}. + * A widget contributed by a {@link BrowserEditorContribution}. The editor + * groups widgets by {@link location} and mounts each group sorted by + * {@link order}. */ -export interface IBrowserContainerContent { +export interface IBrowserEditorWidget { + readonly location: BrowserWidgetLocation; readonly element: HTMLElement; - /** Stacking order — lower numbers are farther back (rendered first). */ + /** Stacking order within the location. Lower numbers render first. */ readonly order: number; } +/** + * Customizes how the URL is rendered into the navbar's URL display element. + * The navbar iterates contributed renderers in registration order; the first + * one to return `true` from {@link render} claims the render. If no renderer + * claims it, the navbar falls back to plain text. + */ +export interface IBrowserUrlRenderer { + /** + * Render the URL into the given (already-emptied) container. Return true if + * the URL was rendered; false to fall through to subsequent renderers. + */ + render(url: string, container: HTMLElement): boolean; + /** + * Fires when {@link render} would produce a different result for the same + * URL (e.g. underlying state changed). The navbar re-renders on this. + */ + readonly onDidChange: Event; +} + class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; private readonly _urlDisplay: HTMLElement; - private readonly _siteInfoWidget: SiteInfoWidget; + private readonly _preUrlWidgetsContainer: HTMLElement; private readonly _urlBarWidgetsContainer: HTMLElement; + private readonly _urlRenderers: IBrowserUrlRenderer[] = []; constructor( editor: BrowserEditor, @@ -266,16 +283,11 @@ class BrowserNavigationBar extends Disposable { } )); - // URL input container (wraps input + share toggle) + // URL input container (wraps pre-url widgets + input + post-url widgets) const urlContainer = $('.browser-url-container'); - // Site info widget (inside URL bar, left side, hidden by default) - const siteInfoContainer = $('.browser-site-info-slot'); - this._siteInfoWidget = this._register(instantiationService.createInstance( - SiteInfoWidget, - siteInfoContainer, - editor - )); + // Pre-URL widgets slot (e.g. site/security indicators contributed by features) + this._preUrlWidgetsContainer = $('.browser-site-info-slot'); // URL input (hidden by default; shown when user clicks the display) this._urlInput = $('input.browser-url-input'); @@ -292,7 +304,7 @@ class BrowserNavigationBar extends Disposable { this._urlBarWidgetsContainer = $('.browser-url-bar-widgets'); - urlContainer.appendChild(siteInfoContainer); + urlContainer.appendChild(this._preUrlWidgetsContainer); urlContainer.appendChild(urlInputWrapper); urlContainer.appendChild(this._urlBarWidgetsContainer); @@ -347,7 +359,7 @@ class BrowserNavigationBar extends Disposable { */ updateFromNavigationEvent(event: IBrowserViewNavigationEvent): void { this._urlInput.value = event.url; - this._updateDisplay(); + this._renderUrl(); } /** @@ -357,15 +369,6 @@ class BrowserNavigationBar extends Disposable { this._showInput(); } - /** - * Show or hide the site info indicator - */ - setCertificateError(certError: IBrowserViewCertificateError | undefined): void { - this._siteInfoWidget.setCertificateError(certError); - this._urlInput.classList.toggle('cert-error', !!certError); - this._updateDisplay(); - } - /** * Switch to input-editing mode: hide display, show and focus input. */ @@ -377,56 +380,68 @@ class BrowserNavigationBar extends Disposable { } /** - * Add widget elements inside the URL bar, sorted by order. + * Add widget elements to the pre-URL slot (left of the URL input), sorted by order. + */ + addPreUrlWidgets(widgets: readonly IBrowserEditorWidget[]): void { + const sorted = widgets.slice().sort((a, b) => a.order - b.order); + for (const widget of sorted) { + this._preUrlWidgetsContainer.appendChild(widget.element); + } + } + + /** + * Add widget elements to the post-URL slot (right of the URL input), sorted by order. */ - addUrlBarWidgets(widgets: readonly IBrowserEditorWidgetContribution[]): void { + addUrlBarWidgets(widgets: readonly IBrowserEditorWidget[]): void { const sorted = widgets.slice().sort((a, b) => a.order - b.order); for (const widget of sorted) { this._urlBarWidgetsContainer.appendChild(widget.element); } } + /** + * Register URL renderers. The navbar re-renders when any renderer's + * `onDidChange` fires. + */ + addUrlRenderers(renderers: readonly IBrowserUrlRenderer[]): void { + for (const renderer of renderers) { + this._urlRenderers.push(renderer); + this._register(renderer.onDidChange(() => this._renderUrl())); + } + this._renderUrl(); + } + /** * Switch to display mode: hide the input and show the styled display. */ private _showDisplay(): void { this._urlInput.style.display = 'none'; this._urlDisplay.style.display = ''; - this._updateDisplay(); + this._renderUrl(); } /** - * Rebuild the display element's content. When there is a cert error - * and the URL starts with "https://", the protocol is rendered with - * a red strikethrough; otherwise the full URL is shown plainly. + * Rebuild the display element's content. Tries each contributed URL renderer + * in order; falls back to plain text if none claims the URL. */ - private _updateDisplay(): void { + private _renderUrl(): void { const url = this._urlInput.value; - const hasCertError = this._urlInput.classList.contains('cert-error'); - const httpsPrefix = 'https:'; - // Clear previous content this._urlDisplay.textContent = ''; this._urlDisplay.classList.toggle('placeholder', !url); - if (hasCertError && url.startsWith(httpsPrefix)) { - const protocol = document.createElement('span'); - protocol.className = 'browser-url-display-protocol-bad'; - protocol.textContent = httpsPrefix; - this._urlDisplay.appendChild(protocol); - - const rest = document.createElement('span'); - rest.textContent = url.slice(httpsPrefix.length); - this._urlDisplay.appendChild(rest); - } else { - this._urlDisplay.textContent = url || localize('browser.urlPlaceholder', "Enter a URL"); + for (const renderer of this._urlRenderers) { + if (renderer.render(url, this._urlDisplay)) { + return; + } } + + this._urlDisplay.textContent = url || localize('browser.urlPlaceholder', "Enter a URL"); } clear(): void { this._urlInput.value = ''; - this._siteInfoWidget.setCertificateError(undefined); - this._updateDisplay(); + this._renderUrl(); } } @@ -460,15 +475,12 @@ export class BrowserEditor extends EditorPane { private _browserContainerWrapper!: HTMLElement; private _browserContainer!: HTMLElement; get browserContainer(): HTMLElement { return this._browserContainer; } - private _errorContainer!: HTMLElement; private _welcomeContainer!: HTMLElement; private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; private _hasUrlContext!: IContextKey; - private _hasErrorContext!: IContextKey; private readonly _inputDisposables = this._register(new DisposableStore()); - private readonly _certActionButton = this._register(new MutableDisposable()); private _currentPadding: { top: number; right: number; bottom: number; left: number } = { top: 0, right: 0, bottom: 0, left: 0 }; constructor( @@ -491,7 +503,6 @@ export class BrowserEditor extends EditorPane { this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); - this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); @@ -518,20 +529,35 @@ export class BrowserEditor extends EditorPane { // Create navigation bar widget with scoped context this._navigationBar = this._register(new BrowserNavigationBar(this, navbar, this.instantiationService, contextKeyService)); - // Inject URL bar widgets from contributions - const allWidgets: IBrowserEditorWidgetContribution[] = []; + // Collect widgets from all contributions, grouped by location. + const widgetsByLocation = new Map(); + const urlRenderers: IBrowserUrlRenderer[] = []; for (const contribution of this._contributionInstances.values()) { - allWidgets.push(...contribution.urlBarWidgets); + for (const widget of contribution.widgets) { + let bucket = widgetsByLocation.get(widget.location); + if (!bucket) { + bucket = []; + widgetsByLocation.set(widget.location, bucket); + } + bucket.push(widget); + } + urlRenderers.push(...contribution.urlRenderers); + } + for (const bucket of widgetsByLocation.values()) { + bucket.sort((a, b) => a.order - b.order); } - this._navigationBar.addUrlBarWidgets(allWidgets); + const widgetsAt = (location: BrowserWidgetLocation): readonly IBrowserEditorWidget[] => + widgetsByLocation.get(location) ?? []; + + this._navigationBar.addPreUrlWidgets(widgetsAt(BrowserWidgetLocation.PreUrl)); + this._navigationBar.addUrlBarWidgets(widgetsAt(BrowserWidgetLocation.PostUrl)); + this._navigationBar.addUrlRenderers(urlRenderers); root.appendChild(navbar); - // Collect toolbar elements from contributions (e.g. find widget container) - for (const contribution of this._contributionInstances.values()) { - for (const element of contribution.toolbarElements) { - root.appendChild(element); - } + // Toolbar widgets — appended between the navbar and the container. + for (const widget of widgetsAt(BrowserWidgetLocation.Toolbar)) { + root.appendChild(widget.element); } // Create browser container wrapper (flex item that fills remaining space) @@ -551,24 +577,14 @@ export class BrowserEditor extends EditorPane { // Wrapper around placeholder contents for border radius clipping. Holds // contribution-provided content (placeholder screenshot, overlay-pause) - // plus the editor-owned error and welcome layers. + // plus the editor-owned welcome layer. const placeholderContents = $('.browser-placeholder-contents'); this._browserContainer.appendChild(placeholderContents); - // Collect and stack container contents from contributions. - const contents: IBrowserContainerContent[] = []; - for (const contribution of this._contributionInstances.values()) { - contents.push(...contribution.containerContents); + // Container widgets — stacked inside the placeholder area. + for (const widget of widgetsAt(BrowserWidgetLocation.ContentArea)) { + placeholderContents.appendChild(widget.element); } - contents.sort((a, b) => a.order - b.order); - for (const content of contents) { - placeholderContents.appendChild(content.element); - } - - // Create error container (hidden by default) - this._errorContainer = $('.browser-error-container'); - this._errorContainer.style.display = 'none'; - placeholderContents.appendChild(this._errorContainer); // Create welcome container (shown when no URL is loaded) this._welcomeContainer = this.createWelcomeContainer(); @@ -638,10 +654,6 @@ export class BrowserEditor extends EditorPane { this.updateNavigationState(navEvent); })); - this._inputDisposables.add(this._model.onDidChangeLoadingState(() => { - this.updateErrorDisplay(); - })); - // Listen for workbench zoom level changes and update browser view placeholder screenshot's zoom factor this._inputDisposables.add(onDidChangeZoomLevel(targetWindowId => { if (targetWindowId === this.window.vscodeWindowId) { @@ -654,7 +666,6 @@ export class BrowserEditor extends EditorPane { } })); - this.updateErrorDisplay(); this.layout(); this.updateVisibility(); } @@ -686,138 +697,13 @@ export class BrowserEditor extends EditorPane { private updateVisibility(): void { // Welcome container: shown when no URL is loaded this._welcomeContainer.style.display = this._model?.url ? 'none' : ''; - - // Error container: shown when there's a load error - this._errorContainer.style.display = this._model?.error ? '' : 'none'; } - private updateErrorDisplay(): void { - if (!this._model) { - return; - } - - const error: IBrowserViewLoadError | undefined = this._model.error; - this._hasErrorContext.set(!!error); - - this._navigationBar.setCertificateError( - this._model.certificateError ?? error?.certificateError - ); - - if (error) { - // Update error content - this._certActionButton.clear(); - - while (this._errorContainer.firstChild) { - this._errorContainer.removeChild(this._errorContainer.firstChild); - } - - const errorContent = $('.browser-error-content'); - const isCertError = !!error.certificateError; - - const errorIcon = $('.browser-error-icon'); - errorIcon.classList.toggle('cert-error', isCertError); - errorIcon.appendChild(renderIcon(isCertError ? Codicon.workspaceUntrusted : Codicon.globe)); - - const errorTitle = $('.browser-error-title'); - errorTitle.textContent = isCertError - ? localize('browser.certErrorLabel', "Certificate Error") - : localize('browser.loadErrorLabel', "Failed to Load Page"); - - const errorMessage = $('.browser-error-detail'); - const errorText = $('span'); - errorText.textContent = isCertError - ? localize('browser.certErrorDescription', "This site's security certificate could not be verified.") - : `${error.errorDescription} (${error.errorCode})`; - errorMessage.appendChild(errorText); - - const errorUrl = $('.browser-error-detail'); - const urlLabel = $('strong'); - urlLabel.textContent = localize('browser.errorUrlLabel', "URL:"); - const urlValue = $('code'); - urlValue.textContent = error.url; - errorUrl.appendChild(urlLabel); - errorUrl.appendChild(document.createTextNode(' ')); - errorUrl.appendChild(urlValue); - - errorContent.appendChild(errorIcon); - errorContent.appendChild(errorTitle); - errorContent.appendChild(errorMessage); - - // Show cert error name below description, above URL - if (error.certificateError) { - const extraWarning = $('b.browser-error-detail'); - extraWarning.textContent = localize('browser.certErrorExtraWarning', " Your connection is not private."); - errorMessage.appendChild(extraWarning); - } - - errorContent.appendChild(errorUrl); - - // Show certificate details table and actions - if (error.certificateError) { - const certError = error.certificateError; - - const certDetailsTable = $('.browser-cert-details-table'); - - const heading = $('.browser-cert-details-heading'); - heading.textContent = localize('browser.certDetailsHeading', "Certificate Details"); - certDetailsTable.appendChild(heading); - - const addRow = (label: string, value: string) => { - const row = $('.browser-cert-details-row'); - const labelEl = $('.browser-cert-details-label'); - labelEl.textContent = label; - const valueEl = $('.browser-cert-details-value'); - valueEl.textContent = value; - row.appendChild(labelEl); - row.appendChild(valueEl); - certDetailsTable.appendChild(row); - }; - - addRow(localize('browser.certError', "Error"), certError.error); - addRow(localize('browser.certIssuer', "Issuer"), certError.issuerName); - addRow(localize('browser.certSubject', "Subject"), certError.subjectName); - - const formatDate = (epoch: number) => new Date(epoch * 1000).toLocaleDateString(); - addRow( - localize('browser.certValid', "Valid"), - `${formatDate(certError.validStart)} - ${formatDate(certError.validExpiry)}` - ); - - addRow(localize('browser.certFingerprint', "Fingerprint"), certError.fingerprint); - - errorContent.appendChild(certDetailsTable); - - const actionContainer = $('.browser-cert-action'); - actionContainer.classList.toggle('reverse', isMacintosh || isLinux); - const canGoBack = this._model.canGoBack; - const buttonBar = new ButtonBar(actionContainer); - this._certActionButton.value = buttonBar; - - const primaryButton = buttonBar.addButton({ ...defaultButtonStyles }); - primaryButton.label = canGoBack - ? localize('browser.certGoBack', "Go Back") - : localize('browser.certCloseTab', "Close Tab"); - primaryButton.onDidClick(() => { - if (canGoBack) { - this.goBack(); - } else { - this.group?.closeEditor(this.input); - } - }); - - const secondaryButton = buttonBar.addButton({ ...defaultButtonStyles, secondary: true }); - secondaryButton.label = localize('browser.certProceed', "Proceed anyway (unsafe)"); - secondaryButton.onDidClick(() => { - this._model?.trustCertificate(certError.host, certError.fingerprint); - }); - - errorContent.appendChild(actionContainer); - } - - this._errorContainer.appendChild(errorContent); - } - - this.updateVisibility(); + /** + * Close this editor tab (i.e. the editor input owning the current page). + */ + closeTab(): void { + this.group?.closeEditor(this.input); } getUrl(): string | undefined { @@ -883,7 +769,6 @@ export class BrowserEditor extends EditorPane { private updateNavigationState(event: IBrowserViewNavigationEvent): void { // Update navigation bar UI this._navigationBar.updateFromNavigationEvent(event); - this._navigationBar.setCertificateError(event.certificateError); // Update context keys for command enablement this._canGoBackContext.set(event.canGoBack); @@ -1042,7 +927,6 @@ export class BrowserEditor extends EditorPane { this._canGoBackContext.reset(); this._canGoForwardContext.reset(); this._hasUrlContext.reset(); - this._hasErrorContext.reset(); this._navigationBar.clear(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 21bf5bd32b0df..4a83ec477a70a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -25,6 +25,7 @@ import './features/webContentsViewRendererFeature.js'; import './features/browserDataStorageFeatures.js'; import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; +import './features/browserEditorErrorFeatures.js'; import './features/browserEditorZoomFeature.js'; import './features/browserEditorEmulationFeatures.js'; import './features/browserEditorFindFeature.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 3c1a4f2ebf06b..bdd67a8d33c19 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -32,7 +32,7 @@ import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; -import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrowserEditorWidget, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; @@ -155,8 +155,8 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { })); } - override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { - return [{ element: this._shareButtonContainer, order: 50 }]; + override get widgets(): readonly IBrowserEditorWidget[] { + return [{ location: BrowserWidgetLocation.PostUrl, element: this._shareButtonContainer, order: 50 }]; } protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts index 03fdea00bfac4..362256df1fa02 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts @@ -29,7 +29,7 @@ import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quic import { defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IBrowserViewModel } from '../../common/browserView.js'; -import { BrowserEditor, BrowserEditorContribution, IContainerLayout, IContainerLayoutOverride } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrowserEditorWidget, IContainerLayout, IContainerLayoutOverride } from '../browserEditor.js'; import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; const CONTEXT_BROWSER_EMULATION_TOOLBAR_VISIBLE = new RawContextKey( @@ -394,8 +394,8 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { // -- BrowserEditorContribution hooks ------------------------------------ - override get toolbarElements(): readonly HTMLElement[] { - return [this._toolbar.element]; + override get widgets(): readonly IBrowserEditorWidget[] { + return [{ location: BrowserWidgetLocation.Toolbar, element: this._toolbar.element, order: 0 }]; } override onContainerCreated(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts new file mode 100644 index 0000000000000..e637628eb489d --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts @@ -0,0 +1,387 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { $, addDisposableListener, EventType } from '../../../../../base/browser/dom.js'; +import { ButtonBar } from '../../../../../base/browser/ui/button/button.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { isLinux, isMacintosh } from '../../../../../base/common/platform.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IBrowserViewCertificateError, IBrowserViewLoadError } from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { + BrowserEditor, + BrowserEditorContribution, + BrowserWidgetLocation, + CONTEXT_BROWSER_HAS_ERROR, + IBrowserEditorWidget, + IBrowserUrlRenderer, +} from '../browserEditor.js'; + +/** + * Renders the full-pane error overlay (load failures and certificate errors) + * inside the browser container, plus drives the navbar's cert indicator and + * the cert-aware URL display rendering. + * + * Subscribes to model loading-state and navigation events; rebuilds the DOM + * and updates the `browserHasError` context key on each transition. When the + * underlying load error carries certificate info, an additional details table + * and trust/back action buttons are rendered inline. The site-info widget + * ("Not Secure" indicator) is contributed as a pre-URL widget and the cert + * URL renderer marks the `https:` prefix when a cert error is active. + */ +class BrowserEditorErrorFeatures extends BrowserEditorContribution { + + private readonly _element = $('.browser-error-container'); + private readonly _certActionButton = this._register(new MutableDisposable()); + private readonly _hasErrorContext: IContextKey; + private readonly _content: IBrowserEditorWidget; + + private readonly _siteInfoSlot = $('.browser-site-info-slot-wrapper'); + private readonly _siteInfoWidget: SiteInfoWidget; + private readonly _preUrlWidget: IBrowserEditorWidget; + private readonly _urlRenderer = this._register(new CertUrlRenderer()); + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(editor); + this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); + this._element.style.display = 'none'; + // Sit above the placeholder screenshot and overlay-pause (orders 100/200). + this._content = { location: BrowserWidgetLocation.ContentArea, element: this._element, order: 300 }; + + this._siteInfoWidget = this._register(instantiationService.createInstance(SiteInfoWidget, this._siteInfoSlot, editor)); + this._preUrlWidget = { location: BrowserWidgetLocation.PreUrl, element: this._siteInfoSlot, order: 0 }; + } + + override get widgets(): readonly IBrowserEditorWidget[] { + return [this._content, this._preUrlWidget]; + } + + override get urlRenderers(): readonly IBrowserUrlRenderer[] { + return [this._urlRenderer]; + } + + protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { + store.add(model.onDidChangeLoadingState(() => this._updateError())); + store.add(model.onDidNavigate(() => this._updateCertState())); + this._updateError(); + } + + override onModelDetached(): void { + this._hasErrorContext.reset(); + this._clearContent(); + this._element.style.display = 'none'; + this._siteInfoWidget.setCertificateError(undefined); + this._urlRenderer.setCertificateError(undefined); + } + + private _updateError(): void { + const model = this.editor.model; + if (!model) { + return; + } + const error = model.error; + this._hasErrorContext.set(!!error); + this._updateCertState(); + + if (!error) { + this._element.style.display = 'none'; + return; + } + + this._clearContent(); + this._element.appendChild(this._renderError(error)); + this._element.style.display = ''; + } + + private _updateCertState(): void { + const model = this.editor.model; + // Cover both paths: the cert from the most recent successful navigation + // (model.certificateError, set when the user trusted a cert this session) + // and the cert that caused the current load error. + const cert = model?.certificateError ?? model?.error?.certificateError; + this._siteInfoWidget.setCertificateError(cert); + this._urlRenderer.setCertificateError(cert); + } + + private _clearContent(): void { + this._certActionButton.clear(); + while (this._element.firstChild) { + this._element.removeChild(this._element.firstChild); + } + } + + private _renderError(error: IBrowserViewLoadError): HTMLElement { + const isCertError = !!error.certificateError; + const errorContent = $('.browser-error-content'); + + const errorIcon = $('.browser-error-icon'); + errorIcon.classList.toggle('cert-error', isCertError); + errorIcon.appendChild(renderIcon(isCertError ? Codicon.workspaceUntrusted : Codicon.globe)); + + const errorTitle = $('.browser-error-title'); + errorTitle.textContent = isCertError + ? localize('browser.certErrorLabel', "Certificate Error") + : localize('browser.loadErrorLabel', "Failed to Load Page"); + + const errorMessage = $('.browser-error-detail'); + const errorText = $('span'); + errorText.textContent = isCertError + ? localize('browser.certErrorDescription', "This site's security certificate could not be verified.") + : `${error.errorDescription} (${error.errorCode})`; + errorMessage.appendChild(errorText); + + // Show cert error name below description, above URL + if (error.certificateError) { + const extraWarning = $('b.browser-error-detail'); + extraWarning.textContent = localize('browser.certErrorExtraWarning', " Your connection is not private."); + errorMessage.appendChild(extraWarning); + } + + const errorUrl = $('.browser-error-detail'); + const urlLabel = $('strong'); + urlLabel.textContent = localize('browser.errorUrlLabel', "URL:"); + const urlValue = $('code'); + urlValue.textContent = error.url; + errorUrl.appendChild(urlLabel); + errorUrl.appendChild(document.createTextNode(' ')); + errorUrl.appendChild(urlValue); + + errorContent.appendChild(errorIcon); + errorContent.appendChild(errorTitle); + errorContent.appendChild(errorMessage); + errorContent.appendChild(errorUrl); + + if (error.certificateError) { + errorContent.appendChild(this._renderCertDetails(error.certificateError)); + errorContent.appendChild(this._renderCertActions(error.certificateError)); + } + + return errorContent; + } + + private _renderCertDetails(certError: IBrowserViewCertificateError): HTMLElement { + const certDetailsTable = $('.browser-cert-details-table'); + + const heading = $('.browser-cert-details-heading'); + heading.textContent = localize('browser.certDetailsHeading', "Certificate Details"); + certDetailsTable.appendChild(heading); + + const addRow = (label: string, value: string) => { + const row = $('.browser-cert-details-row'); + const labelEl = $('.browser-cert-details-label'); + labelEl.textContent = label; + const valueEl = $('.browser-cert-details-value'); + valueEl.textContent = value; + row.appendChild(labelEl); + row.appendChild(valueEl); + certDetailsTable.appendChild(row); + }; + + addRow(localize('browser.certError', "Error"), certError.error); + addRow(localize('browser.certIssuer', "Issuer"), certError.issuerName); + addRow(localize('browser.certSubject', "Subject"), certError.subjectName); + + const formatDate = (epoch: number) => new Date(epoch * 1000).toLocaleDateString(); + addRow( + localize('browser.certValid', "Valid"), + `${formatDate(certError.validStart)} - ${formatDate(certError.validExpiry)}` + ); + + addRow(localize('browser.certFingerprint', "Fingerprint"), certError.fingerprint); + + return certDetailsTable; + } + + private _renderCertActions(certError: IBrowserViewCertificateError): HTMLElement { + const actionContainer = $('.browser-cert-action'); + actionContainer.classList.toggle('reverse', isMacintosh || isLinux); + + const canGoBack = this.editor.model?.canGoBack ?? false; + const buttonBar = new ButtonBar(actionContainer); + this._certActionButton.value = buttonBar; + + const primaryButton = buttonBar.addButton({ ...defaultButtonStyles }); + primaryButton.label = canGoBack + ? localize('browser.certGoBack', "Go Back") + : localize('browser.certCloseTab', "Close Tab"); + primaryButton.onDidClick(() => { + if (canGoBack) { + this.editor.goBack(); + } else { + this.editor.closeTab(); + } + }); + + const secondaryButton = buttonBar.addButton({ ...defaultButtonStyles, secondary: true }); + secondaryButton.label = localize('browser.certProceed', "Proceed anyway (unsafe)"); + secondaryButton.onDidClick(() => { + this.editor.model?.trustCertificate(certError.host, certError.fingerprint); + }); + + return actionContainer; + } +} + +/** + * URL renderer that, when a certificate error is active, splits an `https:` + * prefix into its own span (styled with a red strikethrough via CSS). Other + * URLs (and non-cert-error states) fall through to plain text. + */ +class CertUrlRenderer implements IBrowserUrlRenderer { + private static readonly HTTPS_PREFIX = 'https:'; + + private readonly _onDidChange = new Emitter(); + readonly onDidChange: Event = this._onDidChange.event; + + private _hasCertError = false; + + setCertificateError(certError: IBrowserViewCertificateError | undefined): void { + const next = !!certError; + if (this._hasCertError === next) { + return; + } + this._hasCertError = next; + this._onDidChange.fire(); + } + + render(url: string, container: HTMLElement): boolean { + if (!this._hasCertError || !url.startsWith(CertUrlRenderer.HTTPS_PREFIX)) { + return false; + } + + const protocol = document.createElement('span'); + protocol.className = 'browser-url-display-protocol-bad'; + protocol.textContent = CertUrlRenderer.HTTPS_PREFIX; + container.appendChild(protocol); + + const rest = document.createElement('span'); + rest.textContent = url.slice(CertUrlRenderer.HTTPS_PREFIX.length); + container.appendChild(rest); + + return true; + } + + dispose(): void { + this._onDidChange.dispose(); + } +} + +/** + * Indicator button inside the URL bar that surfaces site security information + * (e.g. certificate errors). Click/Enter shows a hover popover with details + * and (if the user has previously trusted the cert) a revoke action. + */ +class SiteInfoWidget extends Disposable { + + private readonly _container: HTMLElement; + private readonly _indicator: HTMLElement; + private _certError: IBrowserViewCertificateError | undefined; + + constructor( + parent: HTMLElement, + private readonly _editor: BrowserEditor, + @IHoverService private readonly _hoverService: IHoverService, + ) { + super(); + + this._container = $('.browser-site-info-container'); + this._container.style.display = 'none'; + + this._indicator = $('.browser-site-info-indicator'); + this._indicator.tabIndex = 0; + this._indicator.role = 'button'; + this._indicator.ariaLabel = localize('browser.notSecure', "Not Secure"); + this._indicator.appendChild(renderIcon(Codicon.workspaceUntrusted)); + this._container.appendChild(this._indicator); + + parent.appendChild(this._container); + + this._register(addDisposableListener(this._indicator, EventType.CLICK, () => this._showHover())); + this._register(addDisposableListener(this._indicator, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this._showHover(); + } + })); + } + + /** Update visibility and state from a certificate error (or lack thereof). */ + setCertificateError(certError: IBrowserViewCertificateError | undefined): void { + this._certError = certError; + this._container.style.display = certError ? '' : 'none'; + } + + private _showHover(): void { + const certError = this._certError; + if (!certError) { + return; + } + + const content = document.createElement('div'); + content.classList.add('browser-site-info-hover-content'); + + const heading = document.createElement('div'); + heading.classList.add('browser-site-info-hover-heading'); + heading.textContent = localize('browser.certHoverHeading', "Certificate Not Trusted"); + content.appendChild(heading); + + const detail1 = document.createElement('div'); + detail1.classList.add('browser-site-info-hover-detail'); + detail1.textContent = localize('browser.certHoverDetail1', "Your connection to this site is not secure."); + content.appendChild(detail1); + + if (certError.hasTrustedException) { + const detail2 = document.createElement('div'); + detail2.classList.add('browser-site-info-hover-detail'); + detail2.textContent = localize( + 'browser.certHoverDetail2', + "You previously chose to proceed to '{0}' despite a certificate error ({1}).", + certError.host, + certError.error + ); + content.appendChild(detail2); + + const revokeLink = document.createElement('a'); + revokeLink.classList.add('browser-site-info-hover-revoke'); + revokeLink.textContent = localize('browser.certRevoke', "Revoke and Close"); + revokeLink.role = 'button'; + revokeLink.tabIndex = 0; + revokeLink.addEventListener('click', () => { + hover?.dispose(); + this._editor.revokeAndClose(certError); + }); + revokeLink.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + hover?.dispose(); + this._editor.revokeAndClose(certError); + } + }); + content.appendChild(revokeLink); + } + + const hover = this._hoverService.showInstantHover({ + content, + target: this._indicator, + container: this._container, + position: { hoverPosition: HoverPosition.BELOW }, + persistence: { sticky: true } + }, true); + } +} + +BrowserEditor.registerContribution(BrowserEditorErrorFeatures); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts index 9d6389e3e2680..0e9af3a651d69 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -22,7 +22,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; -import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, IBrowserEditorWidget } from '../browserEditor.js'; import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); @@ -232,8 +232,8 @@ export class BrowserEditorFindContribution extends BrowserEditorContribution { /** * The container element to insert below the toolbar. */ - override get toolbarElements(): readonly HTMLElement[] { - return [this._findWidgetContainer]; + override get widgets(): readonly IBrowserEditorWidget[] { + return [{ location: BrowserWidgetLocation.Toolbar, element: this._findWidgetContainer, order: 0 }]; } protected override onModelAttached(model: IBrowserViewModel, _store: DisposableStore): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts index 9d8fc1cddb97c..f55e6171179a7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -20,7 +20,7 @@ import { browserZoomFactors, browserZoomLabel, browserZoomAccessibilityLabel } f import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../../../browserView/common/browserZoomService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; -import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED, IBrowserEditorWidgetContribution } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED, IBrowserEditorWidget } from '../browserEditor.js'; import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { getZoomLevel, onDidChangeZoomLevel } from '../../../../../base/browser/browser.js'; @@ -87,8 +87,8 @@ export class BrowserEditorZoomSupport extends BrowserEditorContribution { this._zoomPill = this._register(new BrowserZoomPill()); } - override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { - return [{ element: this._zoomPill.element, order: 0 }]; + override get widgets(): readonly IBrowserEditorWidget[] { + return [{ location: BrowserWidgetLocation.PostUrl, element: this._zoomPill.element, order: 0 }]; } protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index fe6b373876e83..c1d931ad0bc83 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -37,7 +37,7 @@ import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/t import { Registry } from '../../../../../platform/registry/common/platform.js'; import { match } from '../../../../../base/common/glob.js'; import { $, addDisposableListener, EventType } from '../../../../../base/browser/dom.js'; -import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrowserEditorWidget } from '../browserEditor.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -671,8 +671,8 @@ class LinkOpenedHintPill extends BrowserEditorContribution { })); } - override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { - return [{ element: this._pill, order: 100 }]; + override get widgets(): readonly IBrowserEditorWidget[] { + return [{ location: BrowserWidgetLocation.PostUrl, element: this._pill, order: 100 }]; } protected override onModelAttached(_model: IBrowserViewModel, _store: DisposableStore, isNew: boolean): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts index 9cbccbbc44a5a..039debf97b7ac 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts @@ -18,7 +18,8 @@ import { IBrowserViewModel } from '../../common/browserView.js'; import { BrowserEditor, BrowserEditorContribution, - IBrowserContainerContent, + BrowserWidgetLocation, + IBrowserEditorWidget, IContainerLayout, IContainerLayoutOverride, } from '../browserEditor.js'; @@ -50,8 +51,8 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { private readonly _overlayPauseEl = $('.browser-overlay-paused'); private readonly _overlayManager: BrowserOverlayManager; - private readonly _placeholderContent: IBrowserContainerContent; - private readonly _overlayPauseContent: IBrowserContainerContent; + private readonly _placeholderContent: IBrowserEditorWidget; + private readonly _overlayPauseContent: IBrowserEditorWidget; private readonly _screenshotHandle = this._register(new MutableDisposable()); private _focusTimeout: ReturnType | undefined; @@ -75,14 +76,14 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { message.appendChild(detail); this._overlayPauseEl.appendChild(message); - this._placeholderContent = { element: this._placeholderScreenshot, order: 100 }; - this._overlayPauseContent = { element: this._overlayPauseEl, order: 200 }; + this._placeholderContent = { location: BrowserWidgetLocation.ContentArea, element: this._placeholderScreenshot, order: 100 }; + this._overlayPauseContent = { location: BrowserWidgetLocation.ContentArea, element: this._overlayPauseEl, order: 200 }; this._register(this._overlayManager.onDidChangeOverlayState(() => this._refreshOverlayObscured())); this._refresh(); } - override get containerContents(): readonly IBrowserContainerContent[] { + override get widgets(): readonly IBrowserEditorWidget[] { return [this._placeholderContent, this._overlayPauseContent]; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/siteInfoWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/siteInfoWidget.ts deleted file mode 100644 index 5ec296b0cef5b..0000000000000 --- a/src/vs/workbench/contrib/browserView/electron-browser/siteInfoWidget.ts +++ /dev/null @@ -1,120 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../../../nls.js'; -import { $, addDisposableListener, EventType } from '../../../../base/browser/dom.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IBrowserViewCertificateError } from '../../../../platform/browserView/common/browserView.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import type { BrowserEditor } from './browserEditor.js'; - -/** - * Widget that displays site security information (e.g. certificate errors) - * as an indicator button inside the URL bar, with a hover popover for details. - */ -export class SiteInfoWidget extends Disposable { - - private readonly _container: HTMLElement; - private readonly _indicator: HTMLElement; - - constructor( - parent: HTMLElement, - private readonly editor: BrowserEditor, - @IHoverService private readonly hoverService: IHoverService - ) { - super(); - - this._container = $('.browser-site-info-container'); - this._container.style.display = 'none'; - - this._indicator = $('.browser-site-info-indicator'); - this._indicator.tabIndex = 0; - this._indicator.role = 'button'; - this._indicator.ariaLabel = localize('browser.notSecure', "Not Secure"); - this._indicator.appendChild(renderIcon(Codicon.workspaceUntrusted)); - this._container.appendChild(this._indicator); - - parent.appendChild(this._container); - - this._register(addDisposableListener(this._indicator, EventType.CLICK, () => this._showHover())); - this._register(addDisposableListener(this._indicator, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this._showHover(); - } - })); - } - - /** - * Update visibility and state from a certificate error (or lack thereof). - */ - setCertificateError(certError: IBrowserViewCertificateError | undefined): void { - this._container.style.display = certError ? '' : 'none'; - } - - private _showHover(): void { - const certError = this.editor.getCertificateError(); - if (!certError) { - return; - } - - const content = document.createElement('div'); - content.classList.add('browser-site-info-hover-content'); - - const heading = document.createElement('div'); - heading.classList.add('browser-site-info-hover-heading'); - heading.textContent = localize('browser.certHoverHeading', "Certificate Not Trusted"); - content.appendChild(heading); - - const detail1 = document.createElement('div'); - detail1.classList.add('browser-site-info-hover-detail'); - detail1.textContent = localize( - 'browser.certHoverDetail1', - "Your connection to this site is not secure." - ); - content.appendChild(detail1); - - if (certError.hasTrustedException) { - const detail2 = document.createElement('div'); - detail2.classList.add('browser-site-info-hover-detail'); - detail2.textContent = localize( - 'browser.certHoverDetail2', - "You previously chose to proceed to '{0}' despite a certificate error ({1}).", - certError.host, - certError.error - ); - content.appendChild(detail2); - - const revokeLink = document.createElement('a'); - revokeLink.classList.add('browser-site-info-hover-revoke'); - revokeLink.textContent = localize('browser.certRevoke', "Revoke and Close"); - revokeLink.role = 'button'; - revokeLink.tabIndex = 0; - revokeLink.addEventListener('click', () => { - hover?.dispose(); - this.editor.revokeAndClose(certError); - }); - revokeLink.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - hover?.dispose(); - this.editor.revokeAndClose(certError); - } - }); - content.appendChild(revokeLink); - } - - const hover = this.hoverService.showInstantHover({ - content, - target: this._indicator, - container: this._container, - position: { hoverPosition: HoverPosition.BELOW }, - persistence: { sticky: true } - }, true); - } -} From d3b895504ab630a07a6fea27bfbbc67998f19e31 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 27 May 2026 18:04:40 -0700 Subject: [PATCH 13/18] Read cloud_session_storage_enabled from /copilot_internal/user instead of /v2/token (#318643) --- src/vs/base/common/defaultAccount.ts | 1 + .../workbench/services/accounts/browser/defaultAccount.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 192fd07276002..f916a3799e769 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -39,6 +39,7 @@ export interface IEntitlementsData extends ILegacyQuotaSnapshotData { readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time) readonly token_based_billing?: boolean; readonly can_upgrade_plan?: boolean; + readonly cloud_session_storage_enabled?: boolean; readonly quota_snapshots?: { chat?: IQuotaSnapshotData; completions?: IQuotaSnapshotData; diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 079d7db8fc63c..c23007c4f84ff 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -552,12 +552,15 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const tokenEntitlementsFetchedAt: number | undefined = tokenEntitlementsResult?.fetchedAt; let mcpRegistryDataFetchedAt: number | undefined; let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; + if (entitlementsData) { + policyData = policyData ?? {}; + policyData.cloud_session_storage_enabled = entitlementsData.cloud_session_storage_enabled; + } if (tokenEntitlementsResult?.data) { const tokenEntitlementsData = tokenEntitlementsResult.data; policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.policyData.chat_agent_enabled; policyData.chat_preview_features_enabled = tokenEntitlementsData.policyData.chat_preview_features_enabled; - policyData.cloud_session_storage_enabled = tokenEntitlementsData.policyData.cloud_session_storage_enabled; policyData.mcp = tokenEntitlementsData.policyData.mcp; if (policyData.mcp) { const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData, options); @@ -679,8 +682,6 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid chat_agent_enabled: tokenMap.get('agent_mode') !== '0', // MCP is only enabled if the flag is explicitly present and set to 1 mcp: tokenMap.get('mcp') === '1', - // Cloud session storage policy boolean from Copilot token; undefined when not present - cloud_session_storage_enabled: tokenMap.has('cloud_session_storage_enabled') ? tokenMap.get('cloud_session_storage_enabled') === '1' : undefined, }, copilotTokenInfo: { sn: tokenMap.get('sn'), From 843588fd833110583a733b1bd5e8895a9572d634 Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 27 May 2026 18:12:00 -0700 Subject: [PATCH 14/18] chore: bump electron 42 header version for msvc compatibility (#318587) --- .github/workflows/pr-node-modules.yml | 3 - .npmrc | 2 +- build/.cachesalt | 2 +- .../product-build-win32-node-modules.yml | 3 - .../azure-pipelines/win32/sdl-scan-win32.yml | 3 - .../steps/product-build-win32-compile.yml | 3 - build/lib/electron.ts | 3 +- build/npm/gyp/custom-headers/cppgc/heap.h | 226 ------------------ build/npm/preinstall.ts | 2 - 9 files changed, 4 insertions(+), 243 deletions(-) delete mode 100644 build/npm/gyp/custom-headers/cppgc/heap.h diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index de3a8af8962cc..1f83561abb101 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -251,9 +251,6 @@ jobs: run: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - # Run preinstall script before root dependencies are installed - # so that Electron/v8 headers are patched correctly for native modules. - exec { node build/npm/preinstall.ts } for ($i = 1; $i -le 5; $i++) { try { exec { npm ci } diff --git a/.npmrc b/.npmrc index 66bcebe736bf8..8c21e58ef14d5 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ disturl="https://electronjs.org/headers" -target="42.2.0" +target="42.3.0" ms_build_id="14159160" runtime="electron" ignore-scripts=false diff --git a/build/.cachesalt b/build/.cachesalt index dc913d548599d..57519bc2df234 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-05-13T00:00:00.000Z +2026-05-27T16:21:42.961Z diff --git a/build/azure-pipelines/win32/product-build-win32-node-modules.yml b/build/azure-pipelines/win32/product-build-win32-node-modules.yml index 681713e5bc111..6780073f57af7 100644 --- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml +++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml @@ -70,9 +70,6 @@ jobs: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - # Run preinstall script before root dependencies are installed - # so that Electron/v8 headers are patched correctly for native modules. - exec { node build/npm/preinstall.ts } exec { npm ci } env: npm_config_arch: $(VSCODE_ARCH) diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index 7cdb889cc2994..6c28f30a6f8a3 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -82,9 +82,6 @@ steps: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - # Run preinstall script before root dependencies are installed - # so that Electron/v8 headers are patched correctly for native modules. - exec { node build/npm/preinstall.ts } exec { npm ci } env: npm_config_arch: ${{ parameters.VSCODE_ARCH }} diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index 5e331e041d6d1..6a93ab2aa2c1f 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -78,9 +78,6 @@ steps: - powershell: | . build/azure-pipelines/win32/exec.ps1 $ErrorActionPreference = "Stop" - # Run preinstall script before root dependencies are installed - # so that Electron/v8 headers are patched correctly for native modules. - exec { node build/npm/preinstall.ts } exec { npm ci } env: npm_config_arch: $(VSCODE_ARCH) diff --git a/build/lib/electron.ts b/build/lib/electron.ts index 43b15a96c3b91..016c25f7553db 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -100,7 +100,8 @@ function darwinBundleDocumentTypes(types: { [name: string]: string | string[] }, }); } -const { electronVersion, msBuildId } = util.getElectronVersion(); +const { msBuildId } = util.getElectronVersion(); +const electronVersion = '42.2.0'; export const config = { version: electronVersion, diff --git a/build/npm/gyp/custom-headers/cppgc/heap.h b/build/npm/gyp/custom-headers/cppgc/heap.h deleted file mode 100644 index e07603ab6bff6..0000000000000 --- a/build/npm/gyp/custom-headers/cppgc/heap.h +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright 2020 the V8 project authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#ifndef INCLUDE_CPPGC_HEAP_H_ -#define INCLUDE_CPPGC_HEAP_H_ - -#include -#include -#include -#if defined(_MSC_VER) && !defined(__clang__) -#include -#endif -#include - -#include "cppgc/common.h" -#include "cppgc/custom-space.h" -#include "cppgc/platform.h" -#include "v8config.h" // NOLINT(build/include_directory) - -/** - * cppgc - A C++ garbage collection library. - */ -namespace cppgc { - -class AllocationHandle; -class HeapHandle; - -/** - * Implementation details of cppgc. Those details are considered internal and - * may change at any point in time without notice. Users should never rely on - * the contents of this namespace. - */ -namespace internal { -class Heap; -} // namespace internal - -/** - * A marker that captures the current stack start address. - */ -class V8_EXPORT StackStartMarker { - public: -#if defined(_MSC_VER) && !defined(__clang__) - StackStartMarker() : stack_start_(_AddressOfReturnAddress()) {} -#else - StackStartMarker() : stack_start_(__builtin_frame_address(0)) {} -#endif - void* stack_start() const { return stack_start_; } - - private: - void* stack_start_; -}; - -class V8_EXPORT Heap { - public: - /** - * Specifies the stack state the embedder is in. - */ - using StackState = EmbedderStackState; - - /** - * Specifies whether conservative stack scanning is supported. - */ - enum class StackSupport : uint8_t { - /** - * Conservative stack scan is supported. - */ - kSupportsConservativeStackScan, - /** - * Conservative stack scan is not supported. Embedders may use this option - * when using custom infrastructure that is unsupported by the library. - */ - kNoConservativeStackScan, - }; - - /** - * Specifies supported marking types. - */ - enum class MarkingType : uint8_t { - /** - * Atomic stop-the-world marking. This option does not require any write - * barriers but is the most intrusive in terms of jank. - */ - kAtomic, - /** - * Incremental marking interleaves marking with the rest of the application - * workload on the same thread. - */ - kIncremental, - /** - * Incremental and concurrent marking. - */ - kIncrementalAndConcurrent - }; - - /** - * Specifies supported sweeping types. - */ - enum class SweepingType : uint8_t { - /** - * Atomic stop-the-world sweeping. All of sweeping is performed at once. - */ - kAtomic, - /** - * Incremental sweeping interleaves sweeping with the rest of the - * application workload on the same thread. - */ - kIncremental, - /** - * Incremental and concurrent sweeping. Sweeping is split and interleaved - * with the rest of the application. - */ - kIncrementalAndConcurrent - }; - - /** - * Constraints for a Heap setup. - */ - struct ResourceConstraints { - /** - * Allows the heap to grow to some initial size in bytes before triggering - * garbage collections. This is useful when it is known that applications - * need a certain minimum heap to run to avoid repeatedly invoking the - * garbage collector when growing the heap. - */ - size_t initial_heap_size_bytes = 0; - }; - - /** - * Options specifying Heap properties (e.g. custom spaces) when initializing a - * heap through `Heap::Create()`. - */ - struct HeapOptions { - /** - * Creates reasonable defaults for instantiating a Heap. - * - * \returns the HeapOptions that can be passed to `Heap::Create()`. - */ - static HeapOptions Default() { return {}; } - - /** - * Custom spaces added to heap are required to have indices forming a - * numbered sequence starting at 0, i.e., their `kSpaceIndex` must - * correspond to the index they reside in the vector. - */ - std::vector> custom_spaces; - - /** - * Specifies whether conservative stack scan is supported. When conservative - * stack scan is not supported, the collector may try to invoke - * garbage collections using non-nestable task, which are guaranteed to have - * no interesting stack, through the provided Platform. If such tasks are - * not supported by the Platform, the embedder must take care of invoking - * the GC through `ForceGarbageCollectionSlow()`. - */ - StackSupport stack_support = StackSupport::kSupportsConservativeStackScan; - - /** - * Specifies which types of marking are supported by the heap. - */ - MarkingType marking_support = MarkingType::kIncrementalAndConcurrent; - - /** - * Specifies which types of sweeping are supported by the heap. - */ - SweepingType sweeping_support = SweepingType::kIncrementalAndConcurrent; - - /** - * Resource constraints specifying various properties that the internal - * GC scheduler follows. - */ - ResourceConstraints resource_constraints; - - /** - * Optional marker representing the stack start of the thread creating the - * heap. - */ - std::optional stack_start_marker = std::nullopt; - }; - /** - * Creates a new heap that can be used for object allocation. - * - * \param platform implemented and provided by the embedder. - * \param options HeapOptions specifying various properties for the Heap. - * \returns a new Heap instance. - */ - static std::unique_ptr Create( - std::shared_ptr platform, - HeapOptions options = HeapOptions::Default()); - - virtual ~Heap() = default; - - /** - * Forces garbage collection. - * - * \param source String specifying the source (or caller) triggering a - * forced garbage collection. - * \param reason String specifying the reason for the forced garbage - * collection. - * \param stack_state The embedder stack state, see StackState. - */ - void ForceGarbageCollectionSlow( - const char* source, const char* reason, - StackState stack_state = StackState::kMayContainHeapPointers); - - /** - * \returns the opaque handle for allocating objects using - * `MakeGarbageCollected()`. - */ - AllocationHandle& GetAllocationHandle(); - - /** - * \returns the opaque heap handle which may be used to refer to this heap in - * other APIs. Valid as long as the underlying `Heap` is alive. - */ - HeapHandle& GetHeapHandle(); - - private: - Heap() = default; - - friend class internal::Heap; -}; - -} // namespace cppgc - -#endif // INCLUDE_CPPGC_HEAP_H_ diff --git a/build/npm/preinstall.ts b/build/npm/preinstall.ts index 9cd91e2802143..54a377a65d22b 100644 --- a/build/npm/preinstall.ts +++ b/build/npm/preinstall.ts @@ -146,8 +146,6 @@ function installHeaders() { // the downloaded Electron headers. This is used to work around upstream issues: // - v8-source-location.h: remove dependency on std::source_location (GCC 11+ requirement) // Refs https://chromium-review.googlesource.com/c/v8/v8/+/6879784 - // - cppgc/heap.h: replace GCC/Clang-only __builtin_frame_address(0) with the - // MSVC equivalent _AddressOfReturnAddress() so native modules build with MSVC. if (local !== undefined) { const localHeaderPath = getLocalHeaderPath(local.target); if (localHeaderPath && fs.existsSync(localHeaderPath)) { From b0d10ad25b3d71b131fd9858ce5b009e9da23014 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 27 May 2026 18:54:26 -0700 Subject: [PATCH 15/18] use pixel spinner for all sessions lists (#318656) --- .../browser/ui/pixelSpinner/pixelSpinner.css | 50 +++++++ .../browser/ui/pixelSpinner/pixelSpinner.ts | 18 ++- .../sessions/browser/media/sessionsList.css | 2 + .../sessions/browser/views/sessionsList.ts | 126 ++++++++++++++---- 4 files changed, 165 insertions(+), 31 deletions(-) diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css index df4c30565a2f9..94f914c53ee9e 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css @@ -97,6 +97,56 @@ } } +/* Ring variant: same 2×3 grid layout as the cascading variant, but the + * highlight travels clockwise around the perimeter of the 6 dots: + * [1][2] 1 → 2 + * [3][4] ↑ ↓ + * [5][6] 3 4 + * ↑ ↓ + * 5 ← 6 + */ + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot { + animation: monaco-pixel-spinner-ring-pulse 1200ms linear infinite; + opacity: 0.25; + transform: none; +} + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(1) { + animation-delay: 0ms; +} + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(2) { + animation-delay: 200ms; +} + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(3) { + animation-delay: 1000ms; +} + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(4) { + animation-delay: 400ms; +} + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(5) { + animation-delay: 800ms; +} + +.monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot:nth-child(6) { + animation-delay: 600ms; +} + +@keyframes monaco-pixel-spinner-ring-pulse { + 0%, 100% { + opacity: 0.25; + transform: none; + } + 16.67% { + opacity: 1; + transform: none; + } +} + @media (prefers-reduced-motion: reduce) { .monaco-pixel-spinner .monaco-pixel-spinner-dot { animation: none; diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts index c7a6b4d732edb..384dee889bbdd 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts @@ -15,13 +15,19 @@ export interface IPixelSpinnerOptions { * conveys the busy state. */ readonly ariaLabel?: string; + + /** + * Visual variant of the spinner. + * - `'grid'` (default): six dots in a 2×3 grid that cascade vertically. + * - `'ring'`: six dots arranged in a circle with a highlight that orbits the ring. + */ + readonly variant?: 'grid' | 'ring'; } /** - * Creates a small pixel-art style spinner consisting of six animated dots - * arranged in a 2×3 grid. Color is driven by `currentColor`, so consumers - * can control the visual color via the parent element's `color` style or - * by setting `style.color` directly on the returned element. + * Creates a small pixel-art style spinner. Color is driven by `currentColor`, + * so consumers can control the visual color via the parent element's `color` + * style or by setting `style.color` directly on the returned element. * * Respects `prefers-reduced-motion` by disabling the animation. * @@ -30,7 +36,9 @@ export interface IPixelSpinnerOptions { * @returns The spinner root element. */ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinnerOptions): HTMLElement { - const root = h('span.monaco-pixel-spinner').root; + const variant = options?.variant ?? 'grid'; + const rootClass = variant === 'ring' ? 'span.monaco-pixel-spinner.monaco-pixel-spinner-ring' : 'span.monaco-pixel-spinner'; + const root = h(rootClass).root; if (options?.ariaLabel) { root.setAttribute('role', 'status'); root.setAttribute('aria-label', options.ariaLabel); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index aee0d58e874bb..6e350617ea492 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -108,6 +108,8 @@ display: flex; align-items: flex-start; line-height: 17px; + /* Anchor for outgoing icons during a cross-fade swap (see swapIconWithCrossfade). */ + position: relative; > .codicon { flex-shrink: 0; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index a2d20e17f8041..abe02e68313df 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -21,6 +21,7 @@ import { IObservable, IReader, autorun, observableSignalFromEvent } from '../../ import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { fromNow } from '../../../../../base/common/date.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; import { localize } from '../../../../../nls.js'; import { MenuId, IMenuService } from '../../../../../platform/actions/common/actions.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; @@ -56,6 +57,56 @@ import { buildSessionHoverContent } from '../sessionHoverContent.js'; const $ = DOM.$; +// Sentinel values stored on the row template's `currentIconSelector` when the +// icon container holds a pixel spinner (vs. a codicon). Distinct values per +// spinner variant so transitions between variants correctly rebuild the DOM, +// while transitions that keep the same variant just update the color and avoid +// restarting the CSS animation. +const PIXEL_SPINNER_GRID_KEY = '__pixel_spinner_grid__'; +const PIXEL_SPINNER_RING_KEY = '__pixel_spinner_ring__'; + +// Duration of the cross-fade when the icon swaps to a different glyph/variant. +const ICON_SWAP_FADE_MS = 180; + +// Marker dataset key on the outgoing element during a cross-fade swap. Lets a +// follow-up swap (before the previous fade finishes) skip re-processing it. +const ICON_FADING_OUT_ATTR = 'iconFadingOut'; + +/** + * Swap the contents of `container` to `newChild` with a brief opacity cross-fade. + * Outgoing children (if any) are taken out of normal flow via `position: absolute` + * so the new child can settle into its normal grid/flex slot during the fade. + * The container must be `position: relative` for the absolute positioning to anchor. + * + * The provided `store` owns the removal timer for the outgoing element so the + * swap is cleaned up if the row/template is disposed mid-fade. Safe to call + * repeatedly: rapid successive swaps each mark their own outgoing element and + * never re-process one already fading out. + */ +function swapIconWithCrossfade(container: HTMLElement, newChild: HTMLElement, animate: boolean, store: DisposableStore): void { + if (!animate) { + DOM.clearNode(container); + container.appendChild(newChild); + return; + } + for (const existing of Array.from(container.children) as HTMLElement[]) { + if (existing.dataset[ICON_FADING_OUT_ATTR] === '1') { + continue; + } + existing.dataset[ICON_FADING_OUT_ATTR] = '1'; + existing.style.position = 'absolute'; + existing.style.top = '0'; + existing.style.left = '0'; + existing.style.transition = `opacity ${ICON_SWAP_FADE_MS}ms ease`; + DOM.scheduleAtNextAnimationFrame(DOM.getWindow(existing), () => { existing.style.opacity = '0'; }); + disposableTimeout(() => existing.remove(), ICON_SWAP_FADE_MS + 40, store); + } + newChild.style.opacity = '0'; + newChild.style.transition = `opacity ${ICON_SWAP_FADE_MS}ms ease`; + container.appendChild(newChild); + DOM.scheduleAtNextAnimationFrame(DOM.getWindow(newChild), () => { newChild.style.opacity = '1'; }); +} + export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); export const SessionItemContextMenuId = new MenuId('SessionItemContextMenu'); export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); @@ -313,39 +364,60 @@ class SessionItemRenderer implements ITreeRenderer HTMLElement): boolean => { + if (template.currentIconSelector === key) { + return false; + } + const animate = template.currentIconSelector !== undefined; + template.currentIconSelector = key; + swapIconWithCrossfade(template.iconContainer, createIcon(), animate, template.disposables); + return true; + }; + const recolorActiveIcon = (color: string): void => { + for (const child of Array.from(template.iconContainer.children) as HTMLElement[]) { + if (child.dataset[ICON_FADING_OUT_ATTR] !== '1') { + child.style.color = color; + break; + } + } + }; + + const isPixelSpinner = (sessionStatus === SessionStatus.InProgress || sessionStatus === SessionStatus.NeedsInput) && !motionReduced; if (isPixelSpinner) { - const pixelSpinnerKey = '__pixel_spinner__'; - const iconColor = asCssVariable('textLink.foreground'); - if (template.currentIconSelector !== pixelSpinnerKey) { - template.currentIconSelector = pixelSpinnerKey; - DOM.clearNode(template.iconContainer); - const spinner = createPixelSpinner(template.iconContainer); + const isNeedsInput = sessionStatus === SessionStatus.NeedsInput; + const variant: 'grid' | 'ring' = isNeedsInput ? 'ring' : 'grid'; + const spinnerKey = isNeedsInput ? PIXEL_SPINNER_RING_KEY : PIXEL_SPINNER_GRID_KEY; + const iconColor = isNeedsInput ? asCssVariable('list.warningForeground') : asCssVariable('textLink.foreground'); + const swapped = applyIconSwap(spinnerKey, () => { + const spinner = createPixelSpinner(undefined, { variant }); spinner.style.color = iconColor; - } else { - const spinner = template.iconContainer.firstElementChild as HTMLElement | null; - if (spinner) { - spinner.style.color = iconColor; - } + return spinner; + }); + if (!swapped) { + recolorActiveIcon(iconColor); } } else { const icon = this.getStatusIcon(sessionStatus, isRead, isArchived, gitHubInfo?.pullRequest?.icon); const iconSelector = ThemeIcon.asCSSSelector(icon); const iconColor = icon.color ? asCssVariable(icon.color.id) : ''; - - if (iconSelector !== template.currentIconSelector) { - template.currentIconSelector = iconSelector; - DOM.clearNode(template.iconContainer); - const iconSpan = DOM.append(template.iconContainer, $(`span${iconSelector}`)); + const swapped = applyIconSwap(iconSelector, () => { + const iconSpan = $(`span${iconSelector}`); iconSpan.style.color = iconColor; - } else { - const iconSpan = template.iconContainer.firstElementChild as HTMLElement | null; - if (iconSpan) { - iconSpan.style.color = iconColor; - } + return iconSpan; + }); + if (!swapped) { + recolorActiveIcon(iconColor); } } template.iconContainer.classList.toggle('session-icon-pulse', sessionStatus === SessionStatus.NeedsInput); @@ -560,7 +632,9 @@ class SessionItemRenderer implements ITreeRenderer Date: Wed, 27 May 2026 19:59:46 -0700 Subject: [PATCH 16/18] Bump 500 mb for Mac dmg (#318670) Bump 500 mb for mac dmg --- build/darwin/dmg-settings.py.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/darwin/dmg-settings.py.template b/build/darwin/dmg-settings.py.template index f471029f32a2a..1d53c5df3fc5e 100644 --- a/build/darwin/dmg-settings.py.template +++ b/build/darwin/dmg-settings.py.template @@ -7,7 +7,7 @@ badge_icon = {{BADGE_ICON}} background = {{BACKGROUND}} # Volume size -size = '1g' +size = '1.5g' shrink = False # Files and symlinks From 3cb23da891f869ee2cb57bdf785df72323fe2d0e Mon Sep 17 00:00:00 2001 From: Maruthan G <113752568+maruthang@users.noreply.github.com> Date: Thu, 28 May 2026 08:36:08 +0530 Subject: [PATCH 17/18] fix: combine URI flags to prevent Electron argument filtering on Windows (#308150) * fix: combine URI flags to prevent Electron argument filtering on Windows On Windows, Electron/Chromium's security layer filters out standalone command-line arguments that look like URLs (containing "://"). This causes --folder-uri and --file-uri to fail silently when the URI value is not the last argument. Combine --folder-uri and --file-uri with their values using "=" syntax before spawning the Electron process, so Chromium treats them as flags rather than standalone URL arguments. Fixes #209072 * Stop rewriting --folder-uri / --file-uri past the -- end-of-options marker --------- Co-authored-by: Dmitriy Vasyura --- src/vs/code/node/cli.ts | 8 +++- src/vs/code/node/cliArgs.ts | 28 +++++++++++ src/vs/code/test/node/cliArgs.test.ts | 67 +++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/vs/code/node/cliArgs.ts create mode 100644 src/vs/code/test/node/cliArgs.test.ts diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index e390b34f4f6f5..994f6c38d99bb 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -17,6 +17,7 @@ import { watchFileContents } from '../../platform/files/node/watcher/nodejs/node import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { buildHelpMessage, buildStdinMessage, buildVersionMessage, NATIVE_CLI_COMMANDS, OPTIONS } from '../../platform/environment/node/argv.js'; import { addArg, parseCLIProcessArgv } from '../../platform/environment/node/argvHelper.js'; +import { combineUriFlags } from './cliArgs.js'; import { getStdinFilePath, hasStdinWithoutTty, readFromStdin, stdinDataListener } from '../../platform/environment/node/stdin.js'; import { createWaitMarkerFileSync } from '../../platform/environment/node/wait.js'; import product from '../../platform/product/common/product.js'; @@ -492,8 +493,13 @@ export async function main(argv: string[]): Promise { options['stdio'] = ['ignore', 'pipe', 'ignore']; // restore ability to see output when --status is used } + // On Windows, Chromium filters standalone URL-like argv tokens (containing "://") + // before main.js runs, so rewrite `--folder-uri ` / `--file-uri ` to + // `--flag=value` form. See https://github.com/microsoft/vscode/issues/209072. + const spawnArgs = isWindows ? combineUriFlags(argv.slice(2)) : argv.slice(2); + // We spawn the resolved executable directly - child = spawn(process.execPath, argv.slice(2), options); + child = spawn(process.execPath, spawnArgs, options); } else { // On macOS, we spawn using the open command to obtain behavior // similar to if the app was launched from the dock diff --git a/src/vs/code/node/cliArgs.ts b/src/vs/code/node/cliArgs.ts new file mode 100644 index 0000000000000..2419ae6d622e2 --- /dev/null +++ b/src/vs/code/node/cliArgs.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Rewrites `--folder-uri ` / `--file-uri ` pairs into a single + * `--flag=value` token so the URI is not a standalone argv entry. Used on + * Windows to avoid Chromium filtering URL-like tokens before main.js runs. + * See https://github.com/microsoft/vscode/issues/209072. + */ +export function combineUriFlags(args: string[]): string[] { + const result: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--') { // end-of-options marker: copy the rest verbatim + result.push(...args.slice(i)); + break; + } + if ((arg === '--folder-uri' || arg === '--file-uri') && i + 1 < args.length && !args[i + 1].startsWith('-')) { + result.push(`${arg}=${args[i + 1]}`); + i++; // skip the value, it's now part of the flag + } else { + result.push(arg); + } + } + return result; +} diff --git a/src/vs/code/test/node/cliArgs.test.ts b/src/vs/code/test/node/cliArgs.test.ts new file mode 100644 index 0000000000000..c9d7f87493d86 --- /dev/null +++ b/src/vs/code/test/node/cliArgs.test.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { combineUriFlags } from '../../node/cliArgs.js'; + +suite('combineUriFlags', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('rewrites --folder-uri and --file-uri followed by a URI into --flag=value', () => { + assert.deepStrictEqual( + combineUriFlags([ + '--wait', + '--folder-uri', 'vscode-remote://ssh-remote+host/workspace', + '--file-uri', 'vscode-remote://ssh-remote+host/file.txt', + '--new-window', + '--folder-uri=vscode-remote://already-joined/workspace', + '--folder-uri', // trailing flag with no value + ]), + [ + '--wait', + '--folder-uri=vscode-remote://ssh-remote+host/workspace', + '--file-uri=vscode-remote://ssh-remote+host/file.txt', + '--new-window', + '--folder-uri=vscode-remote://already-joined/workspace', + '--folder-uri', + ] + ); + }); + + test('does not join when next argument is a flag', () => { + assert.deepStrictEqual( + combineUriFlags(['--folder-uri', '--wait', 'somepath']), + ['--folder-uri', '--wait', 'somepath'] + ); + }); + + test('leaves unrelated arguments untouched', () => { + assert.deepStrictEqual( + combineUriFlags(['--wait', '--new-window', 'C:\\some\\path']), + ['--wait', '--new-window', 'C:\\some\\path'] + ); + }); + + test('does not rewrite past the -- end-of-options marker', () => { + assert.deepStrictEqual( + combineUriFlags([ + '--wait', + '--folder-uri', 'vscode-remote://host/before', + '--', + '--folder-uri', 'vscode-remote://host/after', + '--file-uri', 'vscode-remote://host/file.txt', + ]), + [ + '--wait', + '--folder-uri=vscode-remote://host/before', + '--', + '--folder-uri', 'vscode-remote://host/after', + '--file-uri', 'vscode-remote://host/file.txt', + ] + ); + }); +}); From 0dabf4aae4ca3aa41496c8ac460df95ec6f77f83 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 27 May 2026 20:28:45 -0700 Subject: [PATCH 18/18] Add env var to disable disposable tracking (#318672) For perf work Co-authored-by: Copilot --- .vscode/launch.json | 1 + .../contrib/performance/browser/performance.contribution.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 0c417d9875a74..709ebc458c2fd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -309,6 +309,7 @@ "VSCODE_EXTHOST_WILL_SEND_SOCKET": null, "VSCODE_SKIP_PRELAUNCH": "1", "VSCODE_DEV_DEBUG_OBSERVABLES": "1", + // "VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING": "1", }, "cleanUp": "wholeBrowser", "killBehavior": "polite", diff --git a/src/vs/workbench/contrib/performance/browser/performance.contribution.ts b/src/vs/workbench/contrib/performance/browser/performance.contribution.ts index 37bb9c7cdb740..87364c25995c6 100644 --- a/src/vs/workbench/contrib/performance/browser/performance.contribution.ts +++ b/src/vs/workbench/contrib/performance/browser/performance.contribution.ts @@ -5,6 +5,7 @@ import { EventProfiling } from '../../../../base/common/event.js'; import { GCBasedDisposableTracker, setDisposableTracker } from '../../../../base/common/lifecycle.js'; +import { env } from '../../../../base/common/process.js'; import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -167,7 +168,7 @@ Registry.as(ConfigExt.Configuration).registerConfigurati class DisposableTracking { static readonly Id = 'perf.disposableTracking'; constructor(@IEnvironmentService envService: IEnvironmentService) { - if (!envService.isBuilt && !envService.extensionTestsLocationURI) { + if (!envService.isBuilt && !envService.extensionTestsLocationURI && !env['VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING']) { setDisposableTracker(new GCBasedDisposableTracker()); } }