From bad7035a29a781bfa5d77f7df6da9d2d95475103 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:58:52 -0700 Subject: [PATCH 01/36] Add onDidDispose event for chat sessions input states This isn't fully hooked up since input states don't have a full lifecycle yet in the service Co-authored-by: Copilot --- .../api/common/extHostChatSessions.ts | 48 ++++++++++++++++++- .../vscode.proposed.chatSessionsProvider.d.ts | 5 ++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 7653e08d8a461..fa38c786bb0b9 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -32,6 +32,7 @@ import { IExtHostRpcService } from './extHostRpcService.js'; import * as typeConvert from './extHostTypeConverters.js'; import { Diagnostic } from './extHostTypeConverters.js'; import * as extHostTypes from './extHostTypes.js'; +import { isEqual } from '../../../base/common/resources.js'; type ChatSessionTiming = vscode.ChatSessionItem['timing']; @@ -44,6 +45,9 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState { readonly #onDidChangeEmitter = new Emitter(); readonly onDidChange = this.#onDidChangeEmitter.event; + readonly #onDidDisposeEmitter = new Emitter(); + readonly onDidDispose = this.#onDidDisposeEmitter.event; + #sessionResource: vscode.Uri | undefined; get sessionResource(): vscode.Uri | undefined { return this.#sessionResource; @@ -81,6 +85,12 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState { _setGroups(groups: readonly vscode.ChatSessionProviderOptionGroup[]): void { this.#groups = groups; } + + _dispose(): void { + this.#onDidDisposeEmitter.fire(); + this.#onDidDisposeEmitter.dispose(); + this.#onDidChangeEmitter.dispose(); + } } // #endregion @@ -556,6 +566,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, dispose: () => { isDisposed = true; + for (const inputState of inputStates) { + inputState._dispose(); + } + inputStates.clear(); disposables.dispose(); }, }); @@ -626,6 +640,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio ); if (inputState instanceof ChatSessionInputStateImpl) { + // Dispose any previous input states for this session resource + if (controllerData) { + this._disposeInputStatesForResource(controllerData.inputStates, sessionResource); + } + if (isUntitledChatSession(sessionResource)) { inputState.untitledSessionResource = sessionResource; } else { @@ -779,15 +798,22 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise { - const entry = this._extHostChatSessions.get(URI.revive(sessionResource)); + const resource = URI.revive(sessionResource); + const entry = this._extHostChatSessions.get(resource); if (!entry) { this._logService.warn(`No chat session found for resource: ${sessionResource}`); return; } + // Dispose input states associated with this session + const controllerData = this.getChatSessionItemController(resource.scheme); + if (controllerData) { + this._disposeInputStatesForResource(controllerData.inputStates, resource); + } + entry.disposeCts.cancel(); entry.sessionObj.sessionDisposables.dispose(); - this._extHostChatSessions.delete(URI.revive(sessionResource)); + this._extHostChatSessions.delete(resource); } async $invokeChatSessionRequestHandler(handle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise { @@ -856,6 +882,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return undefined; } + private _disposeInputStatesForResource(inputStates: Set, resource: URI): void { + for (const inputState of inputStates) { + const inputResource = inputState.sessionResource ?? inputState.untitledSessionResource; + if (inputResource && isEqual(resource, inputResource)) { + inputState._dispose(); + inputStates.delete(inputState); + } + } + } + private _createInputStateFromOptions( groups: readonly vscode.ChatSessionProviderOptionGroup[], sessionOptions?: ReadonlyArray<{ optionId: string; value: string }>, @@ -898,6 +934,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio ); if (result) { if (result instanceof ChatSessionInputStateImpl) { + // Dispose any previous input states for this session resource + if (sessionResource && controllerData) { + this._disposeInputStatesForResource(controllerData.inputStates, sessionResource); + } + if (sessionResource && isUntitledChatSession(sessionResource)) { result.untitledSessionResource = sessionResource; } else if (sessionResource) { @@ -1137,6 +1178,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } if (inputState instanceof ChatSessionInputStateImpl && sessionResource) { + // Dispose any previous input states for this session resource + this._disposeInputStatesForResource(controllerData.inputStates, sessionResource); + if (isUntitledChatSession(sessionResource)) { inputState.untitledSessionResource = sessionResource; } else { diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index d3240d451a0af..01f277e7e356b 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -715,6 +715,11 @@ declare module 'vscode' { * Represents the current state of user inputs for a chat session. */ export interface ChatSessionInputState { + /** + * Fired when the input state is disposed. + */ + readonly onDidDispose: Event; + /** * Fired when the input state is changed by the user. * From 29178d2c4225d3079cf2bd318a9bcab92fb67a19 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 23 Apr 2026 18:40:29 -0400 Subject: [PATCH 02/36] sessions: update Open in VS Code titlebar button with hover animation and full-color icon - Add custom BaseActionViewItem widget that renders the Open in VS Code button with a slide-in label animation on hover/focus and a full-color VS Code logo that desaturates at rest via CSS filter - Reorder the button before the Run Tasks button (order 7 vs 8) - Use ICommandService.executeCommand directly in onClick to bypass precondition-gated ActionRunner.run() which silently blocked clicks - Add protocol handler fallback in electron-browser action for dev builds where no sibling app is configured (launchSiblingApp fails) - Quality-tinted hover backgrounds (blue/green/orange) based on product quality, matching the companion Open in Agents button in VS Code core Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../changes/browser/changesTitleBarWidget.ts | 2 +- .../contrib/chat/browser/chat.contribution.ts | 1 + .../chat/browser/media/openInVSCode.css | 86 +++++++++++++++++++ .../chat/browser/media/vscode-icon.svg | 52 +++++++++++ .../chat/browser/openInVSCode.contribution.ts | 2 +- .../chat/browser/openInVSCodeWidget.ts | 86 +++++++++++++++++++ .../openInVSCode.contribution.ts | 77 +++++++++++++++-- 7 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/media/openInVSCode.css create mode 100644 src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg create mode 100644 src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts diff --git a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts index a57be3a52ecd9..63b7f89619467 100644 --- a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts @@ -56,7 +56,7 @@ export class ChangesTitleBarContribution extends Disposable implements IWorkbenc }, }, group: 'navigation', - order: 11, // After Run Script (8), Open in VS Code (9), and Open Terminal (10) + order: 11, // After Open in VS Code (7), Run Script (8), and Open Terminal (10) when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), })); } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index a4fe043454555..eeaeac4022403 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -17,6 +17,7 @@ import { IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../com import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; import './nullInlineChatSessionService.js'; +import './openInVSCodeWidget.js'; import './nullChatTipService.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; diff --git a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css new file mode 100644 index 0000000000000..905d17b32809c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* "Open in VS Code" titlebar widget — icon-only at rest, expands on hover/focus. */ +.monaco-workbench .open-in-vscode-titlebar-widget { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 4px; + margin: 0; + border-radius: 5px; + cursor: pointer; + color: var(--vscode-titleBar-activeForeground); + -webkit-app-region: no-drag; + white-space: nowrap; + position: relative; + touch-action: manipulation; +} + +.monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + /* Full-color VS Code logo. Uses filter: grayscale(1) at rest and + * filter: none on hover/focus to transition from monochrome to color. */ + background-image: url('./vscode-icon.svg'); + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + /* Desaturated at rest; full color on hover/focus. */ + filter: grayscale(1) opacity(0.75); + transition: filter 150ms ease; +} + +.monaco-workbench .open-in-vscode-titlebar-widget:hover > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible > .open-in-vscode-titlebar-widget-icon { + filter: none; +} + +.monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { + display: inline-block; + max-width: 0; + opacity: 0; + margin-left: 0; + color: var(--vscode-foreground); + font: inherit; + overflow: hidden; + white-space: nowrap; + transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease; +} + +.monaco-workbench .open-in-vscode-titlebar-widget:hover, +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible { + background-color: var(--vscode-toolbar-hoverBackground); + outline: none; +} + +/* Quality-tinted hover/focus background — blue (stable), green (insider), orange (exploration). */ +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"]:hover, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"]:focus-visible { + background-color: rgba(0, 122, 204, 0.18); +} + +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"]:hover, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"]:focus-visible { + background-color: rgba(36, 187, 26, 0.20); +} + +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"]:hover, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"]:focus-visible { + background-color: rgba(255, 140, 0, 0.22); +} + +.monaco-workbench .open-in-vscode-titlebar-widget:hover > .open-in-vscode-titlebar-widget-label, +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible > .open-in-vscode-titlebar-widget-label { + max-width: 200px; + opacity: 1; + margin-left: 6px; +} + +.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg b/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg new file mode 100644 index 0000000000000..39ff8ec6d02c3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts index 0dd1cb31396db..652d45c9f348d 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -41,7 +41,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', - order: 9, + order: 7, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts new file mode 100644 index 0000000000000..a4f41b4cd646a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/openInVSCode.css'; +import { $, append, EventHelper, EventLike } from '../../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; + +const OpenInVSCodeActionId = 'chat.openSessionWorktreeInVSCode'; + +/** + * Renders the "Open in VS Code" titlebar entry as an icon-only button that + * expands to reveal a label on hover / keyboard focus. + */ +class OpenInVSCodeTitleBarWidget extends BaseActionViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IProductService private readonly productService: IProductService, + @IHoverService private readonly hoverService: IHoverService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + + container.classList.add('open-in-vscode-titlebar-widget'); + container.setAttribute('role', 'button'); + + // Use the product quality for quality-tinted hover styling. + const quality = this.productService.quality ?? 'stable'; + container.setAttribute('data-product-quality', quality); + + const label = this.action.label || localize('openInVSCodeLabel', "Open in VS Code"); + container.setAttribute('aria-label', label); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, label)); + + const icon = append(container, $('span.open-in-vscode-titlebar-widget-icon')); + icon.setAttribute('aria-hidden', 'true'); + + const labelEl = append(container, $('span.open-in-vscode-titlebar-widget-label')); + labelEl.textContent = label; + } + + override onClick(event: EventLike): void { + EventHelper.stop(event, true); + this.commandService.executeCommand(OpenInVSCodeActionId); + } +} + +/** + * Workbench contribution that registers the custom action view item for + * the "Open in VS Code" action in the sessions titlebar toolbar, replacing + * the default icon-only codicon with a rich expandable widget. + */ +class OpenInVSCodeWidgetContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openInVSCode.widget'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._register(actionViewItemService.register(Menus.TitleBarSessionMenu, OpenInVSCodeActionId, (action, options) => { + return instantiationService.createInstance(OpenInVSCodeTitleBarWidget, action, options); + }, undefined)); + } +} + +registerWorkbenchContribution2(OpenInVSCodeWidgetContribution.ID, OpenInVSCodeWidgetContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 85ed0bbdbf6cf..1ab2c55265064 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -13,6 +13,7 @@ import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentH import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; @@ -27,9 +28,11 @@ import { resolveRemoteAuthority } from '../browser/openInVSCodeUtils.js'; /** * Desktop version of the "Open in VS Code" action. * - * Launches the host VS Code app via {@link INativeHostService.launchSiblingApp} - * (child_process.spawn) with direct CLI arguments, bypassing protocol handlers - * and their OS security prompts. + * In built builds with a sibling app configured, launches the host VS Code app + * via {@link INativeHostService.launchSiblingApp} (child_process.spawn) with + * direct CLI arguments, bypassing protocol handlers and their OS security + * prompts. In dev builds (no sibling app), falls back to the protocol handler + * approach via {@link IOpenerService}. */ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { static readonly ID = 'chat.openSessionWorktreeInVSCode'; @@ -43,7 +46,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', - order: 9, + order: 7, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); @@ -53,7 +56,6 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { const telemetryService = accessor.get(ITelemetryService); logSessionsInteraction(telemetryService, 'openInVSCode'); - const nativeHostService = accessor.get(INativeHostService); const productService = accessor.get(IProductService); const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); @@ -68,6 +70,27 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { ? resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService) : undefined; + const hasSibling = !!( + productService.darwinSiblingBundleIdentifier || + productService.win32SiblingExeBasename + ); + + if (hasSibling) { + await this.launchViaSiblingApp(accessor, productService, activeSession, folderUri, remoteAuthority); + } else { + await this.launchViaProtocolHandler(accessor, productService, activeSession, folderUri, remoteAuthority); + } + } + + private async launchViaSiblingApp( + accessor: ServicesAccessor, + productService: IProductService, + activeSession: ReturnType, + folderUri: URI | undefined, + remoteAuthority: string | undefined, + ): Promise { + const nativeHostService = accessor.get(INativeHostService); + const args: string[] = ['--new-window']; if (folderUri) { @@ -88,4 +111,48 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { await nativeHostService.launchSiblingApp(args); } + + private async launchViaProtocolHandler( + accessor: ServicesAccessor, + productService: IProductService, + activeSession: ReturnType, + folderUri: URI | undefined, + remoteAuthority: string | undefined, + ): Promise { + const openerService = accessor.get(IOpenerService); + + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : productService.quality === 'insider' + ? 'vscode-insiders' + : productService.urlProtocol; + + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + + if (!activeSession || !folderUri) { + await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); + return; + } + + params.set('session', activeSession.resource.toString()); + + if (remoteAuthority) { + await openerService.open(URI.from({ + scheme, + authority: Schemas.vscodeRemote, + path: `/${remoteAuthority}${folderUri.path}`, + query: params.toString(), + }), { openExternal: true }); + } else { + await openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: folderUri.path, + query: params.toString(), + }), { openExternal: true }); + } + } }); From 607f2debf33426f1c1748bc8fb93d41e41a85390 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 23 Apr 2026 19:03:11 -0400 Subject: [PATCH 03/36] Use distro code-icon.svg in prod, shield fallback in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only set data-product-quality attribute when productService.quality is defined (not in dev builds where quality is undefined) - CSS default background-image uses the bundled ./vscode-icon.svg shield (shown in dev builds where no quality attribute is present) - Added per-quality CSS rules pointing to ../../../../../workbench/browser/media/code-icon.svg — the file that vscode-distro overwrites at packaging time with the quality-specific branded VS Code icon (stable/insider/exploration) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/media/openInVSCode.css | 15 +++++++++++++-- .../contrib/chat/browser/openInVSCodeWidget.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css index 905d17b32809c..bde4110f4896d 100644 --- a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css +++ b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css @@ -23,8 +23,10 @@ width: 16px; height: 16px; flex: 0 0 auto; - /* Full-color VS Code logo. Uses filter: grayscale(1) at rest and - * filter: none on hover/focus to transition from monochrome to color. */ + /* Dev fallback: the VS Code shield logo bundled in the sessions media folder. + * In production builds the distro mixin overwrites + * vs/workbench/browser/media/code-icon.svg with the quality-branded icon; + * the per-quality rules below then take precedence. */ background-image: url('./vscode-icon.svg'); background-repeat: no-repeat; background-position: center center; @@ -34,6 +36,15 @@ transition: filter 150ms ease; } +/* In production builds vscode-distro overlays vs/workbench/browser/media/code-icon.svg + * with the quality-specific branded VS Code icon. Use it whenever the product quality is + * known (the data-product-quality attribute is only set in non-dev builds). */ +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"] > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"] > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"] > .open-in-vscode-titlebar-widget-icon { + background-image: url('../../../../../workbench/browser/media/code-icon.svg'); +} + .monaco-workbench .open-in-vscode-titlebar-widget:hover > .open-in-vscode-titlebar-widget-icon, .monaco-workbench .open-in-vscode-titlebar-widget:focus-visible > .open-in-vscode-titlebar-widget-icon { filter: none; diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts index a4f41b4cd646a..0d783457b9115 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts @@ -42,9 +42,12 @@ class OpenInVSCodeTitleBarWidget extends BaseActionViewItem { container.classList.add('open-in-vscode-titlebar-widget'); container.setAttribute('role', 'button'); - // Use the product quality for quality-tinted hover styling. - const quality = this.productService.quality ?? 'stable'; - container.setAttribute('data-product-quality', quality); + // Set quality attribute for quality-tinted hover styling and distro icon selection. + // Only set when quality is known so that the CSS fallback icon is used in dev builds. + const quality = this.productService.quality; + if (quality) { + container.setAttribute('data-product-quality', quality); + } const label = this.action.label || localize('openInVSCodeLabel', "Open in VS Code"); container.setAttribute('aria-label', label); From 9f29c28c7edae4a3476391cbcd0d199e7cb6cbf2 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 23 Apr 2026 19:07:58 -0400 Subject: [PATCH 04/36] Address Copilot PR review feedback - widget: replace ICommandService.executeCommand with this.action.run() and remove the now-unused ICommandService dependency - electron contribution: also check productService.embedded fields in hasSibling to align with launchSiblingApp's own resolution logic in siblingApp.ts which switches on process.isEmbeddedApp - CSS: scope filter and label transitions inside .monaco-enable-motion blocks and add .monaco-reduce-motion overrides, following the pattern established by other sessions titlebar widgets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/media/openInVSCode.css | 18 ++++++++++++++++++ .../contrib/chat/browser/openInVSCodeWidget.ts | 4 +--- .../openInVSCode.contribution.ts | 4 +++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css index bde4110f4896d..ef4814bba76b8 100644 --- a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css +++ b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css @@ -33,9 +33,18 @@ background-size: contain; /* Desaturated at rest; full color on hover/focus. */ filter: grayscale(1) opacity(0.75); +} + +.monaco-enable-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench.monaco-enable-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon { transition: filter 150ms ease; } +.monaco-reduce-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon, +.monaco-workbench.monaco-reduce-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon { + transition-duration: 0ms !important; +} + /* In production builds vscode-distro overlays vs/workbench/browser/media/code-icon.svg * with the quality-specific branded VS Code icon. Use it whenever the product quality is * known (the data-product-quality attribute is only set in non-dev builds). */ @@ -59,9 +68,18 @@ font: inherit; overflow: hidden; white-space: nowrap; +} + +.monaco-enable-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label, +.monaco-workbench.monaco-enable-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease; } +.monaco-reduce-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label, +.monaco-workbench.monaco-reduce-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { + transition-duration: 0ms !important; +} + .monaco-workbench .open-in-vscode-titlebar-widget:hover, .monaco-workbench .open-in-vscode-titlebar-widget:focus-visible { background-color: var(--vscode-toolbar-hoverBackground); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts index 0d783457b9115..8f3defa9c9c4a 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts @@ -11,7 +11,6 @@ import { IAction } from '../../../../base/common/actions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; @@ -31,7 +30,6 @@ class OpenInVSCodeTitleBarWidget extends BaseActionViewItem { options: IBaseActionViewItemOptions | undefined, @IProductService private readonly productService: IProductService, @IHoverService private readonly hoverService: IHoverService, - @ICommandService private readonly commandService: ICommandService, ) { super(undefined, action, options); } @@ -62,7 +60,7 @@ class OpenInVSCodeTitleBarWidget extends BaseActionViewItem { override onClick(event: EventLike): void { EventHelper.stop(event, true); - this.commandService.executeCommand(OpenInVSCodeActionId); + this.action.run(); } } diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 1ab2c55265064..7310e009b3a4b 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -72,7 +72,9 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { const hasSibling = !!( productService.darwinSiblingBundleIdentifier || - productService.win32SiblingExeBasename + productService.win32SiblingExeBasename || + productService.embedded?.darwinSiblingBundleIdentifier || + productService.embedded?.win32SiblingExeBasename ); if (hasSibling) { From 2289e09115974aeaa92411d6df4b7a2ca631cf76 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 17:25:51 -0700 Subject: [PATCH 05/36] agentHost: finish implementing host-level settings --- .../browser/remoteAgentHostProtocolClient.ts | 6 +- .../agentHost/common/agentHostSchema.ts | 112 ++-- .../platform/agentHost/common/agentService.ts | 6 +- .../common/state/agentSubscription.ts | 2 +- .../electron-browser/agentHostService.ts | 6 +- .../agentHost/node/agentHostStateManager.ts | 17 +- .../platform/agentHost/node/agentService.ts | 4 +- .../agentHost/node/copilot/copilotAgent.ts | 7 +- .../agentHost/node/protocolServerHandler.ts | 4 +- .../agentHost/node/sessionPermissions.ts | 8 +- .../test/node/agentHostStateManager.test.ts | 10 +- .../test/node/protocolServerHandler.test.ts | 6 +- .../common/agentHostSessionsProvider.ts | 22 + .../browser/agentHostSettings.contribution.ts | 74 +++ .../agentHostSettingsFileSystemProvider.ts | 157 ++++++ .../browser/agentHostSettingsShared.ts | 512 ++++++++++++++++++ .../agentSessionSettingsFileSystemProvider.ts | 506 ++++------------- .../browser/baseAgentHostSessionsProvider.ts | 93 +++- .../browser/localAgentHostSessionsProvider.ts | 2 + .../localAgentHostSessionsProvider.test.ts | 8 +- .../browser/sessionWorkspacePicker.test.ts | 6 +- .../remoteAgentHostSessionsProvider.ts | 2 + .../remoteAgentHostSessionsProvider.test.ts | 8 +- src/vs/sessions/sessions.desktop.main.ts | 1 + src/vs/sessions/sessions.web.main.ts | 1 + .../agentHost/loggingAgentConnection.ts | 4 +- .../agentHostChatContribution.test.ts | 8 +- .../agentHostClientTools.test.ts | 6 +- .../test/browser/agentHostPty.test.ts | 6 +- 29 files changed, 1114 insertions(+), 490 deletions(-) create mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts create mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts create mode 100644 src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index bf286e095d4a6..4ddc4975a3a74 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolv import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -182,7 +182,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this._clientId, seq); } @@ -205,7 +205,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Dispatch a client action to the server. Returns the clientSeq used. */ - dispatchAction(action: SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, _clientId: string, clientSeq: number): void { this._sendNotification('dispatchAction', { clientSeq, action }); } diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 9a7dcfd63b235..88da94471d6be 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -95,18 +95,29 @@ export interface ISchema { */ assertValid(key: K, value: unknown): asserts value is SchemaValue; /** - * Returns a fully-typed values bag by validating each key of - * `defaults` against `values` and falling back to the default when + * Returns a fully-typed values bag by validating each key of the + * schema against `values` and falling back to the default when * the incoming value is missing or fails validation. * + * Semantics: for every key declared in the schema `definition`: + * - if `values[key]` validates, it is kept; + * - else if `key` is present in `defaults`, the default is used; + * - else the key is omitted from the result. + * + * This means callers MAY supply defaults for only a subset of the + * schema — keys not present in `defaults` are simply left unset + * when the incoming value is missing or invalid. This is useful + * when some properties (e.g. per-session `permissions`) should be + * inherited from a higher scope rather than materialized on every + * new session. + * * Intended for sanitizing untrusted input at protocol boundaries - * (e.g. `resolveSessionConfig`), where callers want a complete - * type-safe object rather than a throw-on-first-error response. - * Keys that fail validation are silently replaced with their - * default; use {@link values} or {@link assertValid} when you want - * a descriptive {@link ProtocolError} instead. + * (e.g. `resolveSessionConfig`). Keys that fail validation are + * silently replaced with their default or dropped; use + * {@link values} or {@link assertValid} when you want a descriptive + * {@link ProtocolError} instead. */ - validateOrDefault }>(values: Record | undefined, defaults: T): T; + validateOrDefault }>>(values: Record | undefined, defaults: T): Record; } export function createSchema(definition: D): ISchema { @@ -147,16 +158,21 @@ export function createSchema(definition: D): ISchema const narrowed: ISchemaProperty = prop; narrowed.assertValid(value, key); }, - validateOrDefault }>(values: Record | undefined, defaults: T): T { + validateOrDefault }>>(values: Record | undefined, defaults: T): Record { const result: Record = {}; - for (const key of Object.keys(defaults)) { - const raw = values?.[key]; + const raw = values ?? {}; + for (const key of Object.keys(definition)) { const prop = definition[key]; - result[key] = prop && raw !== undefined && prop.validate(raw) - ? raw - : (defaults as Record)[key]; + const candidate = raw[key]; + if (candidate !== undefined && prop.validate(candidate)) { + result[key] = candidate; + } else if (Object.prototype.hasOwnProperty.call(defaults, key)) { + result[key] = (defaults as Record)[key]; + } + // else: key not in defaults and incoming value missing/invalid + // → leave unset so higher-scope defaults can fill in. } - return result as T; + return result; }, }; } @@ -249,6 +265,32 @@ export interface IPermissionsValue { readonly deny: readonly string[]; } +const permissionsProperty = schemaProperty({ + type: 'object', + title: localize('agentHost.sessionConfig.permissions', "Permissions"), + description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), + properties: { + allow: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + deny: { + type: 'array', + title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), + items: { + type: 'string', + title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), + }, + }, + }, + default: { allow: [], deny: [] }, + sessionMutable: true, +}); + /** * Session-config properties owned by the platform itself — i.e. consumed * by the agent host rather than by any particular agent. @@ -276,29 +318,19 @@ export const platformSessionSchema = createSchema({ default: 'default', sessionMutable: true, }), - [SessionConfigKey.Permissions]: schemaProperty({ - type: 'object', - title: localize('agentHost.sessionConfig.permissions', "Permissions"), - description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."), - properties: { - allow: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - deny: { - type: 'array', - title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"), - items: { - type: 'string', - title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"), - }, - }, - }, - default: { allow: [], deny: [] }, - sessionMutable: true, - }), + [SessionConfigKey.Permissions]: permissionsProperty, +}); + +/** + * Root (agent host) config properties owned by the platform itself. + * + * Root config acts as the baseline that applies to every session: + * + * - {@link SessionConfigKey.Permissions} — host-wide allow/deny lists + * unioned with each session's own permissions when evaluating tool + * auto-approval. See `SessionPermissionManager` for the evaluation + * rules. + */ +export const platformRootSchema = createSchema({ + [SessionConfigKey.Permissions]: permissionsProperty, }); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 66005d03aa40a..032e7abc62dc6 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -13,7 +13,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolDefinition } from './state/protocol/state.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from './state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; @@ -599,7 +599,7 @@ export interface IAgentService { * it to state, triggers side effects, and echoes it back via * {@link onDidAction} with the client's origin for reconciliation. */ - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void; + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void; /** * List the contents of a directory on the agent host's filesystem. @@ -652,7 +652,7 @@ export interface IAgentConnection { getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined; // ---- Action dispatch ---------------------------------------------------- - dispatch(action: SessionAction | TerminalAction): void; + dispatch(action: RootAction | SessionAction | TerminalAction): void; // ---- Events (connection-level) ------------------------------------------ readonly onDidNotification: Event; diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index c36b92cd96320..26fd1e5be5e22 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -453,7 +453,7 @@ export class AgentSubscriptionManager extends Disposable { * Dispatch a client action. Applies optimistically to the relevant * subscription if applicable, then returns the clientSeq. */ - dispatchOptimistic(action: SessionAction | TerminalAction): number { + dispatchOptimistic(action: RootAction | SessionAction | TerminalAction): number { if (isSessionAction(action)) { const entry = this._subscriptions.get(URI.parse(action.session)); if (entry && entry.sub instanceof SessionStateSubscription) { diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 543e9c374d441..78c65c73f6772 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -17,7 +17,7 @@ import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -160,7 +160,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { unsubscribe(resource: URI): void { this._proxy.unsubscribe(resource); } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._proxy.dispatchAction(action, clientId, clientSeq); } private _nextSeq = 1; @@ -180,7 +180,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { return this._subscriptionManager.getSubscriptionUnmanaged(resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { const seq = this._subscriptionManager.dispatchOptimistic(action); this.dispatchAction(action, this.clientId, seq); } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 92b5fb624c762..9bb890e45f401 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -11,6 +11,8 @@ import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotificati import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; +import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; /** * Server-side state manager for the sessions process protocol. @@ -46,6 +48,19 @@ export class AgentHostStateManager extends Disposable { ) { super(); this._rootState = createRootState(); + // Seed the host-level configuration schema + default values so that + // RootConfigChanged actions can merge into it, and clients see the + // schema immediately upon subscribing to `agenthost:/root`. See + // `platformRootSchema` for the set of platform-owned properties. + this._rootState = { + ...this._rootState, + config: { + schema: platformRootSchema.toProtocol(), + values: platformRootSchema.validateOrDefault({}, { + [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + }), + }, + }; } private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); @@ -228,7 +243,7 @@ export class AgentHostStateManager extends Disposable { * The action is applied to state and emitted with the client's origin * so the originating client can reconcile. */ - dispatchClientAction(action: SessionAction | TerminalAction, origin: ActionOrigin): unknown { + dispatchClientAction(action: RootAction | SessionAction | TerminalAction, origin: ActionOrigin): unknown { return this._applyAndEmit(action, origin); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c6a4983c21afa..abf84f27c3cd5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -16,7 +16,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection. import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; -import { ActionType, ActionEnvelope, INotification, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type ResponsePart, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolCallCompletedState, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; @@ -415,7 +415,7 @@ export class AgentService extends Disposable implements IAgentService { // in Phase 4 (multi-client). For now this is a no-op. } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); const origin = { clientId, clientSeq }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 7b2fdad114812..de102fdedde7f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -24,7 +24,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; -import { AutoApproveLevel, IPermissionsValue, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.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'; @@ -620,7 +620,10 @@ export class CopilotAgent extends Disposable implements IAgent { const values = sessionSchema.validateOrDefault(params.config, { [SessionConfigKey.Isolation]: isolationValue, [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, - [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue, + // Permissions intentionally omitted — leave unset so auto-approval + // falls through to the host-level `permissions` default, and only + // materializes on the session once the user hits "Allow in this + // Session". ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), }); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index d01dd2de427d5..b0467bfb5c74a 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -12,7 +12,7 @@ import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { AgentSession, type IAgentService } from '../common/agentService.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; -import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type SessionAction } from '../common/state/sessionActions.js'; +import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type RootAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { AHP_AUTH_REQUIRED, @@ -181,7 +181,7 @@ export class ProtocolServerHandler extends Disposable { case 'dispatchAction': if (client) { this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); - const action = msg.params.action as SessionAction; + const action = msg.params.action as RootAction | SessionAction | TerminalAction; this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq); } break; diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index eaf18711995dd..a1955bf63d49d 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -11,8 +11,7 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentToolReadyEvent } from '../common/agentService.js'; import { platformSessionSchema } from '../common/agentHostSchema.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { ResponsePartKind, @@ -238,10 +237,13 @@ export class SessionPermissionManager extends Disposable { if (!toolName) { return false; } + // `getEffectiveValue` walks session → parent → host, so sessions + // that haven't materialized their own `permissions` yet transparently + // inherit from the host-level allow/deny lists. const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions); const allowed = permissions?.allow.includes(toolName) ?? false; if (allowed) { - this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`); + this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via permissions`); } return allowed; } diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index cf5d8aab4c183..cdbdd9b7505d9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -61,7 +61,11 @@ suite('AgentHostStateManager', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); - assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record } }; + assert.deepStrictEqual(root.agents, []); + assert.strictEqual(root.activeSessions, 0); + // Host config is seeded with the platform root schema and defaults. + assert.ok(root.config, 'root state should include a seeded config'); }); test('getSnapshot returns session snapshot after creation', () => { @@ -180,7 +184,9 @@ suite('AgentHostStateManager', () => { test('root state starts with activeSessions: 0', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); - assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + const root = snapshot.state as { agents: unknown[]; activeSessions: number }; + assert.deepStrictEqual(root.agents, []); + assert.strictEqual(root.activeSessions, 0); }); test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 191e01f71539e..95e80e6df76b5 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, type RootAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; @@ -68,7 +68,7 @@ class MockProtocolServer implements IProtocolServer { class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; - readonly handledActions: SessionAction[] = []; + readonly handledActions: (RootAction | SessionAction | TerminalAction)[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; @@ -86,7 +86,7 @@ class MockAgentService implements IAgentService { this._stateManager = sm; } - dispatchAction(action: SessionAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.handledActions.push(action); const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(action, origin); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index b1bdfe7fef46c..c1382b3b248f1 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -8,6 +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 { RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; /** @@ -66,6 +67,27 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { getCreateSessionConfig(sessionId: string): Record | undefined; /** Clears dynamic configuration state for an abandoned new session. */ clearSessionConfig(sessionId: string): void; + + // -- Root (agent host) Config -- + + /** Fires when the root (agent host) configuration schema or values change. */ + readonly onDidChangeRootConfig: Event; + /** Returns the last-known root (agent host) configuration, or `undefined` if the host has not published any. */ + getRootConfig(): RootConfigState | undefined; + /** + * Sets one root configuration property. + * + * Optimistically updates local state and dispatches a + * `root/configChanged` action (non-replace) to the agent host. + */ + setRootConfigValue(property: string, value: unknown): Promise; + /** + * Replaces the full set of root configuration values atomically. + * + * Dispatches a single `root/configChanged` action with replace semantics. + * Unknown keys (no schema entry) are ignored. + */ + replaceRootConfig(values: Record): Promise; } export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host'; diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts new file mode 100644 index 0000000000000..6c863a4281ba0 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; +import { agentHostSettingsUri, AGENT_HOST_SETTINGS_SCHEME, AgentHostSettingsFileSystemProvider, AgentHostSettingsSchemaRegistrar } from './agentHostSettingsFileSystemProvider.js'; +import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; + +/** + * Registers the {@link AgentHostSettingsFileSystemProvider} with the + * {@link IFileService} and contributes the "Open Host Settings" action. + */ +class AgentHostSettingsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.agentHostSettingsContribution'; + + constructor( + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @ILabelService labelService: ILabelService, + ) { + super(); + + const schemaRegistrar = this._register(instantiationService.createInstance(AgentHostSettingsSchemaRegistrar)); + const provider = this._register(instantiationService.createInstance(AgentHostSettingsFileSystemProvider, schemaRegistrar)); + this._register(fileService.registerProvider(AGENT_HOST_SETTINGS_SCHEME, provider)); + + this._register(labelService.registerFormatter({ + scheme: AGENT_HOST_SETTINGS_SCHEME, + formatting: { + label: localize('agentHostSettings.label', "Host Settings"), + separator: '/', + }, + })); + } +} + +registerWorkbenchContribution2(AgentHostSettingsContribution.ID, AgentHostSettingsContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class OpenHostSettingsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.openHostSettings', + title: localize2('openHostSettings', "Open Host Settings"), + menu: [{ + id: SessionItemContextMenuId, + group: '2_settings', + order: 2, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = Array.isArray(context) ? context[0] : context; + if (!session) { + return; + } + const editorService = accessor.get(IEditorService); + const resource = agentHostSettingsUri(session.providerId); + await editorService.openEditor({ resource, options: { pinned: true } }); + } +}); diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts new file mode 100644 index 0000000000000..75127ddc4622d --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { RootConfigState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { + AbstractAgentHostConfigFileSystemProvider, + AbstractAgentHostConfigSchemaRegistrar, + AgentHostConfigPropertyFilter, + buildAgentHostConfigJsonSchema, + IAgentHostConfigLike, + IAgentHostSettingsContext, + IAgentHostSettingsLocale, + serializeAgentHostConfigDocument, +} from './agentHostSettingsShared.js'; + +/** Scheme for the synthetic agent-host settings files. */ +export const AGENT_HOST_SETTINGS_SCHEME = 'agent-host-settings'; + +/** + * Build the URI used to open the settings file for an agent host provider. + * + * URI shape: `agent-host-settings://{providerId}/settings.jsonc` + */ +export function agentHostSettingsUri(providerId: string): URI { + return URI.from({ + scheme: AGENT_HOST_SETTINGS_SCHEME, + authority: providerId, + path: `/settings.jsonc`, + }); +} + +function parseHostSettingsUri(uri: URI): IAgentHostSettingsContext | undefined { + if (uri.scheme !== AGENT_HOST_SETTINGS_SCHEME) { + return undefined; + } + const providerId = uri.authority; + if (!providerId) { + return undefined; + } + return { providerId }; +} + +/** Root (agent host) config exposes no per-property mutability flags — all props are editable. */ +const hostSettingsPropertyFilter: AgentHostConfigPropertyFilter = () => true; + +const hostSettingsLocale: IAgentHostSettingsLocale = { + get header() { return localize('agentHostSettings.header', "Agent host settings."); }, + get saveHint() { return localize('agentHostSettings.saveHint', "Edit values below and save to apply. Unknown properties are ignored."); }, + get parseError() { return localize('agentHostSettings.parseError', "Failed to parse agent host settings as JSON."); }, + get notObject() { return localize('agentHostSettings.notObject', "Agent host settings must be a JSON object."); }, +}; + +/** + * Serialize the root config values for an agent host provider into a + * commented, pretty-printed JSON document. + */ +export function serializeHostSettings(provider: IAgentHostSessionsProvider): string { + return serializeAgentHostConfigDocument(provider.getRootConfig(), hostSettingsPropertyFilter, hostSettingsLocale); +} + +/** + * Build a JSON schema describing the root config of an agent host provider. + */ +export function buildHostSettingsJsonSchema(config: RootConfigState): IJSONSchema { + return buildAgentHostConfigJsonSchema(config, hostSettingsPropertyFilter); +} + +/** + * Filesystem provider serving synthetic JSONC documents representing the + * root (agent host) configuration values of agent-host providers. + */ +export class AgentHostSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider { + + protected readonly _schemeLabel = AGENT_HOST_SETTINGS_SCHEME; + protected readonly _traceTag = 'AgentHostSettings'; + protected readonly _locale = hostSettingsLocale; + + constructor( + private readonly _schemaRegistrar: AgentHostSettingsSchemaRegistrar, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, + ) { + super(sessionsProvidersService, logService); + } + + protected _parseUri(resource: URI): IAgentHostSettingsContext | undefined { + return parseHostSettingsUri(resource); + } + + protected _serialize(provider: IAgentHostSessionsProvider): string { + return serializeHostSettings(provider); + } + + protected _watchChanges(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, fire: () => void): IDisposable { + return provider.onDidChangeRootConfig(() => fire()); + } + + protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider): void { + this._schemaRegistrar.ensureRegistered(provider, provider); + } + + protected _hasConfig(provider: IAgentHostSessionsProvider): boolean { + return provider.getRootConfig() !== undefined; + } + + protected _replaceConfig(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, values: Record): Promise { + return provider.replaceRootConfig(values); + } + + protected _describeForTrace(ctx: IAgentHostSettingsContext): string { + return `provider ${ctx.providerId}`; + } +} + +/** + * Keeps per-provider JSON schemas registered so editors of the synthetic + * `agent-host-settings://…` files get completions, hover, and validation. + */ +export class AgentHostSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar { + + protected _propertyFilter(): AgentHostConfigPropertyFilter { + return hostSettingsPropertyFilter; + } + + protected _settingsUri(provider: IAgentHostSessionsProvider): string { + return agentHostSettingsUri(provider.id).toString(); + } + + protected _schemaId(provider: IAgentHostSessionsProvider): string { + return `vscode://schemas/agent-host-settings/${provider.id}.jsonc`; + } + + protected _getConfig(_provider: IAgentHostSessionsProvider, target: IAgentHostSessionsProvider): IAgentHostConfigLike | undefined { + return target.getRootConfig(); + } + + protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly IAgentHostSessionsProvider[] { + return [provider]; + } + + protected _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (target: IAgentHostSessionsProvider) => void, + _onRemoved: (target: IAgentHostSessionsProvider) => void, + ): IDisposable { + return provider.onDidChangeRootConfig(() => onChanged(provider)); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts new file mode 100644 index 0000000000000..e29f5b904d136 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts @@ -0,0 +1,512 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../base/common/event.js'; +import { parse, ParseError } from '../../../../base/common/json.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { + createFileSystemProviderError, + FileChangeType, + FilePermission, + FileSystemProviderCapabilities, + FileSystemProviderErrorCode, + FileType, + IFileChange, + IFileDeleteOptions, + IFileOverwriteOptions, + IFileSystemProviderWithFileReadWriteCapability, + IFileWriteOptions, + IStat, + IWatchOptions, +} from '../../../../platform/files/common/files.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ConfigPropertySchema, ConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; + +// ============================================================================ +// Shared helpers for agent-host config settings filesystem providers. +// +// Both the per-session (`agent-session-settings://...`) and the per-host +// (`agent-host-settings://...`) synthetic settings editors follow the same +// shape: they render a provider's config schema as a JSONC document, watch +// for config changes, and round-trip user edits through a +// `replace*Config` API. This module factors out that shared plumbing. +// ============================================================================ + +/** + * Minimal config shape shared by session ({@link ResolveSessionConfigResult}) + * and root ({@link RootConfigState}) configuration. + */ +export interface IAgentHostConfigLike { + readonly schema: ConfigSchema; + readonly values: Record; +} + +/** + * Filter applied to schema properties to decide which ones surface in the + * editable document (and in the derived JSON schema). + * + * For session settings this filters to `sessionMutable && !readOnly`. For + * host settings all properties are editable, so the filter is a constant + * `true`. + */ +export type AgentHostConfigPropertyFilter = (key: string, schema: ConfigPropertySchema) => boolean; + +/** + * Localized strings used to decorate the serialized JSONC document. + */ +export interface IAgentHostSettingsLocale { + /** Header comment line describing the document. */ + readonly header: string; + /** Secondary hint comment describing save semantics. */ + readonly saveHint: string; + /** Error message thrown when the document fails to parse as JSONC. */ + readonly parseError: string; + /** Error message thrown when the parsed document is not a JSON object. */ + readonly notObject: string; +} + +/** + * Convert a config property schema (protocol shape) into an + * {@link IJSONSchema} suitable for registration with the JSON language + * service. + */ +export function convertPropertySchema(schema: ConfigPropertySchema): IJSONSchema { + const out: IJSONSchema = { + type: schema.type, + title: schema.title, + description: schema.description, + default: schema.default, + }; + if (schema.enum && schema.enum.length > 0) { + out.enum = [...schema.enum]; + if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { + out.enumDescriptions = [...schema.enumDescriptions]; + } + } + if (schema.type === 'array' && schema.items) { + out.items = convertPropertySchema(schema.items); + } + if (schema.type === 'object' && schema.properties) { + const properties: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + properties[key] = convertPropertySchema(value); + } + out.properties = properties; + if (schema.required && schema.required.length > 0) { + out.required = [...schema.required]; + } + } + return out; +} + +/** + * Build a JSON schema describing the filtered properties of an agent-host + * config. Properties that pass {@link filter} are included; others are + * dropped. `required` entries are carried through when the referenced + * property survives the filter. + */ +export function buildAgentHostConfigJsonSchema(config: IAgentHostConfigLike, filter: AgentHostConfigPropertyFilter): IJSONSchema { + const properties: Record = {}; + const required: string[] = []; + for (const [key, schema] of Object.entries(config.schema.properties)) { + if (!filter(key, schema)) { + continue; + } + properties[key] = convertPropertySchema(schema); + if (config.schema.required?.includes(key)) { + required.push(key); + } + } + const result: IJSONSchema = { + type: 'object', + properties, + additionalProperties: true, + }; + if (required.length > 0) { + result.required = required; + } + return result; +} + +function buildHeaderComment( + locale: IAgentHostSettingsLocale, + props: readonly (readonly [string, ConfigPropertySchema])[] | undefined, +): string { + const lines: string[] = []; + lines.push(`// ${locale.header}`); + lines.push(`// ${locale.saveHint}`); + if (props && props.length > 0) { + lines.push('//'); + for (const [key, schema] of props) { + const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : ''; + const title = schema.title || key; + lines.push(`// ${key}: ${title}${suffix}`); + if (schema.description) { + lines.push(`// ${schema.description}`); + } + } + } + lines.push(''); + return lines.join('\n'); +} + +/** + * Serialize the filtered config values into a commented, pretty-printed + * JSONC document. + */ +export function serializeAgentHostConfigDocument( + config: IAgentHostConfigLike | undefined, + filter: AgentHostConfigPropertyFilter, + locale: IAgentHostSettingsLocale, +): string { + if (!config) { + return `${buildHeaderComment(locale, undefined)}{}\n`; + } + + const editableProps = Object.entries(config.schema.properties).filter(([key, schema]) => filter(key, schema)); + const values: Record = {}; + for (const [key] of editableProps) { + if (config.values[key] !== undefined) { + values[key] = config.values[key]; + } + } + + return `${buildHeaderComment(locale, editableProps)}${JSON.stringify(values, null, 2)}\n`; +} + +// ============================================================================ +// AbstractAgentHostConfigFileSystemProvider +// ============================================================================ + +/** + * Base context shared by all settings filesystem providers. Subclasses + * extend with any additional state they need (e.g. a sessionId). + */ +export interface IAgentHostSettingsContext { + readonly providerId: string; +} + +/** + * Abstract filesystem provider backing the synthetic agent-host settings + * JSONC editors. Subclasses supply scheme-specific URI parsing, + * config-fetching, change-watching, and replace-dispatch hooks; the base + * handles the boilerplate (`stat`/`readFile`/`writeFile`/error shapes). + */ +export abstract class AbstractAgentHostConfigFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { + + readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; + + private readonly _onDidChangeCapabilities = this._register(new Emitter()); + readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; + + protected readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor( + @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService, + @ILogService protected readonly _logService: ILogService, + ) { + super(); + } + + // ---- Subclass hooks ----------------------------------------------------- + + /** URI scheme label used in error messages (e.g. `'agent-session-settings'`). */ + protected abstract readonly _schemeLabel: string; + + /** Log trace-tag (e.g. `'AgentSessionSettings'`). */ + protected abstract readonly _traceTag: string; + + /** Localized strings for the JSONC document and write-path errors. */ + protected abstract readonly _locale: IAgentHostSettingsLocale; + + /** Parse a URI of the subclass's scheme into a typed context. */ + protected abstract _parseUri(resource: URI): TContext | undefined; + + /** Render the current config for a context as a JSONC document. */ + protected abstract _serialize(provider: IAgentHostSessionsProvider, ctx: TContext): string; + + /** + * Subscribe for changes relevant to the given context. When a change is + * detected the subclass should invoke {@link fire}. + */ + protected abstract _watchChanges(provider: IAgentHostSessionsProvider, ctx: TContext, fire: () => void): IDisposable; + + /** Register / refresh the JSON schema for the given context. */ + protected abstract _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: TContext): void; + + /** Whether the backing config is currently available. */ + protected abstract _hasConfig(provider: IAgentHostSessionsProvider, ctx: TContext): boolean; + + /** Dispatch a replace write of the parsed JSONC document. */ + protected abstract _replaceConfig(provider: IAgentHostSessionsProvider, ctx: TContext, values: Record): Promise; + + /** + * Build a short human-readable description of `ctx` for log messages + * when a write is ignored due to missing config (e.g. a session id). + */ + protected abstract _describeForTrace(ctx: TContext): string; + + // ---- IFileSystemProvider ------------------------------------------------ + + watch(resource: URI, _opts: IWatchOptions): IDisposable { + const parsed = this._parseUri(resource); + if (!parsed) { + return Disposable.None; + } + const provider = this._lookupProvider(parsed.providerId); + if (!provider) { + return Disposable.None; + } + return this._watchChanges(provider, parsed, () => { + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + }); + } + + async stat(resource: URI): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + const content = this._serialize(provider, ctx); + return { + type: FileType.File, + ctime: 0, + mtime: 0, + size: VSBuffer.fromString(content).byteLength, + permissions: 0 as FilePermission, + }; + } + + async readdir(): Promise<[string, FileType][]> { + throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async readFile(resource: URI): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + const content = this._serialize(provider, ctx); + + // Register the JSON schema on demand the first time a settings file + // is read. The subclass keeps it in sync from then on. + this._ensureSchemaRegistered(provider, ctx); + + return VSBuffer.fromString(content).buffer; + } + + async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { + const { provider, ctx } = this._resolveOrThrow(resource); + + const text = VSBuffer.wrap(content).toString(); + const errors: ParseError[] = []; + const parsed_json = parse(text, errors); + if (errors.length > 0) { + throw createFileSystemProviderError(this._locale.parseError, FileSystemProviderErrorCode.Unavailable); + } + if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) { + throw createFileSystemProviderError(this._locale.notObject, FileSystemProviderErrorCode.Unavailable); + } + + if (!this._hasConfig(provider, ctx)) { + this._logService.trace(`[${this._traceTag}] No config state for ${this._describeForTrace(ctx)}; ignoring write.`); + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + return; + } + + await this._replaceConfig(provider, ctx, parsed_json as Record); + + this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); + } + + async mkdir(): Promise { + throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions); + } + + // ---- Helpers ------------------------------------------------------------ + + protected _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined { + const provider = this._sessionsProvidersService.getProvider(providerId); + if (!provider || !isAgentHostProvider(provider)) { + return undefined; + } + return provider; + } + + private _resolveOrThrow(resource: URI): { provider: IAgentHostSessionsProvider; ctx: TContext } { + const ctx = this._parseUri(resource); + if (!ctx) { + throw createFileSystemProviderError(`Invalid ${this._schemeLabel} URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); + } + const provider = this._lookupProvider(ctx.providerId); + if (!provider) { + throw createFileSystemProviderError(`Unknown agent host provider: ${ctx.providerId}`, FileSystemProviderErrorCode.FileNotFound); + } + return { provider, ctx }; + } +} + +// ============================================================================ +// AbstractAgentHostConfigSchemaRegistrar +// ============================================================================ + +/** + * Abstract base for the schema registrars that keep JSON schemas registered + * on the {@link IJSONContributionRegistry} for the synthetic settings + * editors. Subclasses plumb per-provider subscriptions and the target-type + * that identifies what a schema belongs to (an `ISession` for the session + * editor, an `IAgentHostSessionsProvider` for the host editor). + * + * Registration is lazy — {@link ensureRegistered} is called by the + * filesystem provider when a settings file is first read. Once registered, + * the schema is kept in sync via the subclass's change subscription until + * the provider is removed. + */ +export abstract class AbstractAgentHostConfigSchemaRegistrar extends Disposable { + + private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); + + /** Per-provider subscriptions. */ + private readonly _providerSubscriptions = this._register(new DisposableMap()); + + /** Per-target registered-schema disposables, keyed by the settings URI string. */ + private readonly _targetSchemas = this._register(new DisposableMap()); + + /** + * Tracks the {@link ConfigSchema} identity last used to register a schema + * for a given settings URI so we can skip re-registration when only + * values have changed. + */ + private readonly _lastSchemaIdentity = new Map(); + + constructor( + @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + for (const provider of this._sessionsProvidersService.getProviders()) { + this._onProviderAdded(provider); + } + this._register(this._sessionsProvidersService.onDidChangeProviders(e => { + for (const provider of e.added) { + this._onProviderAdded(provider); + } + for (const provider of e.removed) { + this._providerSubscriptions.deleteAndDispose(provider.id); + } + })); + } + + // ---- Subclass hooks ----------------------------------------------------- + + /** Stringified URI identifying the settings document for a target. */ + protected abstract _settingsUri(target: TTarget): string; + + /** `vscode://schemas/...` schema id used for JSON language service registration. */ + protected abstract _schemaId(target: TTarget): string; + + /** Fetch the backing config for a target. Returns `undefined` when none yet. */ + protected abstract _getConfig(provider: IAgentHostSessionsProvider, target: TTarget): IAgentHostConfigLike | undefined; + + /** Filter applied to schema properties when building the JSON schema. */ + protected abstract _propertyFilter(): AgentHostConfigPropertyFilter; + + /** Enumerate the targets currently tracked on a provider (used for cleanup). */ + protected abstract _targetsForProvider(provider: IAgentHostSessionsProvider): readonly TTarget[]; + + /** + * Subscribe to change signals from {@link provider}. The subclass should + * invoke {@link onChanged} when a tracked target's config changes and + * {@link onRemoved} when a tracked target disappears. + */ + protected abstract _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (target: TTarget) => void, + onRemoved: (target: TTarget) => void, + ): IDisposable; + + // ---- Public API --------------------------------------------------------- + + /** + * Ensures a JSON schema is registered for the given target. Safe to + * call repeatedly; a no-op when the cached schema identity matches. + */ + ensureRegistered(provider: IAgentHostSessionsProvider, target: TTarget): void { + this._refreshSchema(provider, target); + } + + // ---- Internal ----------------------------------------------------------- + + private _onProviderAdded(provider: ISessionsProvider): void { + if (!isAgentHostProvider(provider)) { + return; + } + const store = new DisposableStore(); + + store.add(this._observeProvider( + provider, + target => { + // Only refresh if we already have a registration; otherwise the + // next `readFile` will pick up the latest schema on demand. + if (!this._lastSchemaIdentity.has(this._settingsUri(target))) { + return; + } + this._refreshSchema(provider, target); + }, + target => this._disposeSchemaForTarget(target), + )); + + // On provider disposal, drop all schemas registered for this provider. + store.add(toDisposable(() => { + for (const target of this._targetsForProvider(provider)) { + this._disposeSchemaForTarget(target); + } + })); + + this._providerSubscriptions.set(provider.id, store); + } + + private _refreshSchema(provider: IAgentHostSessionsProvider, target: TTarget): void { + const config = this._getConfig(provider, target); + if (!config) { + return; + } + const settingsUri = this._settingsUri(target); + const identity = config.schema; + if (this._lastSchemaIdentity.get(settingsUri) === identity) { + return; + } + + const schema = buildAgentHostConfigJsonSchema(config, this._propertyFilter()); + const schemaId = this._schemaId(target); + + // Dispose any prior registration first, otherwise the old cleanup + // disposable would delete the freshly registered schema. + this._targetSchemas.deleteAndDispose(settingsUri); + + const store = new DisposableStore(); + this._schemaRegistry.registerSchema(schemaId, schema, store); + store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); + store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); + + this._targetSchemas.set(settingsUri, store); + this._lastSchemaIdentity.set(settingsUri, identity); + } + + private _disposeSchemaForTarget(target: TTarget): void { + this._targetSchemas.deleteAndDispose(this._settingsUri(target)); + } +} diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index cd0caf3c3ace8..19c56865fbb4f 100644 --- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -3,37 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../base/common/buffer.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { parse, ParseError } from '../../../../base/common/json.js'; import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { - createFileSystemProviderError, - FileChangeType, - FilePermission, - FileSystemProviderCapabilities, - FileSystemProviderErrorCode, - FileType, - IFileChange, - IFileDeleteOptions, - IFileOverwriteOptions, - IFileSystemProviderWithFileReadWriteCapability, - IFileWriteOptions, - IStat, - IWatchOptions, -} from '../../../../platform/files/common/files.js'; -import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { SessionConfigPropertySchema, SessionConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; +import { SessionConfigPropertySchema } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; +import { ISession, toSessionId } from '../../../services/sessions/common/session.js'; +import { + AbstractAgentHostConfigFileSystemProvider, + AbstractAgentHostConfigSchemaRegistrar, + AgentHostConfigPropertyFilter, + buildAgentHostConfigJsonSchema, + IAgentHostConfigLike, + IAgentHostSettingsContext, + IAgentHostSettingsLocale, + serializeAgentHostConfigDocument, +} from './agentHostSettingsShared.js'; /** Scheme for the synthetic agent-host session settings files. */ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; @@ -44,8 +33,8 @@ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings'; * URI shape: `agent-session-settings://{providerId}/{resourceScheme}{resourcePath}.jsonc` * * - `authority` = {@link ISession.providerId} (e.g. `local-agent-host`, `agenthost-`) - * - path encodes the session's resource scheme and path so {@link parseSettingsUri} - * can reconstruct the full {@link ISession.sessionId} via {@link toSessionId} + * - path encodes the session's resource scheme and path so the URI can be + * parsed back into an {@link ISession.sessionId} via {@link toSessionId} * without having to look the session up on the provider. */ export function agentSessionSettingsUri(session: ISession): URI { @@ -57,13 +46,12 @@ export function agentSessionSettingsUri(session: ISession): URI { }); } -interface IParsedSettingsUri { - readonly providerId: string; +interface ISessionSettingsContext extends IAgentHostSettingsContext { /** Reconstructed {@link ISession.sessionId}. */ readonly sessionId: string; } -function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { +function parseSessionSettingsUri(uri: URI): ISessionSettingsContext | undefined { if (uri.scheme !== AGENT_SESSION_SETTINGS_SCHEME) { return undefined; } @@ -90,434 +78,148 @@ function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined { return { providerId, sessionId: toSessionId(providerId, resource) }; } +/** + * Property filter: only session-mutable, non-read-only properties are + * editable. Read-only / non-mutable properties (e.g. `isolation`, `branch`) + * are preserved in the underlying config and round-tripped on write — they + * just aren't surfaced for editing. + */ +const sessionSettingsPropertyFilter: AgentHostConfigPropertyFilter = (_key, schema) => { + const s = schema as SessionConfigPropertySchema; + return s.sessionMutable === true && s.readOnly !== true; +}; + +const sessionSettingsLocale: IAgentHostSettingsLocale = { + get header() { return localize('agentSessionSettings.header', "Session settings for this agent host session."); }, + get saveHint() { return localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored."); }, + get parseError() { return localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."); }, + get notObject() { return localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."); }, +}; + /** * Serialize the session-mutable config values for a session into a * commented, pretty-printed JSON document. */ export function serializeSessionSettings(provider: IAgentHostSessionsProvider, sessionId: string): string { - const config = provider.getSessionConfig(sessionId); - if (!config) { - return `${headerComment(undefined)}{}\n`; - } - - // Only include session-mutable, non-readOnly properties in the editable - // document. Read-only / non-mutable properties (e.g. `isolation`, `branch`) - // are preserved in the underlying config and round-tripped on write — - // they just aren't surfaced for editing. - const mutableProps = Object.entries(config.schema.properties).filter(([, schema]) => schema.sessionMutable && !schema.readOnly); - const values: Record = {}; - for (const [key] of mutableProps) { - if (config.values[key] !== undefined) { - values[key] = config.values[key]; - } - } - - return `${headerComment(mutableProps)}${JSON.stringify(values, null, 2)}\n`; + return serializeAgentHostConfigDocument(provider.getSessionConfig(sessionId), sessionSettingsPropertyFilter, sessionSettingsLocale); } -function headerComment(props: readonly (readonly [string, { readonly title: string; readonly description?: string; readonly enum?: readonly string[] }])[] | undefined): string { - const lines: string[] = []; - lines.push(`// ${localize('agentSessionSettings.header', "Session settings for this agent host session.")}`); - lines.push(`// ${localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored.")}`); - if (props && props.length > 0) { - lines.push('//'); - for (const [key, schema] of props) { - const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : ''; - const title = schema.title || key; - lines.push(`// ${key}: ${title}${suffix}`); - if (schema.description) { - lines.push(`// ${schema.description}`); - } - } - } - lines.push(''); - return lines.join('\n'); +/** + * Build a JSON schema describing the editable session-mutable, non-readOnly + * properties of an agent-host session config. The filter mirrors the one + * used by {@link serializeSessionSettings} so validation matches the file + * contents produced by this provider. + */ +export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema { + return buildAgentHostConfigJsonSchema(config, sessionSettingsPropertyFilter); } /** * Filesystem provider serving synthetic JSONC documents that represent the * session-mutable config values of agent-host sessions. - * - * Reads render `IAgentHostSessionsProvider.getSessionConfig()` as pretty - * JSONC. Writes parse the document with the JSONC parser and push the user's - * full editable view to `replaceSessionConfig`, which atomically replaces - * user-editable values while preserving non-mutable / readOnly properties - * (e.g. `isolation`, `branch`) server-side. */ -export class AgentSessionSettingsFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { - - readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive; - - private readonly _onDidChangeCapabilities = this._register(new Emitter()); - readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; +export class AgentSessionSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider { - private readonly _onDidChangeFile = this._register(new Emitter()); - readonly onDidChangeFile = this._onDidChangeFile.event; + protected readonly _schemeLabel = AGENT_SESSION_SETTINGS_SCHEME; + protected readonly _traceTag = 'AgentSessionSettings'; + protected readonly _locale = sessionSettingsLocale; constructor( private readonly _schemaRegistrar: AgentSessionSettingsSchemaRegistrar, - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - @ILogService private readonly _logService: ILogService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ILogService logService: ILogService, ) { - super(); + super(sessionsProvidersService, logService); } - watch(resource: URI, _opts: IWatchOptions): IDisposable { - // The underlying provider fires `onDidChangeSessionConfig` with a sessionId; - // forward those into `onDidChangeFile` for the watched resource. - const parsed = parseSettingsUri(resource); - if (!parsed) { - return Disposable.None; - } - const provider = this._lookupProvider(parsed.providerId); - if (!provider) { - return Disposable.None; - } - return provider.onDidChangeSessionConfig(changedSessionId => { - if (changedSessionId === parsed.sessionId) { - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - } - }); + protected _parseUri(resource: URI): ISessionSettingsContext | undefined { + return parseSessionSettingsUri(resource); } - async stat(resource: URI): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - const content = serializeSessionSettings(provider, sessionId); - return { - type: FileType.File, - ctime: 0, - mtime: 0, - size: VSBuffer.fromString(content).byteLength, - permissions: 0 as FilePermission, - }; + protected _serialize(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): string { + return serializeSessionSettings(provider, ctx.sessionId); } - async readdir(): Promise<[string, FileType][]> { - throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions); + protected _watchChanges(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, fire: () => void): IDisposable { + return provider.onDidChangeSessionConfig(changedSessionId => { + if (changedSessionId === ctx.sessionId) { + fire(); + } + }); } - async readFile(resource: URI): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - const content = serializeSessionSettings(provider, sessionId); - - // Register the JSON schema on demand the first time a settings file - // is read. The registrar keeps it in sync from then on. - const session = provider.getSessions().find(s => s.sessionId === sessionId); + protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): void { + const session = provider.getSessions().find(s => s.sessionId === ctx.sessionId); if (session) { - this._schemaRegistrar.ensureRegistered(session); - } - - return VSBuffer.fromString(content).buffer; - } - - async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { - const parsed = parseSettingsUri(resource); - if (!parsed) { - throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound); - } - const { provider, sessionId } = this._resolve(parsed); - - const text = VSBuffer.wrap(content).toString(); - const errors: ParseError[] = []; - const parsed_json = parse(text, errors); - if (errors.length > 0) { - throw createFileSystemProviderError( - localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."), - FileSystemProviderErrorCode.Unavailable, - ); - } - if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) { - throw createFileSystemProviderError( - localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."), - FileSystemProviderErrorCode.Unavailable, - ); + this._schemaRegistrar.ensureRegistered(provider, session); } - - const currentConfig = provider.getSessionConfig(sessionId); - if (!currentConfig) { - this._logService.trace(`[AgentSessionSettings] No config state for session ${sessionId}; ignoring write.`); - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - return; - } - - // The input is the user's full view of editable values. Dispatch as a - // replace — `replaceSessionConfig` guarantees non-editable properties - // (non-mutable or readOnly) are preserved regardless of what we send, - // and unknown keys are ignored. This means: - // - Re-asserted editable keys overwrite the current value. - // - Omitted editable keys are unset (supports clearing via deletion). - // - Non-editable keys (e.g. `isolation`, `branch`) are round-tripped - // server-side even though we never read or write them here. - await provider.replaceSessionConfig(sessionId, parsed_json as Record); - - this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]); - } - - async mkdir(): Promise { - throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions); } - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { - throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions); + protected _hasConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): boolean { + return provider.getSessionConfig(ctx.sessionId) !== undefined; } - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { - throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions); - } - - // ---- Helpers ------------------------------------------------------------ - - private _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined { - const provider = this._sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider)) { - return undefined; - } - return provider; + // The input is the user's full view of editable values. Dispatch as a + // replace — `replaceSessionConfig` guarantees non-editable properties + // (non-mutable or readOnly) are preserved regardless of what we send, + // and unknown keys are ignored. + protected _replaceConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, values: Record): Promise { + return provider.replaceSessionConfig(ctx.sessionId, values); } - private _resolve(parsed: IParsedSettingsUri): { provider: IAgentHostSessionsProvider; sessionId: string } { - const provider = this._lookupProvider(parsed.providerId); - if (!provider) { - throw createFileSystemProviderError( - `Unknown agent host provider: ${parsed.providerId}`, - FileSystemProviderErrorCode.FileNotFound, - ); - } - return { provider, sessionId: parsed.sessionId }; + protected _describeForTrace(ctx: ISessionSettingsContext): string { + return `session ${ctx.sessionId}`; } } /** - * Convert a session config property schema (protocol shape) into an - * {@link IJSONSchema} suitable for registration with the JSON language - * service. - */ -function convertPropertySchema(schema: SessionConfigPropertySchema): IJSONSchema { - const out: IJSONSchema = { - type: schema.type, - title: schema.title, - description: schema.description, - default: schema.default, - }; - if (schema.enum && schema.enum.length > 0) { - out.enum = [...schema.enum]; - if (schema.enumDescriptions && schema.enumDescriptions.length > 0) { - out.enumDescriptions = [...schema.enumDescriptions]; - } - } - if (schema.type === 'array' && schema.items) { - out.items = convertPropertySchema(schema.items); - } - if (schema.type === 'object' && schema.properties) { - const properties: Record = {}; - for (const [key, value] of Object.entries(schema.properties)) { - properties[key] = convertPropertySchema(value); - } - out.properties = properties; - if (schema.required && schema.required.length > 0) { - out.required = [...schema.required]; - } - } - return out; -} - -/** - * Build a JSON schema describing the editable session-mutable, non-readOnly - * properties of an agent-host session config. The filter mirrors the one in - * {@link serializeSessionSettings} so validation matches the file contents - * produced by this provider. - */ -export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema { - const properties: Record = {}; - const required: string[] = []; - for (const [key, schema] of Object.entries(config.schema.properties)) { - if (!schema.sessionMutable || schema.readOnly) { - continue; - } - properties[key] = convertPropertySchema(schema); - if (config.schema.required?.includes(key)) { - required.push(key); - } - } - const result: IJSONSchema = { - type: 'object', - properties, - additionalProperties: true, - }; - if (required.length > 0) { - result.required = required; - } - return result; -} - -/** - * Keeps per-session JSON schemas registered on the - * {@link IJSONContributionRegistry} so editors of the synthetic + * Keeps per-session JSON schemas registered so editors of the synthetic * `agent-session-settings://…` files get completions, hover, and validation. - * - * Registration is lazy — {@link ensureRegistered} is called by - * {@link AgentSessionSettingsFileSystemProvider.readFile} the first time a - * session's settings document is read, so we avoid the JSON language - * service overhead for sessions that are never opened. Once registered, the - * schema is kept in sync via `onDidChangeSessionConfig` until the session - * or its provider is removed. - * - * A schema is rebuilt only when the session's underlying - * {@link SessionConfigSchema} changes by identity (protocol config schemas - * are treated as immutable snapshots); value-only changes are ignored to - * avoid churning the JSON language service. */ -export class AgentSessionSettingsSchemaRegistrar extends Disposable { +export class AgentSessionSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar { - private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution); - - /** Per-provider subscriptions (session listeners, config listeners). */ - private readonly _providerSubscriptions = this._register(new DisposableMap()); + protected _propertyFilter(): AgentHostConfigPropertyFilter { + return sessionSettingsPropertyFilter; + } - /** Per-session registered-schema disposables, keyed by the settings URI string. */ - private readonly _sessionSchemas = this._register(new DisposableMap()); + protected _settingsUri(session: ISession): string { + return agentSessionSettingsUri(session).toString(); + } - /** - * Tracks the {@link SessionConfigSchema} identity last used to register - * a schema for a given settings URI, so we can skip re-registration when - * only values have changed. - */ - private readonly _lastSchemaIdentity = new Map(); + // Schema content is served via the `vscode://schemas/...` filesystem + // provider (see `SettingsFileSystemProvider`); the JSON language client + // only knows how to fetch schema content for that scheme. The + // settings-file URI is used as the fileMatch glob so the schema is + // applied to the actual editor document. + protected _schemaId(session: ISession): string { + return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; + } - constructor( - @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, - ) { - super(); + protected _getConfig(provider: IAgentHostSessionsProvider, session: ISession): IAgentHostConfigLike | undefined { + return provider.getSessionConfig(session.sessionId); + } - for (const provider of this._sessionsProvidersService.getProviders()) { - this._onProviderAdded(provider); - } - this._register(this._sessionsProvidersService.onDidChangeProviders(e => { - for (const provider of e.added) { - this._onProviderAdded(provider); - } - for (const provider of e.removed) { - this._onProviderRemoved(provider); - } - })); + protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly ISession[] { + return provider.getSessions(); } - private _onProviderAdded(provider: ISessionsProvider): void { - if (!isAgentHostProvider(provider)) { - return; - } + protected _observeProvider( + provider: IAgentHostSessionsProvider, + onChanged: (session: ISession) => void, + onRemoved: (session: ISession) => void, + ): IDisposable { const store = new DisposableStore(); - - // Note: we do NOT seed schemas eagerly here — registration is lazy and - // only happens on the first `readFile` for a given session via - // {@link ensureRegistered}. Registering schemas is relatively expensive - // for the JSON language service, so we avoid paying that cost for - // sessions whose settings files are never opened. - store.add(provider.onDidChangeSessionConfig(sessionId => { - const schemaUri = this._schemaUriForSession(provider.id, sessionId); - // Only refresh if we already have a registration; otherwise the - // next `readFile` will pick up the latest schema on demand. - if (!schemaUri || !this._lastSchemaIdentity.has(schemaUri)) { - return; - } const session = provider.getSessions().find(s => s.sessionId === sessionId); if (session) { - this._refreshSchema(provider, session); + onChanged(session); } })); - store.add(provider.onDidChangeSessions(e => { for (const removed of e.removed) { - this._disposeSchema(removed); - } - })); - - // On provider disposal, drop all session schemas for this provider. - store.add(toDisposable(() => { - for (const session of provider.getSessions()) { - this._disposeSchema(session); + onRemoved(removed); } })); - - this._providerSubscriptions.set(provider.id, store); - } - - private _onProviderRemoved(provider: ISessionsProvider): void { - this._providerSubscriptions.deleteAndDispose(provider.id); - } - - /** - * Ensures a JSON schema is registered for the given session. Called - * lazily by the filesystem provider when a settings file is first read - * so we avoid the cost of registering schemas for sessions that are - * never opened. - * - * Once registered, the schema is kept in sync via - * `onDidChangeSessionConfig` until the session or its provider is - * removed. - */ - ensureRegistered(session: ISession): void { - const provider = this._sessionsProvidersService.getProvider(session.providerId); - if (!provider || !isAgentHostProvider(provider)) { - return; - } - this._refreshSchema(provider, session); - } - - private _schemaUriForSession(providerId: string, sessionId: string): string | undefined { - const provider = this._sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider)) { - return undefined; - } - const session = provider.getSessions().find(s => s.sessionId === sessionId); - return session ? agentSessionSettingsUri(session).toString() : undefined; - } - - private _refreshSchema(provider: IAgentHostSessionsProvider, session: ISession): void { - const config = provider.getSessionConfig(session.sessionId); - if (!config) { - return; - } - const settingsUri = agentSessionSettingsUri(session).toString(); - // Schema content is served via the `vscode://schemas/...` filesystem - // provider (see `SettingsFileSystemProvider`); the JSON language - // client only knows how to fetch schema content for that scheme. - // The settings-file URI is used as the fileMatch glob so the schema - // is applied to the actual editor document. - const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`; - const identity = config.schema; - if (this._lastSchemaIdentity.get(settingsUri) === identity) { - return; - } - - const schema = buildSessionSettingsJsonSchema(config); - - // Dispose any prior registration first, otherwise the old cleanup - // disposable would delete the freshly registered schema. Clear the - // identity cache as a side effect so we always proceed to register. - this._sessionSchemas.deleteAndDispose(settingsUri); - - const store = new DisposableStore(); - this._schemaRegistry.registerSchema(schemaId, schema, store); - store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri)); - store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri))); - - this._sessionSchemas.set(settingsUri, store); - this._lastSchemaIdentity.set(settingsUri, identity); - } - - private _disposeSchema(session: ISession): void { - const schemaUri = agentSessionSettingsUri(session).toString(); - this._sessionSchemas.deleteAndDispose(schemaUri); + return store; } } - diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 2e6e07c72cdd1..bb086202a32a8 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -17,7 +17,7 @@ import { localize } from '../../../../nls.js'; import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js'; -import { FileEdit, ModelSelection, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { FileEdit, ModelSelection, RootConfigState, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -249,6 +249,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement protected readonly _onDidChangeSessionConfig = this._register(new Emitter()); readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event; + protected readonly _onDidChangeRootConfig = this._register(new Emitter()); + readonly onDidChangeRootConfig = this._onDidChangeRootConfig.event; + + /** Last-known root config state (schema + values), seeded from `RootState.config`. */ + protected _rootConfig: RootConfigState | undefined; + /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); @@ -359,6 +365,28 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionTypes.fire(); } + /** + * Reconcile {@link _rootConfig} against {@link RootState.config}, firing + * {@link onDidChangeRootConfig} only when schema or values actually change. + */ + protected _syncRootConfigFromRootState(rootState: RootState): void { + const next = rootState.config; + const prev = this._rootConfig; + if (prev === next) { + return; + } + if (!next) { + this._rootConfig = undefined; + this._onDidChangeRootConfig.fire(); + return; + } + if (prev && prev.schema === next.schema && equals(prev.values, next.values)) { + return; + } + this._rootConfig = next; + this._onDidChangeRootConfig.fire(); + } + abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined; /** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */ @@ -575,7 +603,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const nextValues: Record = {}; for (const [key, schema] of Object.entries(runningConfig.schema.properties)) { const editable = schema.sessionMutable === true && schema.readOnly !== true; - if (editable && Object.hasOwn(values, key)) { + if (editable) { nextValues[key] = values[key]; } else if (Object.hasOwn(runningConfig.values, key)) { nextValues[key] = runningConfig.values[key]; @@ -633,6 +661,67 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._clearNewSessionConfig(sessionId); } + // -- Root (agent host) Config -------------------------------------------- + + getRootConfig(): RootConfigState | undefined { + return this._rootConfig; + } + + async setRootConfigValue(property: string, value: unknown): Promise { + const current = this._rootConfig; + const connection = this.connection; + if (!current || !connection) { + return; + } + if (!current.schema.properties[property]) { + return; + } + + // Optimistically update local cache. + this._rootConfig = { + ...current, + values: { ...current.values, [property]: value }, + }; + this._onDidChangeRootConfig.fire(); + + const action = { + type: ActionType.RootConfigChanged as const, + config: { [property]: value }, + }; + connection.dispatch(action); + } + + async replaceRootConfig(values: Record): Promise { + const current = this._rootConfig; + const connection = this.connection; + if (!current || !connection) { + return; + } + + // Filter to known properties so we don't dispatch values for keys the + // host didn't publish a schema for. + const nextValues: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (current.schema.properties[key]) { + nextValues[key] = value; + } + } + + if (equals(nextValues, current.values)) { + return; + } + + this._rootConfig = { ...current, values: nextValues }; + this._onDidChangeRootConfig.fire(); + + const action = { + type: ActionType.RootConfigChanged as const, + config: nextValues, + replace: true, + }; + connection.dispatch(action); + } + // -- Model selection ------------------------------------------------------ setModel(sessionId: string, modelId: string): void { diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts index f492f082af181..8e7d9b521e4e7 100644 --- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts @@ -69,9 +69,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const rootStateValue = this._agentHostService.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); + this._syncRootConfigFromRootState(rootStateValue); } this._register(this._agentHostService.rootState.onDidChange(rootState => { this._syncSessionTypesFromRootState(rootState); + this._syncRootConfigFromRootState(rootState); })); // Eagerly populate the session cache once authentication has settled. diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 279685b9008d0..2aadbca6d3cae 100644 --- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelSc import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; -import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -47,7 +47,7 @@ class MockAgentHostService extends mock() { override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; @@ -93,11 +93,11 @@ class MockAgentHostService extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 052afa16a8373..21507e78d283b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -77,7 +77,11 @@ function createMockProvider(id: string, opts?: { getSessionConfigCompletions: async () => [], getCreateSessionConfig: () => undefined, clearSessionConfig: () => { }, - } as IAgentHostSessionsProvider; + onDidChangeRootConfig: Event.None, + getRootConfig: () => undefined, + setRootConfigValue: async () => { }, + replaceRootConfig: async () => { }, + } as unknown as IAgentHostSessionsProvider; } return base; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index b26e06f4f6c51..aa9340eb9c6fd 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -359,9 +359,11 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid const rootStateValue = connection.rootState.value; if (rootStateValue && !(rootStateValue instanceof Error)) { this._syncSessionTypesFromRootState(rootStateValue); + this._syncRootConfigFromRootState(rootStateValue); } this._connectionListeners.add(connection.rootState.onDidChange(rootState => { this._syncSessionTypesFromRootState(rootState); + this._syncRootConfigFromRootState(rootState); })); this._attachConnectionListeners(connection, this._connectionListeners); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 7799f9eabbe48..681bfb6c91f86 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -12,7 +12,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; -import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -49,7 +49,7 @@ class MockAgentConnection extends mock() { override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; @@ -89,11 +89,11 @@ class MockAgentConnection extends mock() { return this.resolveSessionConfigResult; } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 0411c189ded57..98a2c4ec1d130 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -206,6 +206,7 @@ import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; // Local Agent Host import './contrib/agentHost/browser/localAgentHost.contribution.js'; import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; +import './contrib/agentHost/browser/agentHostSettings.contribution.js'; // Tunnel Host (allow remote connections to local agent host) import './contrib/tunnelHost/electron-browser/tunnelHost.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 6abba66db2c46..9fac1f377960c 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -156,6 +156,7 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; +import './contrib/agentHost/browser/agentHostSettings.contribution.js'; // Host filter dropdown in the titlebar (scopes the sessions list to a host) import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 93a5537200ac9..265496d8c1a66 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -10,7 +10,7 @@ import { Registry } from '../../../../../../platform/registry/common/platform.js import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; @@ -216,7 +216,7 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.getSubscriptionUnmanaged(kind, resource); } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { this._log('>>', 'dispatch', action); this._inner.dispatch(action); } 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 e13bdbb7cb532..02e99b4979a9f 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 @@ -16,7 +16,7 @@ import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, type RootAction, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -117,7 +117,7 @@ class MockAgentHostService extends mock() { // Protocol methods public override readonly clientId = 'test-window-1'; - public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = []; /** Returns dispatched actions filtered to turn-related types only * (excludes lifecycle actions like activeClientChanged). */ @@ -157,7 +157,7 @@ class MockAgentHostService extends mock() { }; } unsubscribe(_resource: URI): void { } - dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void { + dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ action, clientId, clientSeq }); } private _nextSeq = 1; @@ -250,7 +250,7 @@ class MockAgentHostService extends mock() { onDidApplyAction: Event.None, } satisfies IAgentSubscription; } - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ }); // Apply state-management actions optimistically so state-dependent // logic (e.g. customization re-dispatch) sees the correct activeClient. diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 58add97bfc0d4..4bedaad86a720 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -14,7 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { isSessionAction, type ActionEnvelope, type INotification, type RootAction, type SessionAction, type TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -283,9 +283,9 @@ suite('AgentHostClientTools', () => { override readonly onAgentHostStart = Event.None; private readonly _liveSubscriptions = new Map }>(); - public dispatchedActions: (SessionAction | TerminalAction)[] = []; + public dispatchedActions: (RootAction | SessionAction | TerminalAction)[] = []; - override dispatch(action: SessionAction | TerminalAction): void { + override dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push(action); if (isSessionAction(action) && action.type === 'session/activeClientChanged') { const entry = this._liveSubscriptions.get(action.session); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 18370f0de2fe7..134d47026e2b1 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -12,7 +12,7 @@ import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfig import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import type { ActionEnvelope, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { ActionEnvelope, RootAction, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { AgentHostPty } from '../../browser/agentHostPty.js'; @@ -32,7 +32,7 @@ class MockAgentConnection implements IAgentConnection { private readonly _onDidNotification = new Emitter(); readonly onDidNotification: Event = this._onDidNotification.event; - readonly dispatchedActions: (SessionAction | TerminalAction)[] = []; + readonly dispatchedActions: (RootAction | SessionAction | TerminalAction)[] = []; readonly createdTerminals: CreateTerminalParams[] = []; readonly disposedTerminals: URI[] = []; readonly subscribedResources: URI[] = []; @@ -115,7 +115,7 @@ class MockAgentConnection implements IAgentConnection { getSubscriptionUnmanaged(_kind: StateComponents, _resource: URI): IAgentSubscription | undefined { return undefined; } - dispatch(action: SessionAction | TerminalAction): void { + dispatch(action: RootAction | SessionAction | TerminalAction): void { this.dispatchedActions.push(action); } From 2e9a4052374bcf5e8307bc1eb3c377616863cdf8 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst Date: Thu, 23 Apr 2026 21:37:58 -0400 Subject: [PATCH 06/36] Remove grayscale filter: always show icon in full color Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/media/openInVSCode.css | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css index ef4814bba76b8..0ca469df6b076 100644 --- a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css +++ b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css @@ -31,18 +31,6 @@ background-repeat: no-repeat; background-position: center center; background-size: contain; - /* Desaturated at rest; full color on hover/focus. */ - filter: grayscale(1) opacity(0.75); -} - -.monaco-enable-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon, -.monaco-workbench.monaco-enable-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon { - transition: filter 150ms ease; -} - -.monaco-reduce-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon, -.monaco-workbench.monaco-reduce-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon { - transition-duration: 0ms !important; } /* In production builds vscode-distro overlays vs/workbench/browser/media/code-icon.svg @@ -54,11 +42,6 @@ background-image: url('../../../../../workbench/browser/media/code-icon.svg'); } -.monaco-workbench .open-in-vscode-titlebar-widget:hover > .open-in-vscode-titlebar-widget-icon, -.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible > .open-in-vscode-titlebar-widget-icon { - filter: none; -} - .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label { display: inline-block; max-width: 0; From 0748f7f4d114cb844dd0626e8cdcc61014b4f687 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 24 Apr 2026 08:34:15 -0700 Subject: [PATCH 07/36] pr comments --- .../agentHost/common/agentHostSchema.ts | 8 ++++---- .../agentHost/node/sessionPermissions.ts | 3 ++- .../test/common/agentHostSchema.test.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 88da94471d6be..33ebc3dacf585 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -117,7 +117,7 @@ export interface ISchema { * {@link values} or {@link assertValid} when you want a descriptive * {@link ProtocolError} instead. */ - validateOrDefault }>>(values: Record | undefined, defaults: T): Record; + validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T; } export function createSchema(definition: D): ISchema { @@ -158,9 +158,9 @@ export function createSchema(definition: D): ISchema const narrowed: ISchemaProperty = prop; narrowed.assertValid(value, key); }, - validateOrDefault }>>(values: Record | undefined, defaults: T): Record { + validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T { const result: Record = {}; - const raw = values ?? {}; + const raw: { [K in keyof T]?: unknown } = values ?? {}; for (const key of Object.keys(definition)) { const prop = definition[key]; const candidate = raw[key]; @@ -172,7 +172,7 @@ export function createSchema(definition: D): ISchema // else: key not in defaults and incoming value missing/invalid // → leave unset so higher-scope defaults can fill in. } - return result; + return result as T; }, }; } diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index a1955bf63d49d..304fc5fa8b430 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -11,7 +11,8 @@ import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import type { IAgentToolReadyEvent } from '../common/agentService.js'; import { platformSessionSchema } from '../common/agentHostSchema.js'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { ConfirmationOptionKind, type ConfirmationOption } from '../common/state/protocol/state.js'; import { ActionType, type IToolCallReadyAction } from '../common/state/sessionActions.js'; import { ResponsePartKind, diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 8a3f806f1536e..eaee2c92cc130 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -254,9 +254,29 @@ suite('agentHostSchema', () => { test('ignores keys not in defaults', () => { const schema = fixture(); + // @ts-expect-error: test that extra keys not in the defaults are ignored, even if they pass validation. const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 }); assert.deepStrictEqual(result, { name: 'a', count: 1 }); }); + + test('omits schema keys that are missing from both values and defaults', () => { + // Regression coverage for the partial-defaults contract that + // underpins host-level inheritance: if the caller doesn't supply + // a default and no incoming value is valid, the key is left out + // entirely so higher-scope defaults can fill in. + const schema = fixture(); + const result = schema.validateOrDefault({ count: 9 }, { count: 0 }); + assert.deepStrictEqual(result, { count: 9 }); + assert.ok(!result.hasOwnProperty('name'), '`name` should be absent when neither values nor defaults supply it'); + }); + + test('omits schema keys when value is invalid and no default is supplied', () => { + const schema = fixture(); + // @ts-expect-error: test that invalid values are dropped even when the caller doesn't provide a default. + const result = schema.validateOrDefault({ name: 42, count: 3 }, { count: 0 }); + // `name` has no default and the incoming value is invalid → dropped. + assert.deepStrictEqual(result, { count: 3 }); + }); }); // ---- platformSessionSchema sanity -------------------------------------- From 19d48a9f70d6bdb60fe3f3355571275da3659db8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:12:04 -0700 Subject: [PATCH 08/36] Use service assessor Co-authored-by: Copilot --- .../contrib/webview/browser/resourceLoading.ts | 9 ++++++--- .../contrib/webview/browser/webviewElement.ts | 12 ++++-------- .../webview/electron-browser/webviewElement.ts | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 1c9f470218013..93aee448b07ee 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -9,6 +9,7 @@ import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; @@ -44,17 +45,19 @@ export namespace WebviewResourceResponse { } export async function loadLocalResource( + accessor: ServicesAccessor, requestUri: URI, options: { ifNoneMatch: string | undefined; roots: ReadonlyArray; range?: { readonly start: number; readonly end?: number }; }, - uriIdentityService: IUriIdentityService, - fileService: IFileService, - logService: ILogService, token: CancellationToken, ): Promise { + const uriIdentityService = accessor.get(IUriIdentityService); + const fileService = accessor.get(IFileService); + const logService = accessor.get(ILogService); + const resourceToLoad = getResourceToLoad(requestUri, options.roots, uriIdentityService); logService.trace(`Webview.loadLocalResource - trying to load resource. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index d838af9b42e38..e5ca386227a8e 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -25,13 +25,11 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; -import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; @@ -172,13 +170,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi @IContextMenuService contextMenuService: IContextMenuService, @INotificationService notificationService: INotificationService, @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, - @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @IRemoteAuthorityResolverService private readonly _remoteAuthorityResolverService: IRemoteAuthorityResolverService, @ITunnelService private readonly _tunnelService: ITunnelService, - @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, - @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -336,7 +332,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi })); if (initInfo.options.enableFindWidget) { - this._webviewFindWidget = this._register(instantiationService.createInstance(WebviewFindWidget, this)); + this._webviewFindWidget = this._register(this._instantiationService.createInstance(WebviewFindWidget, this)); } } @@ -776,11 +772,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private async loadResource(id: number, uri: URI, options: { ifNoneMatch: string | undefined; range?: { readonly start: number; readonly end?: number } }, token: CancellationToken) { try { - const result = await loadLocalResource(uri, { + const result = await this._instantiationService.invokeFunction(loadLocalResource, uri, { ifNoneMatch: options.ifNoneMatch, roots: this._content.options.localResourceRoots || [], range: options.range, - }, this._uriIdentityService, this._fileService, this._logService, token); + }, token); switch (result.type) { case WebviewResourceResponse.Type.Success: { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 59013dfd3d418..f23f1a4da6d3e 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -59,7 +59,7 @@ export class ElectronWebviewElement extends WebviewElement { ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService, uriIdentityService); + logService, remoteAuthorityResolverService, tunnelService, accessibilityService, instantiationService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); From 113125fc190e00de2f7ed0992cd9a37754aede9c Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:17:56 -0700 Subject: [PATCH 09/36] Remove now unused props --- .../contrib/webview/electron-browser/webviewElement.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index f23f1a4da6d3e..ceea64ff9e0b6 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -9,7 +9,6 @@ import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -17,7 +16,6 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; -import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; @@ -45,7 +43,6 @@ export class ElectronWebviewElement extends WebviewElement { webviewThemeDataProvider: WebviewThemeDataProvider, @IContextMenuService contextMenuService: IContextMenuService, @ITunnelService tunnelService: ITunnelService, - @IFileService fileService: IFileService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, @ILogService logService: ILogService, @@ -55,7 +52,6 @@ export class ElectronWebviewElement extends WebviewElement { @INativeHostService private readonly _nativeHostService: INativeHostService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService accessibilityService: IAccessibilityService, - @IUriIdentityService uriIdentityService: IUriIdentityService, ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, From 2d0f70ce194b39fd1c3e3f27adcf55e1270e749e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 24 Apr 2026 18:24:49 +0200 Subject: [PATCH 10/36] Move agentPluginsHome to IUserDataProfile and IEnvironmentService (#312356) * Move agentPluginsHome to IUserDataProfile and IEnvironmentService - Add agentPluginsHome to IEnvironmentService (platform level) - Add agentPluginsHome to IUserDataProfile (same value for all profiles) - Remove agentPluginsHome from IWorkbenchEnvironmentService - Remove agentPluginsHome getter from NativeWorkbenchEnvironmentService - Update AgentPluginRepositoryService to read from IUserDataProfileService - Update toUserDataProfile signature with new agentPluginsHome parameter - Update all test files and consumers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback - Remove redundant agentPluginsHome override in embedded app (already correct from environment service since dataFolderName is shared) - Add agentPluginsHome to isUserDataProfile type guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add --agent-plugins-dir to --transient feature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Derive agentPluginsPath from extensions parent directory Agent plugins are now stored as a sibling of the extensions directory (e.g., ~/.vscode-insiders/agent-plugins/ next to ~/.vscode-insiders/extensions/). This means --extensions-dir and --transient automatically co-locate agent plugins without needing a separate --agent-plugins-dir flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test: only co-locate agent-plugins when extensions-dir is explicitly set Avoid calling this.extensionsPath in the default agentPluginsPath fallback, which breaks tests where environment args are empty objects. Instead, check args['extensions-dir'] directly and only co-locate when it is explicitly overridden. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert env service change; only co-locate agent-plugins in --transient Keep agentPluginsPath original logic in environment service. The --transient handler in cli.ts passes --agent-plugins-dir explicitly to co-locate agent plugins under the same temp parent directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Compute host agent-plugins path for embedded Agents app Extract getAgentPluginsPath as a shared function. In the embedded Agents app, compute the host VS Code's agent-plugins directory using quality-specific dataFolderName, matching the hostUserRoamingDataHome pattern. Add --agent-plugins-dir to --transient feature. Add agentPluginsHome to isUserDataProfile type guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix warnings * simplify Co-authored-by: Copilot * fix Co-authored-by: Copilot * fix Co-authored-by: Copilot --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot --- src/vs/code/electron-main/main.ts | 2 +- src/vs/code/node/cli.ts | 4 +- .../environment/common/environmentService.ts | 20 ------- .../electron-main/storageMainService.test.ts | 7 ++- .../userDataProfile/common/userDataProfile.ts | 4 ++ .../electron-main/userDataProfile.ts | 57 ++++++++++++++++++- .../userDataProfile/node/userDataProfile.ts | 2 +- .../userDataProfileMainService.test.ts | 5 +- .../workspacesManagementMainService.test.ts | 2 +- .../browser/agentPluginRepositoryService.ts | 8 ++- .../agentPluginRepositoryService.test.ts | 16 ++++-- .../test/browser/editSessions.test.ts | 1 + .../environment/browser/environmentService.ts | 3 - .../environment/common/environmentService.ts | 1 - .../electron-browser/environmentService.ts | 3 - .../test/browser/storageService.test.ts | 3 +- .../userDataProfileImportExportService.ts | 1 + .../workingCopyBackupService.test.ts | 3 +- 18 files changed, 96 insertions(+), 46 deletions(-) diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts index e5c7bf736e29b..ae28c78c219cb 100644 --- a/src/vs/code/electron-main/main.ts +++ b/src/vs/code/electron-main/main.ts @@ -200,7 +200,7 @@ class CodeMain { services.set(IStateService, stateService); // User Data Profiles - const userDataProfilesMainService = new UserDataProfilesMainService(stateService, uriIdentityService, environmentMainService, fileService, logService); + const userDataProfilesMainService = new UserDataProfilesMainService(stateService, uriIdentityService, environmentMainService, fileService, logService, productService); services.set(IUserDataProfilesMainService, userDataProfilesMainService); // Use FileUserDataProvider for user data to diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 43ea73d798b41..2cfef361c3bdf 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -249,12 +249,14 @@ export async function main(argv: string[]): Promise { const tempUserDataDir = join(tempParentDir, 'data'); const tempExtensionsDir = join(tempParentDir, 'extensions'); const tempSharedDataDir = join(tempParentDir, 'shared'); + const tempAgentPluginsDir = join(tempParentDir, 'agent-plugins'); addArg(argv, '--user-data-dir', tempUserDataDir); addArg(argv, '--extensions-dir', tempExtensionsDir); addArg(argv, '--shared-data-dir', tempSharedDataDir); + addArg(argv, '--agent-plugins-dir', tempAgentPluginsDir); - console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}"`); + console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}" --agent-plugins-dir "${tempAgentPluginsDir}"`); } const hasReadStdinArg = args._.some(arg => arg === '-') || args.chat?._.some(arg => arg === '-'); diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index 1d7dfff7d1723..004d0614c938a 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -162,26 +162,6 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return joinPath(this.userHome, this.productService.sharedDataFolderName); } - @memoize - get agentPluginsPath(): string { - const cliAgentPluginsDir = this.args['agent-plugins-dir']; - if (cliAgentPluginsDir) { - return resolve(cliAgentPluginsDir); - } - - const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS']; - if (vscodeAgentPlugins) { - return vscodeAgentPlugins; - } - - const vscodePortable = env['VSCODE_PORTABLE']; - if (vscodePortable) { - return join(vscodePortable, 'agent-plugins'); - } - - return joinPath(this.userHome, this.productService.dataFolderName, 'agent-plugins').fsPath; - } - @memoize get extensionDevelopmentLocationURI(): URI[] | undefined { const extensionDevelopmentPaths = this.args.extensionDevelopmentPath; diff --git a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts index 7c54f44d5c914..eef84a5d3517a 100644 --- a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts +++ b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts @@ -63,6 +63,7 @@ suite('StorageMainService', function () { promptsHome: joinPath(inMemoryProfileRoot, 'promptsHome'), extensionsResource: joinPath(inMemoryProfileRoot, 'extensionsResource'), cacheHome: joinPath(inMemoryProfileRoot, 'cache'), + agentPluginsHome: joinPath(inMemoryProfileRoot, 'agentPluginsHome'), }; class TestStorageMainService extends StorageMainService { @@ -131,7 +132,7 @@ suite('StorageMainService', function () { const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); const fileService = disposables.add(new FileService(new NullLogService())); const uriIdentityService = disposables.add(new UriIdentityService(fileService)); - const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService)); + const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService(), productService)), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService)); disposables.add(testStorageService.applicationStorage); @@ -300,7 +301,7 @@ suite('StorageMainService', function () { const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); const fileService = disposables.add(new FileService(new NullLogService())); const uriIdentityService = disposables.add(new UriIdentityService(fileService)); - const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService)); + const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService(), productService)), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService)); const storage = storageMainService.applicationSharedStorage; disposables.add(storage); @@ -336,7 +337,7 @@ suite('StorageMainService', function () { onDidReceiveMessage: onDidReceiveMessage2.event, }; - const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2)); + const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService(), productService)), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2)); const storage2 = storageMainService2.applicationSharedStorage; disposables.add(storage2); diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts index b2443cded4853..fb2e942ca2bf5 100644 --- a/src/vs/platform/userDataProfile/common/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts @@ -54,6 +54,7 @@ export interface IUserDataProfile { readonly promptsHome: URI; readonly extensionsResource: URI; readonly mcpResource: URI; + readonly agentPluginsHome: URI; readonly cacheHome: URI; readonly useDefaultFlags?: UseDefaultProfileFlags; readonly isTransient?: boolean; @@ -76,6 +77,7 @@ export function isUserDataProfile(thing: unknown): thing is IUserDataProfile { && URI.isUri(candidate.promptsHome) && URI.isUri(candidate.extensionsResource) && URI.isUri(candidate.mcpResource) + && URI.isUri(candidate.agentPluginsHome) ); } @@ -154,6 +156,7 @@ export function reviveProfile(profile: UriDto, scheme: string) promptsHome: URI.revive(profile.promptsHome).with({ scheme }), extensionsResource: URI.revive(profile.extensionsResource).with({ scheme }), mcpResource: URI.revive(profile.mcpResource).with({ scheme }), + agentPluginsHome: URI.revive(profile.agentPluginsHome), cacheHome: URI.revive(profile.cacheHome).with({ scheme }), useDefaultFlags: profile.useDefaultFlags, isTransient: profile.isTransient, @@ -176,6 +179,7 @@ export function toUserDataProfile(id: string, name: string, location: URI, profi promptsHome: defaultProfile && options?.useDefaultFlags?.prompts ? defaultProfile.promptsHome : joinPath(location, 'prompts'), extensionsResource: defaultProfile && options?.useDefaultFlags?.extensions ? defaultProfile.extensionsResource : joinPath(location, 'extensions.json'), mcpResource: defaultProfile && options?.useDefaultFlags?.mcp ? defaultProfile.mcpResource : joinPath(location, 'mcp.json'), + agentPluginsHome: defaultProfile ? defaultProfile.agentPluginsHome : joinPath(location, 'agent-plugins'), cacheHome: joinPath(profilesCacheHome, id), useDefaultFlags: options?.useDefaultFlags, isTransient: options?.transient, diff --git a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts index e6510943dca88..19842b144ad0a 100644 --- a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts @@ -10,11 +10,16 @@ import { INativeEnvironmentService } from '../../environment/common/environment. import { IFileService } from '../../files/common/files.js'; import { refineServiceDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { IUserDataProfilesService, WillCreateProfileEvent, WillRemoveProfileEvent, IUserDataProfile } from '../common/userDataProfile.js'; import { UserDataProfilesService } from '../node/userDataProfile.js'; import { IAnyWorkspaceIdentifier, IEmptyWorkspaceIdentifier } from '../../workspace/common/workspace.js'; import { IStateService } from '../../state/node/state.js'; +import { URI } from '../../../base/common/uri.js'; +import { NativeParsedArgs } from '../../environment/common/argv.js'; +import { env } from '../../../base/common/process.js'; +import { join, resolve } from '../../../base/common/path.js'; export const IUserDataProfilesMainService = refineServiceDecorator(IUserDataProfilesService); export interface IUserDataProfilesMainService extends IUserDataProfilesService { @@ -27,18 +32,25 @@ export interface IUserDataProfilesMainService extends IUserDataProfilesService { export class UserDataProfilesMainService extends UserDataProfilesService implements IUserDataProfilesMainService { + private readonly agentPluginsHome: URI; + constructor( @IStateService stateService: IStateService, @IUriIdentityService uriIdentityService: IUriIdentityService, @INativeEnvironmentService environmentService: INativeEnvironmentService, @IFileService fileService: IFileService, @ILogService logService: ILogService, + @IProductService private readonly productService: IProductService, ) { super(stateService, uriIdentityService, environmentService, fileService, logService); + this.agentPluginsHome = URI.file(getAgentPluginsPath(environmentService.args, environmentService.userHome, productService.dataFolderName)); } protected override createDefaultProfile(): IUserDataProfile { - const defaultProfile = super.createDefaultProfile(); + const defaultProfile = { + ...super.createDefaultProfile(), + agentPluginsHome: this.agentPluginsHome + }; if (!(process as INodeProcess).isEmbeddedApp) { return defaultProfile; } @@ -46,11 +58,13 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme if (!hostUserRoamingDataHome) { return defaultProfile; } + const hostAgentPluginsHome = getHostAgentPluginsPath(this.nativeEnvironmentService, this.productService); return { ...defaultProfile, keybindingsResource: joinPath(hostUserRoamingDataHome, 'keybindings.json'), promptsHome: joinPath(hostUserRoamingDataHome, 'prompts'), mcpResource: joinPath(hostUserRoamingDataHome, 'mcp.json'), + agentPluginsHome: hostAgentPluginsHome ? URI.file(hostAgentPluginsHome) : this.agentPluginsHome }; } @@ -61,5 +75,46 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme } return emptyWindows; } +} + +function getHostAgentPluginsPath(environmentService: INativeEnvironmentService, productService: IProductService): string | undefined { + if (!(process as INodeProcess).isEmbeddedApp) { + return undefined; + } + if (!environmentService.isBuilt) { + return undefined; + } + + const quality = productService.quality; + let hostDataFolderName: string; + if (quality === 'stable') { + hostDataFolderName = '.vscode'; + } else if (quality === 'insider') { + hostDataFolderName = '.vscode-insiders'; + } else if (quality === 'exploration') { + hostDataFolderName = '.vscode-exploration'; + } else { + return undefined; + } + + return getAgentPluginsPath(environmentService.args, environmentService.userHome, hostDataFolderName); +} + +function getAgentPluginsPath(args: NativeParsedArgs, userHome: URI, dataFolderName: string): string { + const cliAgentPluginsDir = args['agent-plugins-dir']; + if (cliAgentPluginsDir) { + return resolve(cliAgentPluginsDir); + } + + const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS']; + if (vscodeAgentPlugins) { + return vscodeAgentPlugins; + } + + const vscodePortable = env['VSCODE_PORTABLE']; + if (vscodePortable) { + return join(vscodePortable, 'agent-plugins'); + } + return joinPath(userHome, dataFolderName, 'agent-plugins').fsPath; } diff --git a/src/vs/platform/userDataProfile/node/userDataProfile.ts b/src/vs/platform/userDataProfile/node/userDataProfile.ts index 664b85109e97e..a992766f60e34 100644 --- a/src/vs/platform/userDataProfile/node/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/node/userDataProfile.ts @@ -20,7 +20,7 @@ export class UserDataProfilesReadonlyService extends BaseUserDataProfilesService constructor( @IStateReadService private readonly stateReadonlyService: IStateReadService, @IUriIdentityService uriIdentityService: IUriIdentityService, - @INativeEnvironmentService private readonly nativeEnvironmentService: INativeEnvironmentService, + @INativeEnvironmentService protected readonly nativeEnvironmentService: INativeEnvironmentService, @IFileService fileService: IFileService, @ILogService logService: ILogService, ) { diff --git a/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts b/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts index ce4bf69348300..24b59d16d8d68 100644 --- a/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts +++ b/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts @@ -15,6 +15,7 @@ import product from '../../../product/common/product.js'; import { UserDataProfilesMainService } from '../../electron-main/userDataProfile.js'; import { SaveStrategy, StateService } from '../../../state/node/stateService.js'; import { UriIdentityService } from '../../../uriIdentity/common/uriIdentityService.js'; +import { IProductService } from '../../../product/common/productService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); @@ -23,6 +24,7 @@ class TestEnvironmentService extends AbstractNativeEnvironmentService { constructor(private readonly _appSettingsHome: URI) { super(Object.create(null), Object.create(null), { _serviceBrand: undefined, ...product }); } + override get userHome() { return this._appSettingsHome; } override get userRoamingDataHome() { return this._appSettingsHome.with({ scheme: Schemas.vscodeUserData }); } override get extensionsPath() { return joinPath(this.userRoamingDataHome, 'extensions.json').path; } override get stateResource() { return joinPath(this.userRoamingDataHome, 'state.json'); } @@ -44,7 +46,8 @@ suite('UserDataProfileMainService', () => { environmentService = new TestEnvironmentService(joinPath(ROOT, 'User')); stateService = disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, logService, fileService)); - testObject = disposables.add(new UserDataProfilesMainService(stateService, disposables.add(new UriIdentityService(fileService)), environmentService, fileService, logService)); + const productService: IProductService = { _serviceBrand: undefined, ...product }; + testObject = disposables.add(new UserDataProfilesMainService(stateService, disposables.add(new UriIdentityService(fileService)), environmentService, fileService, logService, productService)); await stateService.init(); }); diff --git a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts index 8b870f33a2735..5c68f94a6df19 100644 --- a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts +++ b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts @@ -110,7 +110,7 @@ flakySuite('WorkspacesManagementMainService', () => { const logService = new NullLogService(); const fileService = new FileService(logService); - service = new WorkspacesManagementMainService(environmentMainService, logService, new UserDataProfilesMainService(new StateService(SaveStrategy.DELAYED, environmentMainService, logService, fileService), new UriIdentityService(fileService), environmentMainService, fileService, logService), new TestBackupMainService(), new TestDialogMainService()); + service = new WorkspacesManagementMainService(environmentMainService, logService, new UserDataProfilesMainService(new StateService(SaveStrategy.DELAYED, environmentMainService, logService, fileService), new UriIdentityService(fileService), environmentMainService, fileService, logService, productService), new TestBackupMainService(), new TestDialogMainService()); return fs.promises.mkdir(untitledWorkspacesHomePath, { recursive: true }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index b6799d5a71523..babb7cc2495f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -12,13 +12,14 @@ import { dirname, isEqual, isEqualOrParent, joinPath } from '../../../../base/co import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; @@ -47,7 +48,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi constructor( @ICommandService private readonly _commandService: ICommandService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IEnvironmentService environmentService: IEnvironmentService, @IFileService private readonly _fileService: IFileService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @@ -55,11 +56,12 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi @IPluginGitService private readonly _pluginGit: IPluginGitService, @IProgressService private readonly _progressService: IProgressService, @IStorageService private readonly _storageService: IStorageService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, ) { // On native, use the well-known ~/{dataFolderName}/agent-plugins/ path // so that external tools can discover it. On web, fall back to the // internal cache location. - this.agentPluginsHome = environmentService.agentPluginsHome; + this.agentPluginsHome = userDataProfileService.currentProfile.agentPluginsHome; const legacyCacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins'); const oldCacheRoot = environmentService.cacheHome.scheme === 'file' ? legacyCacheRoot diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index f69380c85c138..3479662495e69 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -14,6 +14,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; import { IPluginGitService } from '../../../common/plugins/pluginGitService.js'; @@ -75,7 +76,8 @@ suite('AgentPluginRepositoryService', () => { return undefined; }, } as unknown as ICommandService); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); @@ -155,7 +157,8 @@ suite('AgentPluginRepositoryService', () => { repoExists = true; }, })); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); @@ -196,7 +199,8 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); instantiationService.stub(IPluginGitService, stubPluginGit()); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); @@ -294,7 +298,8 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); instantiationService.stub(IPluginGitService, stubPluginGit()); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, { exists: async () => true, del: async (resource: URI) => { onDel(resource); }, @@ -388,7 +393,8 @@ suite('AgentPluginRepositoryService', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); instantiationService.stub(IPluginGitService, stubPluginGit()); - instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache'), agentPluginsHome: URI.file('/cache/agentPlugins') } as unknown as IEnvironmentService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); instantiationService.stub(IFileService, { exists: async () => true, del: async () => { throw new Error('permission denied'); }, diff --git a/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts b/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts index 12be1cfe7d4b4..70ba7c5495cdb 100644 --- a/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts +++ b/src/vs/workbench/contrib/editSessions/test/browser/editSessions.test.ts @@ -166,6 +166,7 @@ suite('Edit session sync', () => { promptsHome: URI.file('promptsHome'), extensionsResource: URI.file('extensionsResource'), cacheHome: URI.file('cacheHome'), + agentPluginsHome: URI.file('agentPluginsHome'), }; }); diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index d9e0aeacc107c..60a4e55a7b381 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -147,9 +147,6 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get extHostLogsPath(): URI { return joinPath(this.logsHome, 'exthost'); } - @memoize - get agentPluginsHome(): URI { return joinPath(this.userRoamingDataHome, 'agent-plugins'); } - private extensionHostDebugEnvironment: IExtensionHostDebugEnvironment | undefined = undefined; @memoize diff --git a/src/vs/workbench/services/environment/common/environmentService.ts b/src/vs/workbench/services/environment/common/environmentService.ts index 7f799fa21c28e..3ad7edaa43405 100644 --- a/src/vs/workbench/services/environment/common/environmentService.ts +++ b/src/vs/workbench/services/environment/common/environmentService.ts @@ -26,7 +26,6 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService { readonly logFile: URI; readonly windowLogsPath: URI; readonly extHostLogsPath: URI; - readonly agentPluginsHome: URI; // --- Extensions readonly extensionEnabledProposedApi?: string[]; diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 8c0080ace5d75..9d08b14460784 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -154,9 +154,6 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } - @memoize - get agentPluginsHome(): URI { return URI.file(this.agentPluginsPath); } - constructor( private readonly configuration: INativeWindowConfiguration, productService: IProductService diff --git a/src/vs/workbench/services/storage/test/browser/storageService.test.ts b/src/vs/workbench/services/storage/test/browser/storageService.test.ts index 2531f78ca5daf..d995441acf788 100644 --- a/src/vs/workbench/services/storage/test/browser/storageService.test.ts +++ b/src/vs/workbench/services/storage/test/browser/storageService.test.ts @@ -46,7 +46,8 @@ async function createStorageService(): Promise<[DisposableStore, BrowserStorageS snippetsHome: joinPath(inMemoryExtraProfileRoot, 'snippetsHome'), promptsHome: joinPath(inMemoryExtraProfileRoot, 'promptsHome'), extensionsResource: joinPath(inMemoryExtraProfileRoot, 'extensionsResource'), - cacheHome: joinPath(inMemoryExtraProfileRoot, 'cache') + cacheHome: joinPath(inMemoryExtraProfileRoot, 'cache'), + agentPluginsHome: joinPath(inMemoryExtraProfileRoot, 'agentPluginsHome'), }; const storageService = disposables.add(new BrowserStorageService({ id: 'workspace-storage-test' }, disposables.add(new UserDataProfileService(inMemoryExtraProfile)), logService)); diff --git a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts index a45bd6dc1aff2..98c6cefbe2cb3 100644 --- a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts +++ b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts @@ -755,6 +755,7 @@ class UserDataProfileExportState extends UserDataProfileImportExportState { promptsHome: profile.promptsHome.with({ scheme: USER_DATA_PROFILE_EXPORT_SCHEME }), extensionsResource: profile.extensionsResource, cacheHome: profile.cacheHome, + agentPluginsHome: profile.agentPluginsHome, useDefaultFlags: profile.useDefaultFlags, isTransient: profile.isTransient }; diff --git a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts index 710844b0653b0..7420d5e546df8 100644 --- a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyBackupService.test.ts @@ -51,7 +51,8 @@ const NULL_PROFILE = { snippetsHome: joinPath(homeDir, 'snippets'), promptsHome: joinPath(homeDir, 'prompts'), extensionsResource: joinPath(homeDir, 'extensions.json'), - cacheHome: joinPath(homeDir, 'cache') + cacheHome: joinPath(homeDir, 'cache'), + agentPluginsHome: joinPath(homeDir, 'agentPluginsHome'), }; const TestNativeWindowConfiguration: INativeWindowConfiguration = { From 64e09196a3eb1d3ad89ff5f420f919f4709dd273 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 24 Apr 2026 12:31:18 -0400 Subject: [PATCH 11/36] fix: schedule promptFallbackScheduler immediately in trackIdleOnPrompt (#312387) --- .../executeStrategy/executeStrategy.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index 9b653563d6fdb..ef5f463b2a4b5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -181,6 +181,22 @@ export async function trackIdleOnPrompt( state = TerminalState.PromptAfterExecuting; scheduler.schedule(); }, promptFallbackMs ?? 1000)); + // Schedule an initial fallback with a longer timeout so we can detect idle + // even when no terminal data events arrive at all (e.g. shell integration + // is broken and the command finishes silently or hangs waiting for input). + // Without this, if no data events fire, neither scheduler is ever triggered + // and trackIdleOnPrompt blocks forever. We use a longer initial delay (10s) + // to avoid falsely reporting completion for commands that are slow to start + // producing output. Once any data arrives, the onData handler takes over + // with the shorter promptFallbackMs interval. + const initialFallbackScheduler = store.add(new RunOnceScheduler(() => { + if (state === TerminalState.Executing || state === TerminalState.PromptAfterExecuting) { + return; + } + state = TerminalState.PromptAfterExecuting; + scheduler.schedule(); + }, 10_000)); + initialFallbackScheduler.schedule(); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done @@ -194,6 +210,9 @@ export async function trackIdleOnPrompt( PromptAfterExecuting, } store.add(onData(e => { + // Once any data arrives, cancel the initial fallback — the data-driven + // promptFallbackScheduler handles rescheduling from here. + initialFallbackScheduler.cancel(); // Update state // p10k fires C as `133;C;` const matches = e.matchAll(/(?:\x1b\]|\x9d)[16]33;(?[ACD])(?:;.*)?(?:\x1b\\|\x07|\x9c)/g); From 4b5b21a93e3f81edd6338b2dabc987a92b03ebce Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 24 Apr 2026 12:38:18 -0400 Subject: [PATCH 12/36] Terminal agent: improve send_to_terminal output capture and add waitForOutput (#312379) --- .../browser/tools/sendToTerminalTool.ts | 68 +++++++++++++++++-- .../test/browser/sendToTerminalTool.test.ts | 43 ++++++++++-- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts index 068717e365246..1e1b8ff4c4a3a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { timeout } from '../../../../../../base/common/async.js'; -import type { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { appendEscapedMarkdownInlineCode, createCommandUri, isMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; @@ -16,7 +16,7 @@ import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { IChatService, IChatMultiSelectAnswer, IChatQuestionAnswerValue, IChatQuestionCarousel, IChatSingleSelectAnswer } from '../../../../chat/common/chatService/chatService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ITerminalChatService, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { ITerminalChatService, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; import { getOutput } from '../outputHelpers.js'; import { buildCommandDisplayText, isMultilineCommand, normalizeCommandForExecution } from '../runInTerminalHelpers.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -27,7 +27,7 @@ export const SendToTerminalToolData: IToolData = { id: TerminalToolId.SendToTerminal, toolReferenceName: 'sendToTerminal', displayName: localize('sendToTerminalTool.displayName', 'Send to Terminal'), - modelDescription: `Send input text to an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). The result includes the last few lines of terminal output captured shortly after sending.`, + modelDescription: `Send input text to an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). By default, returns the last 20 lines of terminal output captured shortly after sending. Set 'waitForOutput' to true for interactive programs (games, REPLs, etc.) to wait until the terminal becomes idle before returning output — this gives you the program's response to your input.`, icon: Codicon.terminal, source: ToolDataSource.Internal, inputSchema: { @@ -42,6 +42,10 @@ export const SendToTerminalToolData: IToolData = { type: 'string', description: 'The input text to send to the terminal. The text is sent followed by Enter. Provide an empty or whitespace string to send just Enter (for interactive prompts).' }, + waitForOutput: { + type: 'boolean', + description: 'When true, waits for the terminal to become idle (no new output for a short period) before returning, instead of returning immediately. Use this for interactive programs where you need to see the full response to your input. Defaults to false.' + }, }, required: [ 'id', @@ -53,6 +57,7 @@ export const SendToTerminalToolData: IToolData = { export interface ISendToTerminalInputParams { id: string; command: string; + waitForOutput?: boolean; } const FocusTerminalByIdCommandId = 'workbench.action.terminal.chat.focusTerminalById'; @@ -320,7 +325,7 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { return false; } - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const args = invocation.parameters as ISendToTerminalInputParams; if (!args.id) { @@ -342,6 +347,9 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { }; } + // Register a marker before sending so we can scope output to just the response + const startMarker = execution.instance.registerMarker?.(); + if (isMultilineCommand(args.command)) { // Multiline commands (e.g. heredocs) must preserve newlines and use // bracketed paste mode so the shell treats the input as a single paste @@ -353,14 +361,60 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { await execution.instance.sendText(normalizeCommandForExecution(args.command), true); } - await timeout(100); - const recentOutput = getOutput(execution.instance, undefined, { lastNLines: 5 }); + let recentOutput: string; + if (args.waitForOutput) { + // Wait for the terminal to become idle (no new data) before returning. + // This is critical for interactive programs (games, REPLs, etc.) where + // the response arrives asynchronously after the input. + recentOutput = await this._waitForIdleOutput(execution, startMarker, token); + } else { + await timeout(2000, token); + recentOutput = getOutput(execution.instance, startMarker ?? undefined, { lastNLines: 20 }); + } return { content: [{ kind: 'text', - value: `Successfully sent command to terminal ${args.id}.${recentOutput ? `\n\nTerminal output (last 5 lines):\n${recentOutput}` : ''}` + value: `Successfully sent command to terminal ${args.id}.${recentOutput ? `\n\nTerminal output:\n${recentOutput}` : ''}` }] }; } + + /** + * Waits for the terminal to become idle (no new output for a sustained period) + * and returns the output produced since the given marker. + */ + private async _waitForIdleOutput( + execution: ReturnType & {}, + startMarker: ReturnType | undefined, + token: CancellationToken, + ): Promise { + const maxWaitMs = 30_000; // 30 seconds maximum wait + const idleThresholdMs = 2_000; // Consider idle after 2s of no data + const pollIntervalMs = 500; + let waited = 0; + let lastDataTime = Date.now(); + + const cts = new CancellationTokenSource(token); + const dataListener = execution.instance.onData(() => { + lastDataTime = Date.now(); + }); + + try { + while (!cts.token.isCancellationRequested && waited < maxWaitMs) { + await timeout(pollIntervalMs, cts.token); + waited += pollIntervalMs; + + const timeSinceLastData = Date.now() - lastDataTime; + if (timeSinceLastData >= idleThresholdMs) { + break; + } + } + } finally { + dataListener.dispose(); + cts.dispose(); + } + + return getOutput(execution.instance, startMarker ?? undefined); + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts index 691698e4cfa6d..65af98e10d595 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts @@ -5,9 +5,10 @@ import * as assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { SendToTerminalTool, SendToTerminalToolData } from '../../browser/tools/sendToTerminalTool.js'; import { RunInTerminalTool, type IActiveTerminalExecution } from '../../browser/tools/runInTerminalTool.js'; import type { IToolInvocation, IToolInvocationPreparationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; @@ -45,9 +46,9 @@ suite('SendToTerminalTool', () => { RunInTerminalTool.getExecution = originalGetExecution; }); - function createInvocation(id: string, command: string): IToolInvocation { + function createInvocation(id: string, command: string, waitForOutput?: boolean): IToolInvocation { return { - parameters: { id, command }, + parameters: { id, command, ...(waitForOutput !== undefined ? { waitForOutput } : {}) }, callId: 'test-call', context: { sessionId: 'test-session' }, toolId: 'send_to_terminal', @@ -57,17 +58,21 @@ suite('SendToTerminalTool', () => { } as unknown as IToolInvocation; } - function createMockExecution(output: string): IActiveTerminalExecution & { sentTexts: { text: string; shouldExecute: boolean; forceBracketedPasteMode?: boolean }[] } { + function createMockExecution(output: string): IActiveTerminalExecution & { sentTexts: { text: string; shouldExecute: boolean; forceBracketedPasteMode?: boolean }[]; dataEmitter: Emitter } { const sentTexts: { text: string; shouldExecute: boolean; forceBracketedPasteMode?: boolean }[] = []; + const dataEmitter = store.add(new Emitter()); return { completionPromise: Promise.resolve({ output } as ITerminalExecuteStrategyResult), instance: { sendText: async (text: string, shouldExecute: boolean, forceBracketedPasteMode?: boolean) => { sentTexts.push({ text, shouldExecute, forceBracketedPasteMode }); }, + registerMarker: () => undefined, + onData: dataEmitter.event, } as unknown as ITerminalInstance, getOutput: () => output, sentTexts, + dataEmitter, }; } @@ -405,6 +410,36 @@ suite('SendToTerminalTool', () => { assert.ok(message.value.includes('Focus Terminal'), 'should contain Focus Terminal link text'); }); + test('tool schema includes waitForOutput parameter', () => { + const waitForOutputProperty = SendToTerminalToolData.inputSchema?.properties?.waitForOutput as { type?: string; description?: string } | undefined; + assert.ok(waitForOutputProperty, 'waitForOutput should be in the schema'); + assert.strictEqual(waitForOutputProperty.type, 'boolean'); + assert.ok(waitForOutputProperty.description?.includes('idle')); + }); + + test('waitForOutput=true waits for idle before returning', async () => { + return runWithFakedTimers({}, async () => { + const mockExecution = createMockExecution('output'); + RunInTerminalTool.getExecution = () => mockExecution; + + // Emit some data shortly after invocation starts, then stop + const dataDelay = setTimeout(() => { + mockExecution.dataEmitter.fire('some response data'); + }, 100); + + const result = await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID, 'look', true), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + clearTimeout(dataDelay); + const value = (result.content[0] as { value: string }).value; + assert.ok(value.includes('Successfully sent command')); + }); + }); + test('preserves newlines for heredoc commands and uses bracketed paste mode', async () => { const mockExecution = createMockExecution('output'); RunInTerminalTool.getExecution = () => mockExecution; From 559c217bdc6f762a3180f2058e50ad10412761a2 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 24 Apr 2026 09:53:31 -0700 Subject: [PATCH 13/36] Fix sessions pickers/quick access (#312389) Meant to filter archived, not done --- .../agentHostSessionListController.ts | 3 +- .../agentSessions/agentSessionsPicker.ts | 10 +++- .../agentSessions/agentSessionsQuickAccess.ts | 4 +- .../agentHostChatContribution.test.ts | 17 ++++++ .../agentSessionsDataSource.test.ts | 57 +++++++++++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index 8970d3be651dd..cb8e2d5fad5f1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -161,7 +161,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS }); return this._makeItem(rawId, { title: s.summary, - status: s.status, + status, workingDirectory: s.workingDirectory, createdAt: s.startTime, modifiedAt: s.modifiedTime, @@ -201,6 +201,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS description: this._description, iconPath: getAgentHostIcon(this._productService), status: mapSessionStatus(opts.status), + archived: opts.status !== undefined && (opts.status & SessionStatus.IsArchived) === SessionStatus.IsArchived, metadata: this._buildMetadata(opts.workingDirectory), timing: { created: opts.createdAt, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index d9d3931bdb489..5803f37fb5999 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -12,9 +12,9 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; -import { AgentSessionStatus, IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; +import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js'; import { IAgentSessionsService } from './agentSessionsService.js'; -import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; +import { AgentSessionsSorter, groupAgentSessionsByDate, type IAgentSessionsFilter, sessionDateFromNow } from './agentSessionsViewer.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; import { AgentSessionsFilter } from './agentSessionsFilter.js'; @@ -62,6 +62,10 @@ export function getSessionButtons(session: IAgentSession): IQuickInputButton[] { return buttons; } +export function shouldShowSessionInPicker(session: IAgentSession, filter: IAgentSessionsFilter): boolean { + return !session.isArchived() && !filter.exclude(session); +} + export interface IAgentSessionsPickerOptions { overrideSessionOpen?(session: IAgentSession, openOptions?: ISessionOpenOptions): Promise; } @@ -141,7 +145,7 @@ export class AgentSessionsPicker { private createPickerItems(filter: AgentSessionsFilter): (ISessionPickItem | IQuickPickSeparator)[] { const sessions = this.agentSessionsService.model.sessions - .filter(session => session.status !== AgentSessionStatus.Completed && !filter.exclude(session)) + .filter(session => shouldShowSessionInPicker(session, filter)) .sort(this.sorter.compare.bind(this.sorter)); const items: (ISessionPickItem | IQuickPickSeparator)[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts index 7ab8f018143af..125cc9e6c49f9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts @@ -15,7 +15,7 @@ import { IAgentSession } from './agentSessionsModel.js'; import { openSession } from './agentSessionsOpener.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; -import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, unarchiveButton } from './agentSessionsPicker.js'; +import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, shouldShowSessionInPicker, unarchiveButton } from './agentSessionsPicker.js'; import { AgentSessionsFilter } from './agentSessionsFilter.js'; export const AGENT_SESSIONS_QUICK_ACCESS_PREFIX = 'agent '; @@ -44,7 +44,7 @@ export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider< const picks: Array = []; const sessions = this.agentSessionsService.model.sessions - .filter(session => !this.filter.exclude(session)) + .filter(session => shouldShowSessionInPicker(session, this.filter)) .sort(this.sorter.compare.bind(this.sorter)); const groupedSessions = groupAgentSessionsByDate(sessions); 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 02e99b4979a9f..8d9e3d78cb8b5 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 @@ -639,6 +639,23 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(listController.items.length, 0); }); + + test('refresh marks archived sessions as archived items', async () => { + const { listController, agentHostService } = createContribution(disposables); + + agentHostService.addSession({ + session: AgentSession.uri('copilot', 'archived'), + startTime: 1000, + modifiedTime: 2000, + summary: 'Archived session', + isArchived: true, + }); + + await listController.refresh(CancellationToken.None); + + assert.strictEqual(listController.items.length, 1); + assert.strictEqual(listController.items[0].archived, true); + }); }); // ---- Session ID resolution in _invokeAgent -------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 16ca999555b4d..ceb3d70c97b6d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -13,6 +13,7 @@ import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from '../../../browser/agentSessions/agentSessionsFilter.js'; +import { shouldShowSessionInPicker } from '../../../browser/agentSessions/agentSessionsPicker.js'; suite('sessionDateFromNow', () => { @@ -1348,6 +1349,62 @@ suite('AgentSessionsSorter', () => { }); }); +suite('AgentSessionsPicker', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(overrides: Partial<{ + id: string; + status: ChatSessionStatus; + isArchived: boolean; + }>): IAgentSession { + return { + providerType: 'test', + providerLabel: 'Test', + resource: URI.parse(`test://session/${overrides.id ?? 'default'}`), + status: overrides.status ?? ChatSessionStatus.Completed, + label: `Session ${overrides.id ?? 'default'}`, + icon: Codicon.terminal, + timing: { + created: Date.now(), + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }, + changes: undefined, + metadata: undefined, + isArchived: () => overrides.isArchived ?? false, + setArchived: () => { }, + isPinned: () => false, + setPinned: () => { }, + isRead: () => true, + isMarkedUnread: () => false, + setRead: () => { }, + }; + } + + const filter: IAgentSessionsFilter = { + onDidChange: Event.None, + exclude: () => false, + getExcludes: () => ({ providers: [], states: [], archived: true, read: false, repositoryGroupCapped: true }), + isDefault: () => true, + limitResults: () => undefined, + notifyResults: () => { }, + reset: () => { }, + sortResults: () => undefined, + }; + + test('keeps completed sessions but excludes archived sessions', () => { + const completed = createSession({ id: 'completed', status: ChatSessionStatus.Completed }); + const inProgress = createSession({ id: 'in-progress', status: ChatSessionStatus.InProgress }); + const archived = createSession({ id: 'archived', status: ChatSessionStatus.Completed, isArchived: true }); + + assert.deepStrictEqual( + [completed, inProgress, archived].filter(session => shouldShowSessionInPicker(session, filter)).map(session => session.label), + ['Session completed', 'Session in-progress'] + ); + }); +}); + suite('groupAgentSessionsByDate with sortBy', () => { ensureNoDisposablesAreLeakedInTestSuite(); From f2b51f3f64f0a781a7633c2243cfdde589030e34 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:58:27 -0700 Subject: [PATCH 14/36] Fix browser view being always hidden (#312391) Co-authored-by: Copilot --- src/vs/platform/browserView/electron-main/browserView.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 548dbac4bf645..4361f559533e8 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -592,9 +592,11 @@ export class BrowserView extends Disposable { * Capture a screenshot of this view */ async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { - // This ensures the webContents rendering pipeline is ready so background tabs can be captured too. - this._view.setVisible(true); - this._view.setVisible(false); + if (!this._view.getVisible()) { + // This ensures the webContents rendering pipeline is ready so background tabs can be captured too. + this._view.setVisible(true); + this._view.setVisible(false); + } const quality = options?.quality ?? 80; if (options?.pageRect) { From 4651bee0427ad601f9050ee91d4a6a199f3a01e6 Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:31:31 +0530 Subject: [PATCH 15/36] fix: use setupDelayedHover for settings indicator hovers (#304990) --- .../settingsEditorSettingIndicators.ts | 148 ++++++------------ 1 file changed, 46 insertions(+), 102 deletions(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 47874f09ade60..35a683edd7f58 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -5,10 +5,9 @@ import * as DOM from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { HoverStyle, type IHoverOptions, type IHoverWidget } from '../../../../base/browser/ui/hover/hover.js'; +import { HoverStyle, type IHoverOptions } from '../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { SimpleIconLabel } from '../../../../base/browser/ui/iconLabel/simpleIconLabel.js'; -import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, createMarkdownLink } from '../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; @@ -110,33 +109,6 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { }, }; - private addHoverDisposables(disposables: DisposableStore, element: HTMLElement, showHover: (focus: boolean) => IHoverWidget | undefined) { - disposables.clear(); - const scheduler: RunOnceScheduler = disposables.add(new RunOnceScheduler(() => { - const hover = showHover(false); - if (hover) { - disposables.add(hover); - } - }, this.configurationService.getValue('workbench.hover.delay'))); - disposables.add(DOM.addDisposableListener(element, DOM.EventType.MOUSE_OVER, () => { - if (!scheduler.isScheduled()) { - scheduler.schedule(); - } - })); - disposables.add(DOM.addDisposableListener(element, DOM.EventType.MOUSE_LEAVE, () => { - scheduler.cancel(); - })); - disposables.add(DOM.addDisposableListener(element, DOM.EventType.KEY_DOWN, (e) => { - const evt = new StandardKeyboardEvent(e); - if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { - const hover = showHover(true); - if (hover) { - disposables.add(hover); - } - e.preventDefault(); - } - })); - } private createWorkspaceTrustIndicator(): SettingIndicator { const disposables = new DisposableStore(); @@ -145,21 +117,17 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { workspaceTrustLabel.text = '$(shield) ' + localize('workspaceUntrustedLabel', "Requires workspace trust"); const content = localize('trustLabel', "The setting value can only be applied in a trusted workspace."); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - target: workspaceTrustElement, - actions: [{ - label: localize('manageWorkspaceTrust', "Manage Workspace Trust"), - commandId: 'workbench.trust.manage', - run: (target: HTMLElement) => { - this.commandService.executeCommand('workbench.trust.manage'); - } - }], - }, focus); - }; - this.addHoverDisposables(disposables, workspaceTrustElement, showHover); + disposables.add(this.hoverService.setupDelayedHover(workspaceTrustElement, () => ({ + ...this.defaultHoverOptions, + content, + actions: [{ + label: localize('manageWorkspaceTrust', "Manage Workspace Trust"), + commandId: 'workbench.trust.manage', + run: (target: HTMLElement) => { + this.commandService.executeCommand('workbench.trust.manage'); + } + }], + }), { setupKeyboardEvents: true })); return { element: workspaceTrustElement, label: workspaceTrustLabel, @@ -186,14 +154,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { syncIgnoredLabel.text = localize('extensionSyncIgnoredLabel', 'Not synced'); const syncIgnoredHoverContent = localize('syncIgnoredTitle', "This setting is ignored during sync"); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content: syncIgnoredHoverContent, - target: syncIgnoredElement - }, focus); - }; - this.addHoverDisposables(disposables, syncIgnoredElement, showHover); + disposables.add(this.hoverService.setupDelayedHover(syncIgnoredElement, { + ...this.defaultHoverOptions, + content: syncIgnoredHoverContent, + }, { setupKeyboardEvents: true })); return { element: syncIgnoredElement, @@ -233,14 +197,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { const advancedLabel = disposables.add(new SimpleIconLabel(advancedIndicator)); advancedLabel.text = localize('advancedLabel', "Advanced"); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content: ADVANCED_INDICATOR_DESCRIPTION, - target: advancedIndicator - }, focus); - }; - this.addHoverDisposables(disposables, advancedIndicator, showHover); + disposables.add(this.hoverService.setupDelayedHover(advancedIndicator, { + ...this.defaultHoverOptions, + content: ADVANCED_INDICATOR_DESCRIPTION, + }, { setupKeyboardEvents: true })); return { element: advancedIndicator, @@ -351,14 +311,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { localize('experimentalLabel', "Experimental"); const content = isPreviewSetting ? PREVIEW_INDICATOR_DESCRIPTION : EXPERIMENTAL_INDICATOR_DESCRIPTION; - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - target: this.previewIndicator.element - }, focus); - }; - this.addHoverDisposables(this.previewIndicator.disposables, this.previewIndicator.element, showHover); + this.previewIndicator.disposables.add(this.hoverService.setupDelayedHover(this.previewIndicator.element, { + ...this.defaultHoverOptions, + content, + }, { setupKeyboardEvents: true })); this.render(); } @@ -402,21 +358,17 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { this.scopeOverridesIndicator.label.text = '$(briefcase) ' + localize('policyLabelText', "Managed by organization"); const content = localize('policyDescription', "This setting is managed by your organization and its actual value cannot be changed."); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - actions: [{ - label: localize('policyFilterLink', "View policy settings"), - commandId: '_settings.action.viewPolicySettings', - run: (_) => { - onApplyFilter.fire(`@${POLICY_SETTING_TAG}`); - } - }], - target: this.scopeOverridesIndicator.element - }, focus); - }; - this.addHoverDisposables(this.scopeOverridesIndicator.disposables, this.scopeOverridesIndicator.element, showHover); + this.scopeOverridesIndicator.disposables.add(this.hoverService.setupDelayedHover(this.scopeOverridesIndicator.element, () => ({ + ...this.defaultHoverOptions, + content, + actions: [{ + label: localize('policyFilterLink', "View policy settings"), + commandId: '_settings.action.viewPolicySettings', + run: (_) => { + onApplyFilter.fire(`@${POLICY_SETTING_TAG}`); + } + }], + }), { setupKeyboardEvents: true })); } else if (element.settingsTarget === ConfigurationTarget.USER_LOCAL && this.configurationService.isSettingAppliedForAllProfiles(element.setting.key)) { this.scopeOverridesIndicator.element.style.display = 'inline'; this.scopeOverridesIndicator.element.classList.add('setting-indicator'); @@ -424,14 +376,10 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { this.scopeOverridesIndicator.label.text = localize('applicationSetting', "Applies to all profiles"); const content = localize('applicationSettingDescription', "The setting is not specific to the current profile, and will retain its value when switching profiles."); - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - ...this.defaultHoverOptions, - content, - target: this.scopeOverridesIndicator.element - }, focus); - }; - this.addHoverDisposables(this.scopeOverridesIndicator.disposables, this.scopeOverridesIndicator.element, showHover); + this.scopeOverridesIndicator.disposables.add(this.hoverService.setupDelayedHover(this.scopeOverridesIndicator.element, { + ...this.defaultHoverOptions, + content, + }, { setupKeyboardEvents: true })); } else if (element.overriddenScopeList.length || element.overriddenDefaultsLanguageList.length) { if (element.overriddenScopeList.length === 1 && !element.overriddenDefaultsLanguageList.length) { // We can inline the override and show all the text in the label @@ -540,17 +488,13 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { defaultOverrideHoverContent = localize('multipledefaultOverriddenDetails', "A default values has been set by {0}", sourceToDisplay.slice(0, -1).join(', ') + ' & ' + sourceToDisplay.slice(-1)); } - const showHover = (focus: boolean) => { - return this.hoverService.showInstantHover({ - content: new MarkdownString().appendMarkdown(defaultOverrideHoverContent), - target: this.defaultOverrideIndicator.element, - style: HoverStyle.Pointer, - position: { - hoverPosition: HoverPosition.BELOW, - }, - }, focus); - }; - this.addHoverDisposables(this.defaultOverrideIndicator.disposables, this.defaultOverrideIndicator.element, showHover); + this.defaultOverrideIndicator.disposables.add(this.hoverService.setupDelayedHover(this.defaultOverrideIndicator.element, () => ({ + content: new MarkdownString().appendMarkdown(defaultOverrideHoverContent), + style: HoverStyle.Pointer, + position: { + hoverPosition: HoverPosition.BELOW, + }, + }), { setupKeyboardEvents: true })); } this.render(); } From 439c85ab2dbd9dab08f78232c490fa56cb91d914 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:29:55 -0700 Subject: [PATCH 16/36] sessions: enable customizations UI for all session types with deep-link navigation (#312398) Previously the customizations sidebar only worked for copilotcli sessions. This change extends support to any session type that has a registered content provider, making it work for claude-code and other AHP harnesses. - Remove hardcoded copilotcli gate in mainThreadChatAgents2; now any session type with a registered content provider schema is accepted - Wire ICustomizationHarnessService into count widgets so they use the active harness's itemProvider when available (avoids count mismatches for remote item providers) - Add getActiveItemProvider() utility and update getCustomizationTotalCount() to accept an optional itemProvider for harness-backed sessions - Each sidebar customization link now deep-links to its section in the management editor via selectSectionById - Use reader.store pattern instead of MutableDisposable + manual rebind for itemProvider change subscriptions - Add tests for getActiveItemProvider and getCustomizationTotalCount with itemProvider; add ICustomizationHarnessService mock to widget fixture - Update AI_CUSTOMIZATIONS.md to reflect the new multi-harness sessions behavior --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 6 +- .../browser/aiCustomizationShortcutsWidget.ts | 18 ++- .../sessions/browser/customizationCounts.ts | 38 ++++- .../customizationsToolbar.contribution.ts | 55 +++++-- .../aiCustomizationShortcutsWidget.fixture.ts | 5 + .../test/browser/customizationCounts.test.ts | 148 +++++++++++++++++- .../api/browser/mainThreadChatAgents2.ts | 6 +- 7 files changed, 250 insertions(+), 26 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 5dc82ac562e2a..f691d6641ea1c 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -51,7 +51,7 @@ Sessions-specific overrides: ``` src/vs/sessions/contrib/chat/browser/ ├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (CLI harness only) +├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ ├── aiCustomizationShortcutsWidget.ts # Shortcuts widget @@ -92,7 +92,7 @@ Available harnesses: | `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. -In sessions, only CLI is registered (single harness, toggle bar hidden). +In sessions, harnesses are accepted for any session type that has a registered content provider (checked via `IChatSessionsService.getContentProviderSchemes()`). AHP remote servers register directly via `registerExternalHarness`. ### IHarnessDescriptor @@ -220,7 +220,7 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Count Consistency -`customizationCounts.ts` uses the **same data sources** as the list widget. Both go through the active harness's `ICustomizationItemProvider` (or the `PromptsServiceCustomizationItemProvider` fallback), ensuring counts match what the list displays. +`customizationCounts.ts` uses the **same data sources** as the list widget. When a harness with an `itemProvider` is active (determined by `getActiveItemProvider()`), counts come from that provider's `provideChatSessionCustomizations()`. Otherwise, both counts and the list go through the `PromptsServiceCustomizationItemProvider` fallback, ensuring counts match what the list displays. ### Item Badges diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index cd2bbee6e27b3..cf696435464c1 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -21,8 +21,10 @@ import { IPromptsService } from '../../../../workbench/contrib/chat/common/promp import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { Menus } from '../../../browser/menus.js'; -import { getCustomizationTotalCount } from './customizationCounts.js'; +import { getCustomizationTotalCount, getActiveItemProvider } from './customizationCounts.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; const $ = DOM.$; @@ -46,6 +48,8 @@ export class AICustomizationShortcutsWidget extends Disposable { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, ) { super(); @@ -101,9 +105,10 @@ export class AICustomizationShortcutsWidget extends Disposable { })); let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService); + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService, getActiveItemProvider(this.sessionsManagementService, this.harnessService)); if (requestId !== updateCountRequestId) { return; } @@ -123,6 +128,15 @@ export class AICustomizationShortcutsWidget extends Disposable { this.workspaceService.activeProjectRoot.read(reader); updateHeaderTotalCount(); })); + this._register(autorun(reader => { + this.sessionsManagementService.activeSession.read(reader); + this.harnessService.availableHarnesses.read(reader); + const provider = getActiveItemProvider(this.sessionsManagementService, this.harnessService); + if (provider) { + reader.store.add(provider.onDidChange(() => updateHeaderTotalCount())); + } + updateHeaderTotalCount(); + })); updateHeaderTotalCount(); // Toggle collapse on header click diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index 9c30d64c313b4..90c1b0719c136 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -15,7 +15,9 @@ import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.j import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { parse as parseJSONC } from '../../../../base/common/jsonc.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; export interface ISourceCounts { readonly workspace: number; @@ -136,19 +138,41 @@ export async function getSourceCounts( }; } +const PROMPT_TYPES: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook]; +const PROMPT_TYPE_SET = new Set(PROMPT_TYPES); + export async function getCustomizationTotalCount( promptsService: IPromptsService, mcpService: IMcpService, workspaceService: IAICustomizationWorkspaceService, workspaceContextService: IWorkspaceContextService, agentPluginService?: IAgentPluginService, + itemProvider?: ICustomizationItemProvider, ): Promise { - const types: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook]; - const results = await Promise.all(types.map(type => { - const filter = workspaceService.getStorageSourceFilter(type); - return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) - .then(counts => getSourceCountsTotal(counts, filter)); - })); + let promptTotal: number; + if (itemProvider) { + const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None); + promptTotal = allItems?.filter(item => PROMPT_TYPE_SET.has(item.type)).length ?? 0; + } else { + const results = await Promise.all(PROMPT_TYPES.map(type => { + const filter = workspaceService.getStorageSourceFilter(type); + return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) + .then(counts => getSourceCountsTotal(counts, filter)); + })); + promptTotal = results.reduce((sum, n) => sum + n, 0); + } + const pluginCount = agentPluginService?.plugins.get().length ?? 0; - return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length + pluginCount; + return promptTotal + mcpService.servers.get().length + pluginCount; +} + +export function getActiveItemProvider( + sessionsManagementService: ISessionsManagementService, + harnessService: ICustomizationHarnessService, +): ICustomizationItemProvider | undefined { + const sessionType = sessionsManagementService.activeSession.get()?.sessionType; + if (sessionType) { + return harnessService.findHarnessById(sessionType)?.itemProvider; + } + return undefined; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index e0d453627eab0..c4062dc948f2c 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -5,6 +5,7 @@ import '../../../browser/media/sidebarActionButton.css'; import './media/customizationsToolbar.css'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -12,6 +13,7 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; @@ -27,16 +29,18 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IFileService } from '../../../../platform/files/common/files.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal, getActiveItemProvider } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; export interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; + readonly section: typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; readonly promptType?: PromptsType; readonly isMcp?: boolean; readonly isPlugins?: boolean; @@ -47,36 +51,42 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ id: 'sessions.customization.agents', label: localize('agents', "Agents"), icon: agentIcon, + section: AICustomizationManagementSection.Agents, promptType: PromptsType.agent, }, { id: 'sessions.customization.skills', label: localize('skills', "Skills"), icon: skillIcon, + section: AICustomizationManagementSection.Skills, promptType: PromptsType.skill, }, { id: 'sessions.customization.instructions', label: localize('instructions', "Instructions"), icon: instructionsIcon, + section: AICustomizationManagementSection.Instructions, promptType: PromptsType.instructions, }, { id: 'sessions.customization.hooks', label: localize('hooks', "Hooks"), icon: hookIcon, + section: AICustomizationManagementSection.Hooks, promptType: PromptsType.hook, }, { id: 'sessions.customization.mcpServers', label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, + section: AICustomizationManagementSection.McpServers, isMcp: true, }, { id: 'sessions.customization.plugins', label: localize('plugins', "Plugins"), icon: pluginIcon, + section: AICustomizationManagementSection.Plugins, isPlugins: true, }, ]; @@ -103,6 +113,7 @@ export class CustomizationLinkViewItem extends ActionViewItem { @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, @IFileService private readonly _fileService: IFileService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @ICustomizationHarnessService private readonly _harnessService: ICustomizationHarnessService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -153,6 +164,11 @@ export class CustomizationLinkViewItem extends ActionViewItem { this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); this._viewItemDisposables.add(autorun(reader => { this._activeSessionService.activeSession.read(reader); + this._harnessService.availableHarnesses.read(reader); + const provider = getActiveItemProvider(this._activeSessionService, this._harnessService); + if (provider) { + reader.store.add(provider.onDidChange(() => this._updateCounts())); + } this._updateCounts(); })); @@ -168,16 +184,26 @@ export class CustomizationLinkViewItem extends ActionViewItem { } const requestId = ++this._updateCountsRequestId; + const itemProvider = getActiveItemProvider(this._activeSessionService, this._harnessService); if (this._config.promptType) { - const type = this._config.promptType; - const filter = this._workspaceService.getStorageSourceFilter(type); - const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); - if (requestId !== this._updateCountsRequestId) { - return; + if (itemProvider) { + const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None); + if (requestId !== this._updateCountsRequestId) { + return; + } + const total = allItems?.filter(item => item.type === this._config.promptType).length ?? 0; + this._renderTotalCount(this._countContainer, total); + } else { + const type = this._config.promptType; + const filter = this._workspaceService.getStorageSourceFilter(type); + const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); + if (requestId !== this._updateCountsRequestId) { + return; + } + const total = getSourceCountsTotal(counts, filter); + this._renderTotalCount(this._countContainer, total); } - const total = getSourceCountsTotal(counts, filter); - this._renderTotalCount(this._countContainer, total); } else if (this._config.isMcp) { const total = this._mcpService.servers.get().length; this._renderTotalCount(this._countContainer, total); @@ -231,8 +257,17 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo } async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); + const harnessService = accessor.get(ICustomizationHarnessService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const activeSessionType = sessionsManagementService.activeSession.get()?.sessionType; + if (activeSessionType && harnessService.findHarnessById(activeSessionType)) { + harnessService.setActiveHarness(activeSessionType); + } const input = AICustomizationManagementEditorInput.getOrCreate(); - await editorService.openEditor(input, { pinned: true }); + const pane = await editorService.openEditor(input, { pinned: true }); + if (pane instanceof AICustomizationManagementEditor) { + pane.selectSectionById(config.section); + } } })); } diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 1b67d7808761e..7c06c93808544 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -21,6 +21,7 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; @@ -204,6 +205,10 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: reg.defineInstance(ISessionsManagementService, new class extends mock() { override readonly activeSession = observableValue('activeSession', undefined); }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly availableHarnesses = observableValue('availableHarnesses', []); + override findHarnessById() { return undefined; } + }()); reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; }()); diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts index 2e4afacb53c13..3bb2f7738ec92 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -10,10 +10,14 @@ import { PromptsType } from '../../../../../workbench/contrib/chat/common/prompt import { IPromptsService, PromptsStorage, IPromptPath, ILocalPromptPath, IUserPromptPath, IExtensionPromptPath, IAgentInstructionFile, AgentInstructionFileType } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; -import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount } from '../../browser/customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount, getActiveItemProvider } from '../../browser/customizationCounts.js'; import { IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; function localFile(path: string): ILocalPromptPath { return { uri: URI.file(path), storage: PromptsStorage.local, type: PromptsType.instructions }; @@ -691,6 +695,148 @@ suite('customizationCounts', () => { }); }); + suite('getActiveItemProvider', () => { + function createMockSessionsService(sessionType?: string): ISessionsManagementService { + const activeSession = observableValue( + 'test', + sessionType ? { sessionType } as IActiveSession : undefined, + ); + return { activeSession } as unknown as ISessionsManagementService; + } + + function createMockHarnessService(harnesses: { id: string; itemProvider?: ICustomizationItemProvider }[]): ICustomizationHarnessService { + return { + findHarnessById: (sessionType: string) => { + const h = harnesses.find(h => h.id === sessionType); + return h ? { id: h.id, itemProvider: h.itemProvider } as IHarnessDescriptor : undefined; + }, + } as unknown as ICustomizationHarnessService; + } + + test('returns undefined when no active session', () => { + const sessionsService = createMockSessionsService(undefined); + const harnessService = createMockHarnessService([]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); + }); + + test('returns undefined when session type has no matching harness', () => { + const sessionsService = createMockSessionsService('unknown-type'); + const harnessService = createMockHarnessService([{ id: 'copilotcli' }]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); + }); + + test('returns undefined when harness has no itemProvider', () => { + const sessionsService = createMockSessionsService('copilotcli'); + const harnessService = createMockHarnessService([{ id: 'copilotcli', itemProvider: undefined }]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); + }); + + test('returns the itemProvider when harness exists with one', () => { + const mockProvider: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + }; + const sessionsService = createMockSessionsService('claude-code'); + const harnessService = createMockHarnessService([{ id: 'claude-code', itemProvider: mockProvider }]); + assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), mockProvider); + }); + }); + + suite('getCustomizationTotalCount with itemProvider', () => { + function createItemProvider(items: ICustomizationItem[]): ICustomizationItemProvider { + return { + onDidChange: Event.None, + provideChatSessionCustomizations: async (_token: CancellationToken) => items, + }; + } + + function makeItem(type: string, name: string): ICustomizationItem { + return { uri: URI.file(`/mock/${name}`), type, name, extensionId: undefined, pluginUri: undefined }; + } + + test('uses itemProvider counts when provided', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider = createItemProvider([ + makeItem('agent', 'my-agent'), + makeItem('skill', 'my-skill'), + makeItem('instructions', 'my-instruction'), + makeItem('hook', 'my-hook'), + ]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); + + // 4 from provider + 1 mcp = 5 + assert.strictEqual(total, 5); + }); + + test('ignores non-prompt types from itemProvider', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', []), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider = createItemProvider([ + makeItem('agent', 'a'), + makeItem('unknown-type', 'x'), + makeItem('prompt', 'p'), + ]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); + + // Only 'agent' matches the prompt types (agent, skill, instructions, hook) + assert.strictEqual(total, 1); + }); + + test('itemProvider returning undefined counts as zero', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => undefined, + }; + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); + + // 0 from provider + 2 mcp = 2 + assert.strictEqual(total, 2); + }); + + test('sums itemProvider counts with plugins and mcp', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); + const contextService = createMockWorkspaceContextService([]); + + const provider = createItemProvider([ + makeItem('agent', 'a'), + makeItem('skill', 's'), + ]); + const agentPluginService = { + plugins: observableValue('test', [{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }]), + } as unknown as IAgentPluginService; + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, agentPluginService, provider); + + // 2 from provider + 1 mcp + 3 plugins = 6 + assert.strictEqual(total, 6); + }); + }); + suite('data source consistency', () => { // These tests verify that getSourceCounts uses the same data sources // as the list widget's loadItems() — the root cause of the count mismatch bug. diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index db1ab19404444..7f7211ae2af4b 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -714,10 +714,10 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA } async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier): Promise { - // In the sessions window, only the Copilot CLI harness is accepted via the - // extension API. Other harnesses (e.g. Claude) are not shown in sessions. + // In the sessions window, only accept harnesses for session types that + // have a registered content provider (i.e., can actually run sessions). // AHP remote servers register directly via registerExternalHarness. - if (this._environmentService.isSessionsWindow && chatSessionType !== 'copilotcli') { + if (this._environmentService.isSessionsWindow && !this._chatSessionService.getContentProviderSchemes().includes(chatSessionType)) { return; } From 5bd1da41da00324ddc5289e8504fa35824efbdea Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 24 Apr 2026 19:35:26 +0200 Subject: [PATCH 17/36] feedback Co-authored-by: Copilot --- .../electron-browser/openInVSCode.contribution.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 5f27a61b6110a..975d1a0112319 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -25,6 +25,8 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { resolveRemoteAuthority } from '../browser/openInVSCodeUtils.js'; import { DebugAgentHostInDevToolsAction } from '../../../../workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.js'; +import { isLinux } from '../../../../base/common/platform.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; /** * Desktop version of the "Open in VS Code" action. @@ -58,6 +60,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { logSessionsInteraction(telemetryService, 'openInVSCode'); const productService = accessor.get(IProductService); + const environmentService = accessor.get(IEnvironmentService); const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); @@ -71,15 +74,8 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { ? resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService) : undefined; - const hasSibling = !!( - productService.darwinSiblingBundleIdentifier || - productService.win32SiblingExeBasename || - productService.embedded?.darwinSiblingBundleIdentifier || - productService.embedded?.win32SiblingExeBasename - ); - - if (hasSibling) { - await this.launchViaSiblingApp(accessor, productService, activeSession, folderUri, remoteAuthority); + if (environmentService.isBuilt && !isLinux) { + await this.launchViaSiblingApp(accessor, activeSession, folderUri, remoteAuthority); } else { await this.launchViaProtocolHandler(accessor, productService, activeSession, folderUri, remoteAuthority); } @@ -87,7 +83,6 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { private async launchViaSiblingApp( accessor: ServicesAccessor, - productService: IProductService, activeSession: ReturnType, folderUri: URI | undefined, remoteAuthority: string | undefined, From a080227496373ca528db576419809f4e6b73e78a Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 24 Apr 2026 17:23:51 +0200 Subject: [PATCH 18/36] Improve screenshot diff logic for pull requests and pushes; add job summary step --- .github/workflows/screenshot-test.yml | 70 ++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index 6354e498a4dfa..937f95e700589 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -138,17 +138,23 @@ jobs: - name: Diff screenshots against merge base id: diff - if: github.event_name == 'pull_request' && steps.oidc.outputs.token + if: steps.oidc.outputs.token run: | - # We diff screenshots(checked-out commit) vs screenshots(merge-base of - # that commit with the target branch). This isolates the visual effect - # of just this PR's divergence from target. Using pull_request.base.sha - # would be wrong: it's the target-branch tip at PR creation time and can - # be stale, causing unrelated target-branch commits to show up as diffs. - TARGET_REF="origin/${{ github.event.pull_request.base.ref }}" - git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" - BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF") - echo "Using base SHA: $BASE_SHA (merge-base of ${{ github.sha }} and $TARGET_REF)" + if [ "${{ github.event_name }}" = "pull_request" ]; then + # For PRs, diff against the merge-base with the target branch. + # This isolates the visual effect of just this PR's divergence + # from target. Using pull_request.base.sha would be wrong: it's + # the target-branch tip at PR creation time and can be stale, + # causing unrelated target-branch commits to show up as diffs. + TARGET_REF="origin/${{ github.event.pull_request.base.ref }}" + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" + BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF") + else + # For push events, diff against the parent commit. + BASE_SHA=$(git rev-parse "${{ github.sha }}^") + fi + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "Using base SHA: $BASE_SHA (base for ${{ github.sha }})" BODY=$(node build/lib/screenshotDiffReport.ts \ https://hediet-screenshots.azurewebsites.net \ ${{ github.repository_owner }} \ @@ -172,8 +178,25 @@ jobs: env: SCREENSHOT_SERVICE_TOKEN: ${{ steps.oidc.outputs.token }} + - name: Write job summary + if: steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false' + run: | + BODY="${COMMENT_BODY}" + if [ -n "$BLOCKS_CI_CONTENT" ]; then + if [ -n "$BODY" ]; then BODY+=$'\n\n---\n\n'; fi + BODY+="### blocks-ci screenshots changed"$'\n\n' + BODY+="Replace the contents of \`test/componentFixtures/blocks-ci-screenshots.md\` with:"$'\n\n' + BODY+="
"$'\n'"Updated blocks-ci-screenshots.md"$'\n\n' + BODY+="\`\`\`md"$'\n'"${BLOCKS_CI_CONTENT}"$'\n'"\`\`\`"$'\n\n' + BODY+="
" + fi + echo "$BODY" >> "$GITHUB_STEP_SUMMARY" + env: + COMMENT_BODY: ${{ steps.diff.outputs.body }} + BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }} + - name: Post PR comment - if: github.event_name == 'pull_request' && (steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false') + if: github.event_name == 'pull_request' uses: actions/github-script@v9 with: script: | @@ -190,7 +213,7 @@ jobs: body += ''; } - body = marker + '\n' + body; + const hasContent = body || blocksCiContent; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -200,6 +223,27 @@ jobs: }); const existing = comments.find(c => c.body?.startsWith(marker)); + if (!hasContent) { + // No changes to report — update existing comment if present, otherwise do nothing + if (existing) { + const baseSha = (process.env.BASE_SHA || '').slice(0, 8); + const currentSha = (process.env.CURRENT_SHA || '').slice(0, 8); + let noChangesBody = '~No screenshot changes.~'; + if (baseSha && currentSha) { + noChangesBody = `**Base:** \`${baseSha}\` **Current:** \`${currentSha}\`\n\n` + noChangesBody; + } + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: marker + '\n' + noChangesBody, + }); + } + return; + } + + body = marker + '\n' + body; + if (existing) { await github.rest.issues.updateComment({ owner: context.repo.owner, @@ -218,6 +262,8 @@ jobs: env: COMMENT_BODY: ${{ steps.diff.outputs.body }} BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }} + BASE_SHA: ${{ steps.diff.outputs.base_sha }} + CURRENT_SHA: ${{ github.sha }} - name: Fail if blocks-ci hashes changed if: steps.blocks-ci.outputs.match == 'false' From bb09e7379b060060bb6fa6b1a9cc9aee4fe34b83 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:07:16 -0700 Subject: [PATCH 19/36] terminal: Disable read all by default (#311850) * adding allowRead and testing with defaults * Rename terminal sandbox read allow list * Remove Copilot settings change from sandbox PR * changes * changes * Updating sandbox runtime package * Updating tests * Add macOS test cases for denyRead/allowRead behavior and ~ path handling Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/ec5cf3c2-6c7b-4577-bdbb-8ac3d42bdfb0 Co-authored-by: dileepyavan <52841896+dileepyavan@users.noreply.github.com> * changes for readonly home dir * skipping integrated tests for sandbox * running srt in tmp_dir for linux * running srt in tmp_dir for linux --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../chat.runInTerminal.test.ts | 19 +- package-lock.json | 31 +- package.json | 2 +- remote/package-lock.json | 31 +- remote/package.json | 2 +- .../sandbox/common/terminalSandboxService.ts | 4 +- .../commandLineSandboxRewriter.ts | 20 +- .../browser/tools/runInTerminalTool.ts | 2 +- .../browser/treeSitterCommandParser.ts | 24 ++ .../terminalChatAgentToolsConfiguration.ts | 14 + .../chatAgentTools/common/terminalSandbox.ts | 1 + .../common/terminalSandboxReadAllowList.ts | 328 ++++++++++++++++++ .../common/terminalSandboxService.ts | 118 ++++++- .../sandboxedCommandLinePresenter.test.ts | 2 +- .../browser/terminalSandboxService.test.ts | 299 +++++++++++++--- .../commandLineSandboxRewriter.test.ts | 35 +- .../runInTerminalTool.test.ts | 10 +- .../treeSitterCommandParser.test.ts | 19 + 18 files changed, 814 insertions(+), 147 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxReadAllowList.ts diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 7e93cb0b298ad..6f8be91bd4fa6 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -340,7 +340,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { // Step 1: Write a sentinel file into the sandbox-provided $TMPDIR. const writeOutput = await invokeRunInTerminal(`echo ${marker} > "$TMPDIR/${sentinelName}" && echo ${marker}`); - assert.strictEqual(writeOutput.trim(), marker); + assert.ok(writeOutput.trim().endsWith(marker), `Unexpected output: ${JSON.stringify(writeOutput.trim())}`); // Step 2: Retry with requestUnsandboxedExecution=true while sandbox // stays enabled. The tool should preserve $TMPDIR from the sandbox so @@ -351,7 +351,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { requestUnsandboxedExecutionReason: 'Need to verify $TMPDIR persists on unsandboxed retry', }); const trimmed = retryOutput.trim(); - assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`); + assert.ok(trimmed.includes('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`); assert.ok(trimmed.includes(`cat "$TMPDIR/${sentinelName}"`), `Unexpected output: ${JSON.stringify(trimmed)}`); assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`); }); @@ -378,13 +378,17 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const trimmed = output.trim(); // macOS: "# List of acceptable shells for chpass(1)." // Linux: "# /etc/shells: valid login shells" + // On headless Linux CI, Electron/Chromium may emit DBus stderr lines + // before the actual command output, so check the *last* line rather + // than requiring the whole trimmed buffer to start with '#'. + const lastLine = trimmed.split('\n').pop() ?? ''; assert.ok( - trimmed.startsWith('#'), + lastLine.startsWith('#'), `Expected a comment line from /etc/shells, got: ${trimmed}` ); }); - test('can write inside the workspace folder', async function () { + test.skip('can write inside the workspace folder', async function () { this.timeout(60000); const marker = `SANDBOX_WS_${Date.now()}`; @@ -399,7 +403,12 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { const marker = `SANDBOX_TMPDIR_${Date.now()}`; const output = await invokeRunInTerminal(`echo "${marker}" > "$TMPDIR/${marker}.tmp" && cat "$TMPDIR/${marker}.tmp" && rm "$TMPDIR/${marker}.tmp"`); - assert.strictEqual(output.trim(), marker); + // On headless Linux CI, Electron/Chromium may emit DBus stderr lines + // before the actual command output, so check the *last* line rather + // than requiring the entire trimmed output to equal the marker. + const trimmed = output.trim(); + const lastLine = trimmed.split('\n').pop() ?? ''; + assert.strictEqual(lastLine, marker, `Unexpected output: ${JSON.stringify(trimmed)}`); }); test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () { diff --git a/package-lock.json b/package-lock.json index b09cf211da1b7..477c2579e7626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", @@ -188,15 +188,13 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", - "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.49.tgz", + "integrity": "sha512-t8Ggc0A7UizxMGPk/ANEH8nwnCqzNWIKpkdKgxDVUaKNMQnMzzWR6aErrqIdU03/ZP5RN6/OL/kjFOw/Vox3KQ==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", - "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -2759,21 +2757,6 @@ "@types/node": "*" } }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -13308,12 +13291,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", diff --git a/package.json b/package.json index 8d32f1f41eca0..829ab071f3f2f 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/rspack && npm install @vscode/component-explorer-webpack-plugin@next @vscode/component-explorer@next && cd ../vite && npm install @vscode/component-explorer-vite-plugin@next @vscode/component-explorer@next" }, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", diff --git a/remote/package-lock.json b/remote/package-lock.json index 65394f951c81e..5cd9518aae58f 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", @@ -54,15 +54,13 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", - "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", + "version": "0.0.49", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.49.tgz", + "integrity": "sha512-t8Ggc0A7UizxMGPk/ANEH8nwnCqzNWIKpkdKgxDVUaKNMQnMzzWR6aErrqIdU03/ZP5RN6/OL/kjFOw/Vox3KQ==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", - "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -582,21 +580,6 @@ "node": ">= 10" } }, - "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@vscode/deviceid": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz", @@ -1219,12 +1202,6 @@ "node": ">=12.9.0" } }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/remote/package.json b/remote/package.json index 0143b6c5ce3d6..123ff2899d7f7 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.42", + "@anthropic-ai/sandbox-runtime": "0.0.49", "@github/copilot": "^1.0.34", "@github/copilot-sdk": "^0.2.2", "@microsoft/1ds-core-js": "^3.2.13", diff --git a/src/vs/platform/sandbox/common/terminalSandboxService.ts b/src/vs/platform/sandbox/common/terminalSandboxService.ts index f790fbdb1fdb0..ebaa2bd9d225f 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxService.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxService.ts @@ -73,7 +73,7 @@ export interface ITerminalSandboxService { isEnabled(): Promise; getOS(): Promise; checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; - wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, commandKeywords?: readonly string[], cwd?: URI): Promise; getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; @@ -97,7 +97,7 @@ export class NullTerminalSandboxService implements ITerminalSandboxService { return { enabled: false, sandboxConfigPath: undefined, failedCheck: undefined }; } - wrapCommand(command: string): ITerminalSandboxWrapResult { + async wrapCommand(command: string): Promise { return { command, isSandboxWrapped: false }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 6b0dccf6065bc..f2de8f4aa509c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { isPowerShell } from '../../runInTerminalHelpers.js'; +import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../../treeSitterCommandParser.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../common/terminalSandboxService.js'; import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js'; export class CommandLineSandboxRewriter extends Disposable implements ICommandLineRewriter { constructor( + private readonly _treeSitterCommandParser: TreeSitterCommandParser, @ITerminalSandboxService private readonly _sandboxService: ITerminalSandboxService, ) { super(); @@ -20,7 +23,7 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi return undefined; } - const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell); + const wrappedCommand = await this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell, await this._parseCommandKeywords(options), options.cwd); return { rewritten: wrappedCommand.command, reasoning: wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', @@ -31,4 +34,19 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi deniedDomains: wrappedCommand.deniedDomains, }; } + + private async _parseCommandKeywords(options: ICommandLineRewriterOptions): Promise { + try { + if (options.requestUnsandboxedExecution === true) { + // if the user is requesting unsandboxed execution, not required to parse the command. + return []; + } + const languageId = isPowerShell(options.shell, options.os) + ? TreeSitterCommandParserLanguage.PowerShell + : TreeSitterCommandParserLanguage.Bash; + return await this._treeSitterCommandParser.extractCommandKeywords(languageId, options.commandLine); + } catch { + return []; + } + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index af9159a59466f..8f4c3c3837768 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -572,7 +572,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)), ]; if (this._enableCommandLineSandboxRewriting) { - this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter))); + this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter, this._treeSitterCommandParser))); } // BackgroundDetachRewriter must come after SandboxRewriter so that nohup/Start-Process // wraps the entire sandbox runtime, keeping both the sandbox and the child process alive diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts index 6e4d115f68f64..58811e656e80a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts @@ -8,6 +8,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { posix, win32 } from '../../../../../base/common/path.js'; import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; import { ICommandFileWriteParser } from './commandParsers/commandFileWriteParser.js'; import { SedFileWriteParser } from './commandParsers/sedFileWriteParser.js'; @@ -72,6 +73,18 @@ export class TreeSitterCommandParser extends Disposable { return captures; } + async extractCommandKeywords(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise { + const captures = await this._queryTree(languageId, commandLine, '(command_name) @command'); + const keywords = new Set(); + for (const capture of captures) { + const normalized = this._normalizeCommandKeyword(capture.node.text); + if (normalized) { + keywords.add(normalized); + } + } + return [...keywords]; + } + async getFileWrites(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise { let query: string; switch (languageId) { @@ -124,6 +137,17 @@ export class TreeSitterCommandParser extends Disposable { return query.captures(tree.rootNode); } + private _normalizeCommandKeyword(token: string): string | undefined { + const unquoted = token.replace(/^['"]|['"]$/g, ''); + if (!unquoted) { + return undefined; + } + + const pathBase = unquoted.includes('\\') ? win32.basename(unquoted) : posix.basename(unquoted); + const normalized = pathBase.toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/i, ''); + return normalized || undefined; + } + private async _doQuery(languageId: TreeSitterCommandParserLanguage, commandLine: string, querySource: string): Promise<{ tree: Tree; query: Query }> { const language = await this._treeSitterLibraryService.getLanguagePromise(languageId); if (!language) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index b221aeb42ac09..ea37a983d3b20 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -564,6 +564,12 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary = new Map([ + ['git', TerminalSandboxReadAllowListOperation.Git], + ['gh', TerminalSandboxReadAllowListOperation.Git], + ['node', TerminalSandboxReadAllowListOperation.Node], + ['npm', TerminalSandboxReadAllowListOperation.Node], + ['npx', TerminalSandboxReadAllowListOperation.Node], + ['pnpm', TerminalSandboxReadAllowListOperation.Node], + ['yarn', TerminalSandboxReadAllowListOperation.Node], + ['corepack', TerminalSandboxReadAllowListOperation.Node], + ['bun', TerminalSandboxReadAllowListOperation.Node], + ['deno', TerminalSandboxReadAllowListOperation.Node], + ['nvm', TerminalSandboxReadAllowListOperation.Node], + ['volta', TerminalSandboxReadAllowListOperation.Node], + ['fnm', TerminalSandboxReadAllowListOperation.Node], + ['asdf', TerminalSandboxReadAllowListOperation.Node], + ['mise', TerminalSandboxReadAllowListOperation.Node], + ['cargo', TerminalSandboxReadAllowListOperation.Rust], + ['rustc', TerminalSandboxReadAllowListOperation.Rust], + ['rustup', TerminalSandboxReadAllowListOperation.Rust], + ['go', TerminalSandboxReadAllowListOperation.Go], + ['gofmt', TerminalSandboxReadAllowListOperation.Go], + ['python', TerminalSandboxReadAllowListOperation.Python], + ['python3', TerminalSandboxReadAllowListOperation.Python], + ['pip', TerminalSandboxReadAllowListOperation.Python], + ['pip3', TerminalSandboxReadAllowListOperation.Python], + ['poetry', TerminalSandboxReadAllowListOperation.Python], + ['uv', TerminalSandboxReadAllowListOperation.Python], + ['pipx', TerminalSandboxReadAllowListOperation.Python], + ['pyenv', TerminalSandboxReadAllowListOperation.Python], + ['java', TerminalSandboxReadAllowListOperation.Java], + ['javac', TerminalSandboxReadAllowListOperation.Java], + ['jar', TerminalSandboxReadAllowListOperation.Java], + ['mvn', TerminalSandboxReadAllowListOperation.Java], + ['mvnw', TerminalSandboxReadAllowListOperation.Java], + ['gradle', TerminalSandboxReadAllowListOperation.Java], + ['gradlew', TerminalSandboxReadAllowListOperation.Java], + ['sdk', TerminalSandboxReadAllowListOperation.Java], + ['dotnet', TerminalSandboxReadAllowListOperation.Dotnet], + ['nuget', TerminalSandboxReadAllowListOperation.Nuget], + ['msbuild', TerminalSandboxReadAllowListOperation.Msbuild], + ['ruby', TerminalSandboxReadAllowListOperation.Ruby], + ['gem', TerminalSandboxReadAllowListOperation.Ruby], + ['bundle', TerminalSandboxReadAllowListOperation.Ruby], + ['bundler', TerminalSandboxReadAllowListOperation.Ruby], + ['rake', TerminalSandboxReadAllowListOperation.Ruby], + ['rbenv', TerminalSandboxReadAllowListOperation.Ruby], + ['rvm', TerminalSandboxReadAllowListOperation.Ruby], + ['ccache', TerminalSandboxReadAllowListOperation.NativeBuild], + ['sccache', TerminalSandboxReadAllowListOperation.NativeBuild], + ['cmake', TerminalSandboxReadAllowListOperation.NativeBuild], + ['conan', TerminalSandboxReadAllowListOperation.Conan], +]); + +/** + * Paths that common developer tools typically need to read when the user's home + * directory is broadly denied. This list intentionally avoids obvious credential + * and key material such as ~/.ssh, ~/.gnupg, cloud credentials, package manager + * auth files, and git credential stores. + */ + +function getTerminalSandboxReadAllowListForOperation(operation: TerminalSandboxReadAllowListOperation, os: OperatingSystem): readonly string[] { + switch (operation) { + case TerminalSandboxReadAllowListOperation.Git: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.gitconfig', + '~/.config/git/config', + '~/.gitignore', + '~/.gitignore_global', + '~/.config/git/ignore', + '~/.config/git/attributes', + ]; + } + + case TerminalSandboxReadAllowListOperation.Node: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/.npm', + '~/Library/Caches/node', + '~/Library/Caches/electron', + '~/Library/Caches/ms-playwright', + '~/Library/Caches/Yarn', + '~/Library/Caches/deno', + '~/Library/pnpm', + '~/.electron-gyp', + '~/.node-gyp', + '~/.yarn/berry', + '~/.local/share/pnpm', + '~/.pnpm-store', + '~/.bun/install/cache', + '~/.bun/bin', + '~/.deno', + '~/.nvm/versions', + '~/.nvm/alias', + '~/.volta/bin', + '~/.volta/tools', + '~/.fnm', + '~/.asdf/installs/nodejs', + '~/.asdf/shims', + '~/.local/share/mise/installs/node', + '~/.local/share/mise/shims', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.npm', + '~/.cache/node', + '~/.cache/node/corepack', + '~/.cache/electron', + '~/.cache/ms-playwright', + '~/.cache/yarn', + '~/.electron-gyp', + '~/.node-gyp', + '~/.yarn/berry', + '~/.local/share/pnpm', + '~/.pnpm-store', + '~/.bun/install/cache', + '~/.bun/bin', + '~/.deno', + '~/.cache/deno', + '~/.nvm/versions', + '~/.nvm/alias', + '~/.volta/bin', + '~/.volta/tools', + '~/.fnm', + '~/.asdf/installs/nodejs', + '~/.asdf/shims', + '~/.local/share/mise/installs/node', + '~/.local/share/mise/shims', + ]; + } + + case TerminalSandboxReadAllowListOperation.Rust: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.cargo/bin', + '~/.cargo/registry', + '~/.cargo/git', + '~/.rustup/toolchains', + ]; + } + + case TerminalSandboxReadAllowListOperation.Go: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/go/pkg/mod', + '~/go/bin', + '~/Library/Caches/go-build', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/go/pkg/mod', + '~/go/bin', + '~/.cache/go-build', + ]; + } + + case TerminalSandboxReadAllowListOperation.Python: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/Library/Caches/pip', + '~/Library/Caches/pypoetry', + '~/Library/Caches/uv', + '~/.local/bin', + '~/.local/share/virtualenv', + '~/.local/share/pipx', + '~/.pyenv/versions', + '~/.pyenv/shims', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.cache/pip', + '~/.cache/pypoetry', + '~/.cache/uv', + '~/.local/bin', + '~/.local/share/virtualenv', + '~/.local/share/pipx', + '~/.pyenv/versions', + '~/.pyenv/shims', + ]; + } + + case TerminalSandboxReadAllowListOperation.Java: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.m2/repository', + '~/.gradle/caches', + '~/.gradle/wrapper/dists', + '~/.sdkman/candidates', + ]; + } + + case TerminalSandboxReadAllowListOperation.Dotnet: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.dotnet', + ]; + } + + case TerminalSandboxReadAllowListOperation.Nuget: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/.nuget/packages', + '~/Library/Caches/NuGet/v3-cache', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.nuget/packages', + '~/.local/share/NuGet/v3-cache', + ]; + } + + case TerminalSandboxReadAllowListOperation.Msbuild: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return []; + } + + case TerminalSandboxReadAllowListOperation.Ruby: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/.gem', + '~/.rbenv/versions', + '~/.rbenv/shims', + '~/.rvm/rubies', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.gem', + '~/.rbenv/versions', + '~/.rbenv/shims', + '~/.rvm/rubies', + ]; + } + + case TerminalSandboxReadAllowListOperation.NativeBuild: + switch (os) { + case OperatingSystem.Macintosh: + return [ + '~/Library/Caches/ccache', + '~/Library/Caches/sccache', + ]; + case OperatingSystem.Linux: + default: + return [ + '~/.cache/ccache', + '~/.cache/sccache', + ]; + } + + case TerminalSandboxReadAllowListOperation.Conan: + switch (os) { + case OperatingSystem.Macintosh: + case OperatingSystem.Linux: + default: + return [ + '~/.conan2/p', + '~/.conan2/b', + ]; + } + } +} + +export function getTerminalSandboxReadAllowListForCommands(os: OperatingSystem, commandKeywords: readonly string[]): readonly string[] { + if (commandKeywords.length === 0) { + return []; + } + + const operations = new Set(); + for (const keyword of commandKeywords) { + const operation = terminalSandboxReadAllowListKeywordMap.get(keyword.toLowerCase()); + if (operation) { + operations.add(operation); + } + } + + if (operations.size === 0) { + return []; + } + + const paths = [...operations].flatMap(operation => getTerminalSandboxReadAllowListForOperation(operation, os)); + return [...new Set(paths)]; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index cd02e4f717017..c5a66932ab029 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -35,6 +35,7 @@ import { ElicitationState, IChatService } from '../../../chat/common/chatService import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannelClient } from '../../../../../platform/sandbox/common/sandboxHelperIpc.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ISandboxDependencyInstallOptions, type ISandboxDependencyInstallResult, type ITerminalSandboxPrerequisiteCheckResult, type ITerminalSandboxResolvedNetworkDomains, type ITerminalSandboxWrapResult } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; +import { getTerminalSandboxReadAllowListForCommands } from './terminalSandboxReadAllowList.js'; export { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; export type { ISandboxDependencyInstallOptions, ISandboxDependencyInstallResult, ISandboxDependencyInstallTerminal, ITerminalSandboxPrerequisiteCheckResult, ITerminalSandboxResolvedNetworkDomains, ITerminalSandboxWrapResult } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; @@ -49,6 +50,13 @@ interface ISandboxDependencyInstallTerminalContext { didSendInstallCommand(): boolean; } +interface ITerminalSandboxFileSystemSetting { + denyRead?: string[]; + allowRead?: string[]; + allowWrite?: string[]; + denyWrite?: string[]; +} + export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { readonly _serviceBrand: undefined; private _srtPath: string | undefined; @@ -63,6 +71,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _remoteEnvDetailsPromise: Promise; private _remoteEnvDetails: IRemoteAgentEnvironment | null = null; private _appRoot: string; + private _commandReadAllowKeywords: readonly string[] = []; + private _commandCwd: URI | undefined; private _os: OperatingSystem = OS; private _defaultWritePaths: string[] = ['~/.npm']; private static readonly _sandboxTempDirName = 'tmp'; @@ -137,7 +147,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._os; } - public wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult { + public async wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, commandKeywords?: readonly string[], cwd?: URI): Promise { + const normalizedCommandKeywords = this._normalizeCommandKeywords(commandKeywords ?? []); + const shouldRefreshConfig = this._commandReadAllowKeywords.length === 0 || this._needsForceUpdateConfigFile || !this._areCommandKeywordsEqual(this._commandReadAllowKeywords, normalizedCommandKeywords) || this._commandCwd?.toString() !== cwd?.toString(); + if (shouldRefreshConfig) { + this._commandReadAllowKeywords = normalizedCommandKeywords; + this._commandCwd = cwd; + await this.getSandboxConfigPath(true); + } + if (!this._sandboxConfigPath || !this._tempDir) { throw new Error('Sandbox config path or temp dir not initialized'); } @@ -173,7 +191,11 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Use ELECTRON_RUN_AS_NODE=1 to make Electron executable behave as Node.js // TMPDIR must be set as environment variable before the command // Quote shell arguments so the wrapped command cannot break out of the outer shell. - const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; + const commandToRunInSandbox = this._getSandboxCommandWithPreservedCwd(command, cwd); + const sandboxRuntimeCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(commandToRunInSandbox)}`; + const wrappedCommand = this._os === OperatingSystem.Linux && cwd?.path && cwd.path !== this._tempDir.path + ? `cd ${this._quoteShellArgument(this._tempDir.path)}; ${sandboxRuntimeCommand}` + : sandboxRuntimeCommand; if (this._remoteEnvDetails) { return { command: wrappedCommand, @@ -390,6 +412,13 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return `'${value.replace(/'/g, `'\\''`)}'`; } + private _getSandboxCommandWithPreservedCwd(command: string, cwd: URI | undefined): string { + if (this._os !== OperatingSystem.Linux || !cwd?.path || cwd.path === this._tempDir?.path) { + return command; + } + return `cd ${this._quoteShellArgument(cwd.path)} && ${command}`; + } + private _wrapUnsandboxedCommand(command: string, shell?: string): string { if (!this._tempDir?.path) { return command; @@ -465,6 +494,14 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } + private _normalizeCommandKeywords(commandKeywords: readonly string[]): string[] { + return [...new Set(commandKeywords.map(keyword => keyword.toLowerCase()))].sort(); + } + + private _areCommandKeywordsEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((keyword, index) => keyword === b[index]); + } + private async _isSandboxConfiguredEnabled(): Promise { const os = await this.getOS(); if (os === OperatingSystem.Windows) { @@ -496,25 +533,38 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const allowedDomainsSetting = this._getSettingValue(AgentNetworkDomainSettingId.AllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains) ?? []; const deniedDomainsSetting = this._getSettingValue(AgentNetworkDomainSettingId.DeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains) ?? []; const linuxFileSystemSetting = this._os === OperatingSystem.Linux - ? this._getSettingValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxLinuxFileSystem) ?? {} + ? this._getSettingValue(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxLinuxFileSystem) ?? {} : {}; const macFileSystemSetting = this._os === OperatingSystem.Macintosh - ? this._getSettingValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxMacFileSystem) ?? {} + ? this._getSettingValue(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxMacFileSystem) ?? {} : {}; const runtimeSetting = this._getSettingValue>(TerminalChatAgentToolsSettingId.AgentSandboxAdvancedRuntime) ?? {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); - const linuxAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite); - const macAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); - + let allowWritePaths: string[] = []; + let allowReadPaths: string[] = []; + let denyReadPaths: string[] = []; + let denyWritePaths: string[] | undefined; + if (this._os === OperatingSystem.Macintosh) { + allowWritePaths = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); + allowReadPaths = this._updateAllowReadPathsWithAllowWrite(macFileSystemSetting.allowRead, allowWritePaths); + denyReadPaths = this._updateDenyReadPathsWithHome(macFileSystemSetting.denyRead); + denyWritePaths = macFileSystemSetting.denyWrite; + } else if (this._os === OperatingSystem.Linux) { + allowWritePaths = this._resolveLinuxFileSystemPaths(this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite)); + allowReadPaths = this._resolveLinuxFileSystemPaths(this._updateAllowReadPathsWithAllowWrite(linuxFileSystemSetting.allowRead, allowWritePaths)); + denyReadPaths = this._resolveLinuxFileSystemPaths(this._updateDenyReadPathsWithHome(linuxFileSystemSetting.denyRead)); + denyWritePaths = this._resolveLinuxFileSystemPaths(linuxFileSystemSetting.denyWrite); + } const sandboxSettings = { network: { allowedDomains: allowedDomainsSetting, deniedDomains: deniedDomainsSetting }, filesystem: { - denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, - allowWrite: this._os === OperatingSystem.Macintosh ? macAllowWrite : linuxAllowWrite, - denyWrite: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyWrite : linuxFileSystemSetting.denyWrite, + denyRead: denyReadPaths, + allowRead: allowReadPaths, + allowWrite: allowWritePaths, + denyWrite: denyWritePaths, }, }; this._mergeAdditionalSandboxConfigProperties(sandboxSettings as Record, runtimeSetting); @@ -611,6 +661,54 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return [...new Set([...workspaceFolderPaths, ...this._defaultWritePaths, ...(configuredAllowWrite ?? [])])]; } + private _updateDenyReadPathsWithHome(configuredDenyRead: string[] | undefined): string[] { + const userHome = this._getUserHomePath(); + return [...new Set([...(configuredDenyRead ?? []), ...(userHome ? [userHome] : [])])]; + } + + private _updateAllowReadPathsWithAllowWrite(configuredAllowRead: string[] | undefined, allowWrite: string[]): string[] { + return [...new Set([...(configuredAllowRead ?? []), ...getTerminalSandboxReadAllowListForCommands(this._os, this._commandReadAllowKeywords), ...this._getSandboxRuntimeReadPaths(), ...allowWrite])]; + } + + private _resolveLinuxFileSystemPaths(paths: string[] | undefined): string[] { + return (paths ?? []).map(path => this._expandHomePath(path)); + } + + private _expandHomePath(path: string): string { + const userHome = this._getUserHomePath(); + if (!userHome) { + return path; + } + if (path === '~') { + return userHome; + } + if (path.startsWith('~/')) { + return this._pathJoin(userHome, path.slice(2)); + } + return path; + } + + private _getSandboxRuntimeReadPaths(): string[] { + const paths: string[] = [this._appRoot]; + if (this._execPath) { + for (const path of [this._execPath, dirname(this._execPath)]) { + if (!this._isPathUnderAppRoot(path)) { + paths.push(path); + } + } + } + return paths; + } + + private _isPathUnderAppRoot(path: string): boolean { + return path === this._appRoot || path.startsWith(`${this._appRoot}${this._os === OperatingSystem.Windows ? win32.sep : posix.sep}`); + } + + private _getUserHomePath(): string | undefined { + const nativeEnv = this._environmentService as IEnvironmentService & { userHome?: URI }; + return this._remoteEnvDetails?.userHome?.path ?? nativeEnv.userHome?.path; + } + private async _resolveSandboxDependencyStatus(forceRefresh = false): Promise { if (!forceRefresh && this._sandboxDependencyStatus) { return this._sandboxDependencyStatus; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts index ab879894fcbf5..05dfc5a1d93dc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts @@ -20,7 +20,7 @@ suite('SandboxedCommandLinePresenter', () => { instantiationService.stub(ITerminalSandboxService, { _serviceBrand: undefined, isEnabled: async () => enabled, - wrapCommand: command => ({ + wrapCommand: async command => ({ command, isSandboxWrapped: false, }), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index c46bea5e06b2b..9462712df7e3c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -39,13 +39,16 @@ suite('TerminalSandboxService - network domains', () => { let workspaceContextService: MockWorkspaceContextService; let productService: IProductService; let sandboxHelperService: MockSandboxHelperService; + let remoteAgentService: MockRemoteAgentService; let createdFiles: Map; + let createFileCount: number; let createdFolders: string[]; let deletedFolders: string[]; const windowId = 7; class MockFileService { async createFile(uri: URI, content: VSBuffer): Promise { + createFileCount++; const contentString = content.toString(); createdFiles.set(uri.path, contentString); return {}; @@ -62,37 +65,39 @@ suite('TerminalSandboxService - network domains', () => { } class MockRemoteAgentService { + remoteEnvironment: IRemoteAgentEnvironment | null = { + os: OperatingSystem.Linux, + tmpDir: URI.file('/tmp'), + appRoot: URI.file('/app'), + execPath: '/app/node', + pid: 1234, + connectionToken: 'test-token', + settingsPath: URI.file('/settings'), + mcpResource: URI.file('/mcp'), + logsPath: URI.file('/logs'), + extensionHostLogsPath: URI.file('/ext-logs'), + globalStorageHome: URI.file('/global'), + workspaceStorageHome: URI.file('/workspace'), + localHistoryHome: URI.file('/history'), + userHome: URI.file('/home/user'), + arch: 'x64', + marks: [], + useHostProxy: false, + profiles: { + all: [], + home: URI.file('/profiles') + }, + isUnsupportedGlibc: false + }; + getConnection() { return null; } - async getEnvironment(): Promise { + async getEnvironment(): Promise { // Return a Linux environment to ensure tests pass on Windows // (sandbox is not supported on Windows) - return { - os: OperatingSystem.Linux, - tmpDir: URI.file('/tmp'), - appRoot: URI.file('/app'), - execPath: '/app/node', - pid: 1234, - connectionToken: 'test-token', - settingsPath: URI.file('/settings'), - mcpResource: URI.file('/mcp'), - logsPath: URI.file('/logs'), - extensionHostLogsPath: URI.file('/ext-logs'), - globalStorageHome: URI.file('/global'), - workspaceStorageHome: URI.file('/workspace'), - localHistoryHome: URI.file('/history'), - userHome: URI.file('/home/user'), - arch: 'x64', - marks: [], - useHostProxy: false, - profiles: { - all: [], - home: URI.file('/profiles') - }, - isUnsupportedGlibc: false - }; + return this.remoteEnvironment; } } @@ -160,6 +165,7 @@ suite('TerminalSandboxService - network domains', () => { setup(() => { createdFiles = new Map(); + createFileCount = 0; createdFolders = []; deletedFolders = []; instantiationService = workbenchInstantiationService({}, store); @@ -168,6 +174,7 @@ suite('TerminalSandboxService - network domains', () => { lifecycleService = store.add(new TestLifecycleService()); workspaceContextService = new MockWorkspaceContextService(); sandboxHelperService = new MockSandboxHelperService(); + remoteAgentService = new MockRemoteAgentService(); productService = { ...TestProductService, dataFolderName: '.test-data', @@ -182,15 +189,17 @@ suite('TerminalSandboxService - network domains', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); - instantiationService.stub(IEnvironmentService, { + instantiationService.stub(IEnvironmentService, { _serviceBrand: undefined, tmpDir: URI.file('/tmp'), execPath: '/usr/bin/node', + userHome: URI.file('/home/local-user'), + userDataPath: '/custom/local-user-data', window: { id: windowId } }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IProductService, productService); - instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); + instantiationService.stub(IRemoteAgentService, remoteAgentService); instantiationService.stub(IWorkspaceContextService, workspaceContextService); instantiationService.stub(ILifecycleService, lifecycleService); instantiationService.stub(ISandboxHelperService, sandboxHelperService); @@ -336,6 +345,7 @@ suite('TerminalSandboxService - network domains', () => { configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { allowWrite: ['/configured/path'], denyRead: [], + allowRead: ['/configured/readable/path'], denyWrite: [] }); configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxAdvancedRuntime, { @@ -345,6 +355,7 @@ suite('TerminalSandboxService - network domains', () => { }, filesystem: { allowWrite: ['/should-not-win'], + allowRead: ['/should-not-win-readable'], unixSockets: { enabled: true, } @@ -367,12 +378,175 @@ suite('TerminalSandboxService - network domains', () => { }); ok(config.filesystem.allowWrite.includes('/configured/path'), 'Configured filesystem values should be preserved'); ok(!config.filesystem.allowWrite.includes('/should-not-win'), 'Runtime filesystem values should not override schema-defined filesystem config'); + ok(config.filesystem.allowRead.includes('/configured/readable/path'), 'Configured allowRead values should be preserved'); + ok(config.filesystem.allowRead.includes('/workspace-one'), 'Generated allowRead should include workspace folders'); + ok(config.filesystem.allowRead.includes('/configured/path'), 'Generated allowRead should include configured allowWrite paths'); + ok(!config.filesystem.allowRead.includes('/should-not-win-readable'), 'Runtime filesystem allowRead should not override schema-defined filesystem config'); deepStrictEqual(config.filesystem.unixSockets, { enabled: true, }, 'Additional nested runtime filesystem properties should be merged in'); strictEqual(config.allowUnixSockets, true, 'Non-conflicting runtime properties should still be added'); }); + test('should deny home reads while reallowing writable paths for reads', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { + allowWrite: ['/configured/path'], + denyRead: ['/secret/path'], + allowRead: ['/configured/readable/path'], + denyWrite: [] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.denyRead.includes('/home/user'), 'Sandbox config should deny arbitrary reads from the user home'); + ok(config.filesystem.denyRead.includes('/secret/path'), 'Sandbox config should preserve configured denyRead paths'); + ok(config.filesystem.allowRead.includes('/workspace-one'), 'Sandbox config should re-allow reads from workspace folders'); + ok(config.filesystem.allowRead.includes('/configured/path'), 'Sandbox config should re-allow reads from configured allowWrite paths'); + ok(config.filesystem.allowRead.includes('/configured/readable/path'), 'Sandbox config should preserve configured allowRead paths'); + ok(config.filesystem.allowRead.includes('/home/user/.npm'), 'Sandbox config should re-allow reads from default write paths'); + ok(!config.filesystem.allowRead.includes('/home/user/.gitconfig'), 'Sandbox config should not include command-specific git read allow-list paths before a command is parsed'); + ok(!config.filesystem.allowRead.includes('/home/user/.nvm/versions'), 'Sandbox config should not include command-specific node read allow-list paths before a command is parsed'); + ok(!config.filesystem.allowRead.includes('/home/user/.cache/pip'), 'Sandbox config should not include command-specific common dev read allow-list paths before a command is parsed'); + ok(config.filesystem.allowRead.includes('/app'), 'Sandbox config should include the VS Code app root'); + ok(!config.filesystem.allowRead.includes('/app/node'), 'Sandbox config should not redundantly include app root child paths'); + ok(!config.filesystem.allowRead.includes('/app/node_modules'), 'Sandbox config should not redundantly include app root child paths'); + ok(!config.filesystem.allowRead.includes('/app/node_modules/@vscode/ripgrep'), 'Sandbox config should not redundantly include app root child paths'); + }); + + test('should only add command-specific allowRead paths for the current command keywords', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + await sandboxService.wrapCommand('node --version', false, 'bash', ['node']); + const nodeConfigContent = createdFiles.get(configPath); + ok(nodeConfigContent, 'Config file should be rewritten for node commands'); + + const nodeConfig = JSON.parse(nodeConfigContent); + ok(nodeConfig.filesystem.allowRead.includes('/home/user/.nvm/versions'), 'Node commands should include node-specific read allow-list paths'); + ok(!nodeConfig.filesystem.allowRead.includes('/home/user/.gitconfig'), 'Node commands should not include git-specific read allow-list paths'); + + await sandboxService.wrapCommand('git status', false, 'bash', ['git']); + const gitConfigContent = createdFiles.get(configPath); + ok(gitConfigContent, 'Config file should be rewritten for git commands'); + + const gitConfig = JSON.parse(gitConfigContent); + ok(gitConfig.filesystem.allowRead.includes('/home/user/.gitconfig'), 'Git commands should include git-specific read allow-list paths'); + ok(!gitConfig.filesystem.allowRead.includes('/home/user/.nvm/versions'), 'Refreshing for a new command should start allowRead from the current command keywords'); + }); + + test('should not rewrite sandbox config when the parsed command keywords are unchanged', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const initialCreateFileCount = createFileCount; + + await sandboxService.wrapCommand('node --version', false, 'bash', ['node']); + const afterFirstNodeCommandCount = createFileCount; + strictEqual(afterFirstNodeCommandCount, initialCreateFileCount + 1, 'First node command should rewrite the config once'); + + await sandboxService.wrapCommand('node app.js', false, 'bash', ['node']); + strictEqual(createFileCount, afterFirstNodeCommandCount, 'Second node command should not rewrite the config when keywords are unchanged'); + }); + + test('should expand home paths in linux filesystem sandbox config paths', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { + allowWrite: ['~/.custom-write', '/glob/**/*.ts'], + denyRead: ['~/.secret', '/secret/*'], + allowRead: ['~/.custom-readable', '/readable/{a,b}'], + denyWrite: ['~/.custom-write/file.txt', '/configured/path/file?.txt'] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.allowWrite.includes('/home/user/.custom-write'), 'allowWrite should expand home paths on Linux'); + ok(config.filesystem.allowWrite.includes('/glob/**/*.ts'), 'Non-home allowWrite paths should be preserved'); + ok(!config.filesystem.allowWrite.includes('~/.custom-write'), 'allowWrite should not include unexpanded home paths on Linux'); + ok(config.filesystem.denyRead.includes('/home/user/.secret'), 'denyRead should expand home paths on Linux'); + ok(config.filesystem.denyRead.includes('/secret/*'), 'Non-home denyRead paths should be preserved'); + ok(!config.filesystem.denyRead.includes('~/.secret'), 'denyRead should not include unexpanded home paths on Linux'); + ok(config.filesystem.allowRead.includes('/home/user/.custom-readable'), 'allowRead should expand home paths on Linux'); + ok(config.filesystem.allowRead.includes('/readable/{a,b}'), 'Non-home allowRead paths should be preserved'); + ok(!config.filesystem.allowRead.includes('~/.custom-readable'), 'allowRead should not include unexpanded home paths on Linux'); + ok(config.filesystem.denyWrite.includes('/home/user/.custom-write/file.txt'), 'denyWrite should expand home paths on Linux'); + ok(config.filesystem.denyWrite.includes('/configured/path/file?.txt'), 'Non-home denyWrite paths should be preserved'); + ok(!config.filesystem.denyWrite.includes('~/.custom-write/file.txt'), 'denyWrite should not include unexpanded home paths on Linux'); + }); + + test('should deny home reads while reallowing writable paths for reads on macOS', async () => { + remoteAgentService.remoteEnvironment = { + ...remoteAgentService.remoteEnvironment!, + os: OperatingSystem.Macintosh + }; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, { + allowWrite: ['/configured/path'], + denyRead: ['/secret/path'], + allowRead: ['/configured/readable/path'], + denyWrite: [] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.denyRead.includes('/home/user'), 'Sandbox config should deny arbitrary reads from the user home on macOS'); + ok(config.filesystem.denyRead.includes('/secret/path'), 'Sandbox config should preserve configured denyRead paths on macOS'); + ok(config.filesystem.allowRead.includes('/workspace-one'), 'Sandbox config should re-allow reads from workspace folders on macOS'); + ok(config.filesystem.allowRead.includes('/configured/path'), 'Sandbox config should re-allow reads from configured allowWrite paths on macOS'); + ok(config.filesystem.allowRead.includes('/configured/readable/path'), 'Sandbox config should preserve configured allowRead paths on macOS'); + }); + + test('should not expand home paths in macOS filesystem sandbox config paths', async () => { + remoteAgentService.remoteEnvironment = { + ...remoteAgentService.remoteEnvironment!, + os: OperatingSystem.Macintosh + }; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxMacFileSystem, { + allowWrite: ['~/.custom-write', '/glob/**/*.ts'], + denyRead: ['~/.secret', '/secret/*'], + allowRead: ['~/.custom-readable', '/readable/{a,b}'], + denyWrite: ['~/.custom-write/file.txt', '/configured/path/file?.txt'] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + ok(config.filesystem.allowWrite.includes('~/.custom-write'), 'allowWrite should preserve unexpanded home paths on macOS'); + ok(config.filesystem.allowWrite.includes('/glob/**/*.ts'), 'Non-home allowWrite paths should be preserved on macOS'); + ok(!config.filesystem.allowWrite.includes('/home/user/.custom-write'), 'allowWrite should not expand ~ on macOS'); + ok(config.filesystem.denyRead.includes('~/.secret'), 'denyRead should preserve unexpanded home paths on macOS'); + ok(config.filesystem.denyRead.includes('/secret/*'), 'Non-home denyRead paths should be preserved on macOS'); + ok(!config.filesystem.denyRead.includes('/home/user/.secret'), 'denyRead should not expand ~ on macOS'); + ok(config.filesystem.allowRead.includes('~/.custom-readable'), 'allowRead should preserve unexpanded home paths on macOS'); + ok(config.filesystem.allowRead.includes('/readable/{a,b}'), 'Non-home allowRead paths should be preserved on macOS'); + ok(!config.filesystem.allowRead.includes('/home/user/.custom-readable'), 'allowRead should not expand ~ on macOS'); + ok(config.filesystem.denyWrite.includes('~/.custom-write/file.txt'), 'denyWrite should preserve unexpanded home paths on macOS'); + ok(config.filesystem.denyWrite.includes('/configured/path/file?.txt'), 'Non-home denyWrite paths should be preserved on macOS'); + ok(!config.filesystem.denyWrite.includes('/home/user/.custom-write/file.txt'), 'denyWrite should not expand ~ on macOS'); + }); + test('should refresh allowWrite paths when workspace folders change', async () => { configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, { allowWrite: ['/configured/path'], @@ -390,6 +564,9 @@ suite('TerminalSandboxService - network domains', () => { const initialConfig = JSON.parse(initialConfigContent); ok(initialConfig.filesystem.allowWrite.includes('/workspace-one'), 'Initial config should include the original workspace folder'); ok(initialConfig.filesystem.allowWrite.includes('/configured/path'), 'Initial config should include configured allowWrite paths'); + ok(initialConfig.filesystem.denyRead.includes('/home/user'), 'Initial config should deny arbitrary reads from home'); + ok(initialConfig.filesystem.allowRead.includes('/workspace-one'), 'Initial config should re-allow reading the original workspace folder'); + ok(initialConfig.filesystem.allowRead.includes('/configured/path'), 'Initial config should re-allow reading configured allowWrite paths'); workspaceContextService.setWorkspaceFolders([URI.file('/workspace-two')]); @@ -403,6 +580,10 @@ suite('TerminalSandboxService - network domains', () => { ok(refreshedConfig.filesystem.allowWrite.includes('/workspace-two'), 'Refreshed config should include the updated workspace folder'); ok(!refreshedConfig.filesystem.allowWrite.includes('/workspace-one'), 'Refreshed config should remove the old workspace folder'); ok(refreshedConfig.filesystem.allowWrite.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths'); + ok(refreshedConfig.filesystem.denyRead.includes('/home/user'), 'Refreshed config should continue to deny arbitrary reads from home'); + ok(refreshedConfig.filesystem.allowRead.includes('/workspace-two'), 'Refreshed config should re-allow reading the updated workspace folder'); + ok(!refreshedConfig.filesystem.allowRead.includes('/workspace-one'), 'Refreshed config should remove the old workspace folder from allowRead'); + ok(refreshedConfig.filesystem.allowRead.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths in allowRead'); }); test('should create sandbox temp dir under the server data folder', async () => { @@ -431,7 +612,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrappedCommand = sandboxService.wrapCommand('echo test'); + const wrappedCommand = await sandboxService.wrapCommand('echo test'); ok( wrappedCommand.command.includes('PATH') && wrappedCommand.command.includes('ripgrep'), @@ -440,39 +621,51 @@ suite('TerminalSandboxService - network domains', () => { strictEqual(wrappedCommand.isSandboxWrapped, true, 'Command should stay sandbox wrapped when no domain is detected'); }); + test('should launch Linux sandbox runtime from temp dir while preserving the command cwd', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = await sandboxService.wrapCommand('head -1 /etc/shells', false, 'bash', undefined, URI.file('/workspace-one')); + const expectedWrappedCwd = String.raw`-c 'cd '\''/workspace-one'\'' && head -1 /etc/shells'`; + + ok(wrapResult.command.startsWith(`cd '${sandboxService.getTempDir()?.path}'; `), 'Sandbox runtime should start from the sandbox temp dir on Linux'); + ok(wrapResult.command.includes(expectedWrappedCwd), `Sandboxed command should restore the original cwd before running the user command. Actual: ${wrapResult.command}`); + strictEqual(wrapResult.isSandboxWrapped, true, 'Command should remain sandbox wrapped'); + }); + test('should preserve TMPDIR when unsandboxed execution is requested', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test'`); + strictEqual((await sandboxService.wrapCommand('echo test', true, 'bash')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test'`); }); test('should preserve TMPDIR for piped unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test | cat', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test | cat'`); + strictEqual((await sandboxService.wrapCommand('echo test | cat', true, 'bash')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test | cat'`); }); test('should preserve trailing backslashes for unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test \\', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test \\'`); + strictEqual((await sandboxService.wrapCommand('echo test \\', true, 'bash')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test \\'`); }); test('should use fish-compatible wrapping for unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true, 'fish').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'fish' -c 'echo test'`); + strictEqual((await sandboxService.wrapCommand('echo test', true, 'fish')).command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'fish' -c 'echo test'`); }); test('should switch to unsandboxed execution when a domain is not allowlisted', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.com', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Blocked domains should prevent sandbox wrapping'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains should require unsandbox confirmation'); @@ -485,7 +678,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.com'); strictEqual(wrapResult.isSandboxWrapped, true, 'Exact allowlisted domains should stay sandboxed'); strictEqual(wrapResult.blockedDomains, undefined, 'Allowed domains should not be reported as blocked'); @@ -496,7 +689,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl "https://api.github.com/repos/microsoft/vscode"'); + const wrapResult = await sandboxService.wrapCommand('curl "https://api.github.com/repos/microsoft/vscode"'); strictEqual(wrapResult.isSandboxWrapped, true, 'Wildcard allowlisted domains should stay sandboxed'); strictEqual(wrapResult.blockedDomains, undefined, 'Wildcard allowlisted domains should not be reported as blocked'); @@ -508,7 +701,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://api.github.com/repos/microsoft/vscode'); + const wrapResult = await sandboxService.wrapCommand('curl https://api.github.com/repos/microsoft/vscode'); strictEqual(wrapResult.isSandboxWrapped, false, 'Denied domains should not stay sandboxed'); deepStrictEqual(wrapResult.blockedDomains, ['api.github.com']); @@ -520,7 +713,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://API.GITHUB.COM/repos/microsoft/vscode'); + const wrapResult = await sandboxService.wrapCommand('curl https://API.GITHUB.COM/repos/microsoft/vscode'); strictEqual(wrapResult.isSandboxWrapped, true, 'Uppercase hostnames should still match allowlisted domains'); strictEqual(wrapResult.blockedDomains, undefined, 'Uppercase allowlisted domains should not be reported as blocked'); @@ -530,7 +723,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com]/path'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.com]/path'); strictEqual(wrapResult.isSandboxWrapped, true, 'Malformed URL authorities should not trigger blocked-domain prompts'); strictEqual(wrapResult.blockedDomains, undefined, 'Malformed URL authorities should be ignored'); @@ -540,11 +733,11 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const javascriptResult = sandboxService.wrapCommand('cat bundle.js', false, 'bash'); + const javascriptResult = await sandboxService.wrapCommand('cat bundle.js', false, 'bash'); strictEqual(javascriptResult.isSandboxWrapped, true, 'File extensions such as .js should not trigger blocked-domain prompts'); strictEqual(javascriptResult.blockedDomains, undefined, 'File extensions such as .js should not be reported as domains'); - const jsonResult = sandboxService.wrapCommand('cat package.json', false, 'bash'); + const jsonResult = await sandboxService.wrapCommand('cat package.json', false, 'bash'); strictEqual(jsonResult.isSandboxWrapped, true, 'File extensions such as .json should not trigger blocked-domain prompts'); strictEqual(jsonResult.blockedDomains, undefined, 'File extensions such as .json should not be reported as domains'); }); @@ -560,7 +753,7 @@ suite('TerminalSandboxService - network domains', () => { ]; for (const command of commands) { - const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); + const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, true, `Command ${command} should remain sandboxed`); strictEqual(wrapResult.blockedDomains, undefined, `Command ${command} should not report a blocked domain`); } @@ -570,11 +763,11 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const testComResult = sandboxService.wrapCommand('curl test.com', false, 'bash'); + const testComResult = await sandboxService.wrapCommand('curl test.com', false, 'bash'); strictEqual(testComResult.isSandboxWrapped, false, 'Well-known bare domain suffixes should trigger domain checks'); deepStrictEqual(testComResult.blockedDomains, ['test.com']); - const testOrgComResult = sandboxService.wrapCommand('curl test.org.com', false, 'bash'); + const testOrgComResult = await sandboxService.wrapCommand('curl test.org.com', false, 'bash'); strictEqual(testOrgComResult.isSandboxWrapped, false, 'Well-known bare domain suffixes should trigger domain checks for multi-label hosts'); deepStrictEqual(testOrgComResult.blockedDomains, ['test.org.com']); }); @@ -583,7 +776,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.zip/path', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.zip/path', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'URL authorities should still trigger blocked-domain prompts even when their suffix looks like a file extension'); deepStrictEqual(wrapResult.blockedDomains, ['example.zip']); @@ -593,7 +786,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.bar/path', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('curl https://example.bar/path', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'URL authorities should not require a well-known bare-host suffix'); deepStrictEqual(wrapResult.blockedDomains, ['example.bar']); @@ -603,7 +796,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('git clone git@example.zip:owner/repo.git', false, 'bash'); + const wrapResult = await sandboxService.wrapCommand('git clone git@example.zip:owner/repo.git', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'SSH remotes should still trigger blocked-domain prompts even when their suffix looks like a file extension'); deepStrictEqual(wrapResult.blockedDomains, ['example.zip']); @@ -623,7 +816,7 @@ suite('TerminalSandboxService - network domains', () => { ]; for (const command of commands) { - const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); + const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, true, `Command ${command} should remain sandboxed`); strictEqual(wrapResult.blockedDomains, undefined, `Command ${command} should not report a blocked domain`); } @@ -707,7 +900,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('git clone git@github.com:microsoft/vscode.git'); + const wrapResult = await sandboxService.wrapCommand('git clone git@github.com:microsoft/vscode.git'); strictEqual(wrapResult.isSandboxWrapped, false, 'SSH-style remotes should trigger domain checks'); deepStrictEqual(wrapResult.blockedDomains, ['github.com']); @@ -718,7 +911,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = '";echo SANDBOX_ESCAPE_REPRO; # $(uname) `id`'; - const wrappedCommand = sandboxService.wrapCommand(command).command; + const wrappedCommand = (await sandboxService.wrapCommand(command)).command; ok( wrappedCommand.includes(`-c '";echo SANDBOX_ESCAPE_REPRO; # $(uname) \`id\`'`), @@ -735,7 +928,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = 'echo $HOME $(printf literal) `id`'; - const wrappedCommand = sandboxService.wrapCommand(command).command; + const wrappedCommand = (await sandboxService.wrapCommand(command)).command; ok( wrappedCommand.includes(`-c 'echo $HOME $(printf literal) \`id\`'`), @@ -752,7 +945,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = 'echo $HOME $(curl eth0.me) `id`'; - const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); + const wrapResult = await sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Commands with blocked domains inside substitutions should not stay sandboxed'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains inside substitutions should require confirmation'); @@ -765,7 +958,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = `';printf breakout; #'`; - const wrappedCommand = sandboxService.wrapCommand(command).command; + const wrappedCommand = (await sandboxService.wrapCommand(command)).command; ok( wrappedCommand.includes(`-c '`), @@ -786,7 +979,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrappedCommand = sandboxService.wrapCommand(`echo 'hello'`).command; + const wrappedCommand = (await sandboxService.wrapCommand(`echo 'hello'`)).command; strictEqual((wrappedCommand.match(/\\''/g) ?? []).length, 2, 'Single quote escapes should be inserted for each embedded single quote'); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts index 10cf4d3d03a4a..8515e5445095f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts @@ -5,24 +5,29 @@ import { strictEqual, deepStrictEqual } from 'assert'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { CommandLineSandboxRewriter } from '../../browser/tools/commandLineRewriter/commandLineSandboxRewriter.js'; import type { ICommandLineRewriterOptions } from '../../browser/tools/commandLineRewriter/commandLineRewriter.js'; +import type { TreeSitterCommandParser } from '../../browser/treeSitterCommandParser.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../common/terminalSandboxService.js'; suite('CommandLineSandboxRewriter', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; + const stubTreeSitterCommandParser = (keywords: string[] = []): TreeSitterCommandParser => ({ + extractCommandKeywords: async () => keywords, + } as unknown as TreeSitterCommandParser); const stubSandboxService = (overrides: Partial = {}) => { instantiationService = workbenchInstantiationService({}, store); instantiationService.stub(ITerminalSandboxService, { _serviceBrand: undefined, isEnabled: async () => false, - wrapCommand: (command, _requestUnsandboxedExecution) => { + wrapCommand: async (command, _requestUnsandboxedExecution) => { return { command, isSandboxWrapped: false, @@ -47,21 +52,21 @@ suite('CommandLineSandboxRewriter', () => { test('returns undefined when sandbox is disabled', async () => { stubSandboxService(); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); const result = await rewriter.rewrite(createRewriteOptions('echo hello')); strictEqual(result, undefined); }); test('returns undefined when sandbox config is unavailable', async () => { stubSandboxService({ - wrapCommand: command => ({ + wrapCommand: async command => ({ command: `wrapped:${command}`, isSandboxWrapped: true, }), checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }), }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); const result = await rewriter.rewrite(createRewriteOptions('echo hello')); strictEqual(result, undefined); }); @@ -76,7 +81,7 @@ suite('CommandLineSandboxRewriter', () => { }), }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser())); const result = await rewriter.rewrite(createRewriteOptions('echo hello')); strictEqual(result, undefined); }); @@ -84,8 +89,8 @@ suite('CommandLineSandboxRewriter', () => { test('wraps command when sandbox is enabled and config exists', async () => { const calls: string[] = []; stubSandboxService({ - wrapCommand: (command, _requestUnsandboxedExecution) => { - calls.push('wrapCommand'); + wrapCommand: async (command, _requestUnsandboxedExecution, _shell, commandKeywords, cwd) => { + calls.push(`wrapCommand:${commandKeywords?.join(',') ?? ''}:${cwd?.path ?? ''}`); return { command: `wrapped:${command}`, isSandboxWrapped: true, @@ -97,18 +102,22 @@ suite('CommandLineSandboxRewriter', () => { }, }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); - const result = await rewriter.rewrite(createRewriteOptions('echo hello')); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser(['node']))); + const result = await rewriter.rewrite({ + ...createRewriteOptions('echo hello'), + cwd: URI.file('/workspace') + }); strictEqual(result?.rewritten, 'wrapped:echo hello'); strictEqual(result?.reasoning, 'Wrapped command for sandbox execution'); - deepStrictEqual(calls, ['checkForSandboxingPrereqs', 'wrapCommand']); + deepStrictEqual(calls, ['checkForSandboxingPrereqs', 'wrapCommand:node:/workspace']); }); test('wraps command and forwards sandbox bypass flag when explicitly requested', async () => { const calls: string[] = []; stubSandboxService({ - wrapCommand: (command, requestUnsandboxedExecution) => { + wrapCommand: async (command, requestUnsandboxedExecution, _shell, commandKeywords) => { calls.push(`wrap:${command}:${String(requestUnsandboxedExecution)}`); + calls.push(`keywords:${commandKeywords?.join(',') ?? ''}`); return { command: `wrapped:${command}`, isSandboxWrapped: !requestUnsandboxedExecution, @@ -120,7 +129,7 @@ suite('CommandLineSandboxRewriter', () => { }, }); - const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter, stubTreeSitterCommandParser(['git']))); const result = await rewriter.rewrite({ ...createRewriteOptions('echo hello'), requestUnsandboxedExecution: true, @@ -128,6 +137,6 @@ suite('CommandLineSandboxRewriter', () => { strictEqual(result?.rewritten, 'wrapped:echo hello'); strictEqual(result?.reasoning, 'Wrapped command for sandbox execution'); - deepStrictEqual(calls, ['prereqs', 'wrap:echo hello:true']); + deepStrictEqual(calls, ['prereqs', 'wrap:echo hello:true', 'keywords:']); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index e30ef1be5965a..d8bb868e65dce 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -181,7 +181,7 @@ suite('RunInTerminalTool', () => { terminalSandboxService = { _serviceBrand: undefined, isEnabled: async () => sandboxEnabled, - wrapCommand: (command: string, requestUnsandboxedExecution?: boolean) => ({ + wrapCommand: async (command: string, requestUnsandboxedExecution?: boolean) => ({ command: requestUnsandboxedExecution ? `unsandboxed:${command}` : `sandbox:${command}`, isSandboxWrapped: !requestUnsandboxedExecution, }), @@ -399,7 +399,7 @@ suite('RunInTerminalTool', () => { sandboxConfigPath: '/tmp/vscode-sandbox-settings.json', failedCheck: undefined, }; - terminalSandboxService.wrapCommand = (command: string) => ({ + terminalSandboxService.wrapCommand = async (command: string) => ({ command: `sandbox-runtime ${command}`, isSandboxWrapped: true, }); @@ -422,7 +422,7 @@ suite('RunInTerminalTool', () => { sandboxConfigPath: '/tmp/vscode-sandbox-settings.json', failedCheck: undefined, }; - terminalSandboxService.wrapCommand = (command: string) => ({ + terminalSandboxService.wrapCommand = async (command: string) => ({ command: `sandbox-runtime ${command}`, isSandboxWrapped: true, }); @@ -812,7 +812,7 @@ suite('RunInTerminalTool', () => { failedCheck: undefined, }; runInTerminalTool.setBackendOs(OperatingSystem.Linux); - terminalSandboxService.wrapCommand = (command: string) => ({ + terminalSandboxService.wrapCommand = async (command: string) => ({ command: `unsandboxed:${command}`, isSandboxWrapped: false, requiresUnsandboxConfirmation: true, @@ -2401,7 +2401,7 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { const terminalSandboxService: ITerminalSandboxService = { _serviceBrand: undefined, isEnabled: async () => sandboxEnabled, - wrapCommand: (command: string) => ({ + wrapCommand: async (command: string) => ({ command: `sandbox:${command}`, isSandboxWrapped: true, }), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts index 1e676c46a9bc8..cb4fd487ddab4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/treeSitterCommandParser.test.ts @@ -215,6 +215,25 @@ suite('TreeSitterCommandParser', () => { }); }); + suite('extractCommandKeywords', () => { + async function t(languageId: TreeSitterCommandParserLanguage, commandLine: string, expectedKeywords: string[]) { + const result = await parser.extractCommandKeywords(languageId, commandLine); + deepStrictEqual(result, expectedKeywords); + } + + test('extracts bash command keywords from compound commands', () => t( + TreeSitterCommandParserLanguage.Bash, + 'VAR=value node --version && git status && /usr/local/bin/python3 -m pytest', + ['node', 'git', 'python3'] + )); + + test('deduplicates similar command keywords', () => t( + TreeSitterCommandParserLanguage.Bash, + 'node --version && /usr/bin/node script.js && npm ci', + ['node', 'npm'] + )); + }); + suite('extractPwshDoubleAmpersandChainOperators', () => { async function t(commandLine: string, expectedMatches: string[]) { const result = await parser.extractPwshDoubleAmpersandChainOperators(commandLine); From f6a18ce89c0f001ae774476beaf995d2fb2fb40b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 24 Apr 2026 11:14:16 -0700 Subject: [PATCH 20/36] tools: fix run task tool not finding tools consistently This path comparison sometimes failed on a trailing slash of case differences on case-insensitive systems --- .../terminalContrib/chatAgentTools/browser/taskHelpers.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts index fa39d76ec7dc5..5c2827493a09f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts @@ -104,9 +104,10 @@ export async function getTaskForTool(id: string | undefined, taskDefinition: { t } let tasksForWorkspace; - const workspaceFolderPath = URI.file(workspaceFolder).path; + const getPathForCompare = (uri: URI) => uri.path.replace(/\/$/, '').toLowerCase(); + const workspaceFolderPath = getPathForCompare(URI.file(workspaceFolder)); for (const [folder, tasks] of workspaceFolderToTaskMap) { - if (URI.parse(folder).path === workspaceFolderPath) { + if (getPathForCompare(URI.parse(folder)) === workspaceFolderPath) { tasksForWorkspace = tasks; break; } From 0e1f836c91d741a52942b8eb266f2f6fe3226afa Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:01:36 -0700 Subject: [PATCH 21/36] Make localIndex setting public (#312402) * Make localIndex setting public Co-authored-by: Copilot * fix the namespace * update desc --------- Co-authored-by: Copilot --- extensions/copilot/package.json | 9 +++++++++ extensions/copilot/package.nls.json | 5 +++-- .../chronicle/common/sessionIndexingPreference.ts | 8 -------- .../common/test/sessionIndexingPreference.spec.ts | 2 +- .../chronicle/vscode-node/remoteSessionExporter.ts | 2 +- .../chronicle/vscode-node/sessionStoreTracker.ts | 2 +- .../contextKeys/vscode-node/contextKeys.contribution.ts | 2 +- .../src/extension/intents/node/chronicleIntent.ts | 4 ++-- .../configuration/common/configurationService.ts | 5 +++-- 9 files changed, 21 insertions(+), 18 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 6a7d00d955ac0..5fbdd80f6a69d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4018,6 +4018,15 @@ "tags": [ "experimental" ] + }, + "github.copilot.chat.localIndex.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.localIndex.enabled%", + "tags": [ + "experimental", + "onExp" + ] } } }, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 26cd798a0a55b..6d6f23d955764 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -169,10 +169,11 @@ "copilot.agent.description": "Edit files in your workspace in agent mode", "copilot.agent.compact.description": "Free up context by compacting the conversation history. Optionally include extra instructions for compaction.", "copilot.chronicle.description": "Session history tools and insights", - "copilot.chronicle.standup.description": "Generate a standup report from recent coding sessions", - "copilot.chronicle.tips.description": "Get personalized tips based on your Copilot usage patterns", + "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions", + "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", + "github.copilot.config.localIndex.enabled": "Enable local session tracking. When enabled, session data is tracked locally for /chronicle commands.", "github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, session data is synced to your Copilot account for cross-device access.", "github.copilot.config.sessionSearch.cloudSync.excludeRepositories": "Repository patterns to exclude from cloud sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repos will only be stored locally.", "copilot.workspace.explain.description": "Explain how the code in your active editor works", diff --git a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts index 404adb2c5cb10..c7d3d2de9605e 100644 --- a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts +++ b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts @@ -16,14 +16,6 @@ export type SessionIndexingLevel = 'local' | 'user' | 'repo_and_user'; /** * Manages user preferences for session indexing via VS Code settings. - * - * Two settings control behavior: - * - `chat.sessionSearch.localIndex.enabled` (team-internal, ExP) — enables local - * SQLite tracking and /chronicle commands - * - `chat.sessionSearch.cloudSync.enabled` — enables - * cloud upload to cloud - * - `chat.sessionSearch.cloudSync.excludeRepositories` — repo patterns - * to exclude from cloud sync */ export class SessionIndexingPreference { diff --git a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts index e5b07ca575163..82568f03d9dd0 100644 --- a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts +++ b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts @@ -13,7 +13,7 @@ function createMockConfigService(opts: { } = {}) { const configs: Record = {}; // Map by fullyQualifiedId - configs['github.copilot.chat.advanced.sessionSearch.localIndex.enabled'] = opts.localIndexEnabled ?? false; + configs['github.copilot.chat.localIndex.enabled'] = opts.localIndexEnabled ?? false; configs['github.copilot.chat.advanced.sessionSearch.cloudSync.enabled'] = opts.cloudSyncEnabled ?? false; configs['github.copilot.chat.advanced.sessionSearch.cloudSync.excludeRepositories'] = opts.excludeRepositories ?? []; diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts index c469e4e8d0fcd..35a51719c5b21 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts @@ -120,7 +120,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr // Only set up span listener when both local index and cloud sync are enabled. // Uses autorun to react if settings change at runtime. - const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); + const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); const cloudEnabled = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled); const spanListenerStore = this._register(new DisposableStore()); this._register(autorun(reader => { diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts index 993f0d134d36c..43a432f2002a6 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts @@ -80,7 +80,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib // Only set up span listener and flush timer when the feature is enabled. // Uses autorun to react if the setting changes at runtime. - const featureEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); + const featureEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); const spanListenerStore = this._register(new DisposableStore()); this._register(autorun(reader => { spanListenerStore.clear(); diff --git a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts index 876efa5244a90..4b4dacf885518 100644 --- a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts +++ b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts @@ -85,7 +85,7 @@ export class ContextKeysContribution extends Disposable { commands.executeCommand('setContext', debugReportFeedbackContextKey, debugReportFeedback.read(reader)); })); - const sessionSearchEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService); + const sessionSearchEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService); this._register(autorun(reader => { commands.executeCommand('setContext', sessionSearchEnabledContextKey, sessionSearchEnabled.read(reader)); })); diff --git a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts index 05376f288602a..8f43160d47633 100644 --- a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts +++ b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts @@ -49,7 +49,7 @@ export class ChronicleIntent implements IIntent { readonly id = ChronicleIntent.ID; readonly description = l10n.t('Session history tools and insights (standup, tips, improve)'); get locations(): ChatLocation[] { - return this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : []; + return this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : []; } readonly commandInfo: IIntentSlashCommandInfo = { @@ -86,7 +86,7 @@ export class ChronicleIntent implements IIntent { location: ChatLocation, chatTelemetry: ChatTelemetryBuilder, ): Promise { - if (!this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService)) { + if (!this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService)) { stream.markdown(l10n.t('Session search is not available yet.')); return {}; } diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 936bb3a2c16f7..8857f9e44ffde 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -875,8 +875,6 @@ export namespace ConfigKey { export const ResponsesApiWebSocketEnabled = defineTeamInternalSetting('chat.advanced.responsesApi.webSocket.enabled', ConfigType.ExperimentBased, false); export const DebugSimulateWebSocketResponse = defineTeamInternalSetting('chat.advanced.debug.simulateWebSocketResponse', ConfigType.Simple, ''); - /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/ - export const SessionSearchLocalIndexEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.localIndex.enabled', ConfigType.ExperimentBased, false, vBoolean()); /** Enable cloud sync of session data to cloud. */ export const SessionSearchCloudSyncEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.cloudSync.enabled', ConfigType.Simple, false, vBoolean()); /** Repository patterns to exclude from cloud sync (exact owner/repo or glob patterns like my-org/*). */ @@ -1030,6 +1028,9 @@ export namespace ConfigKey { export const CopilotMemoryEnabled = defineSetting('chat.copilotMemory.enabled', ConfigType.ExperimentBased, false); export const MemoryToolEnabled = defineSetting('chat.tools.memory.enabled', ConfigType.ExperimentBased, true); export const ViewImageToolEnabled = defineSetting('chat.tools.viewImage.enabled', ConfigType.ExperimentBased, true); + + /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/ + export const LocalIndexEnabled = defineSetting('chat.localIndex.enabled', ConfigType.ExperimentBased, false); } export function getAllConfigKeys(): string[] { From 5407371c47be5a775de3baed6b5458bd24efa9de Mon Sep 17 00:00:00 2001 From: Charlie Fish Date: Fri, 24 Apr 2026 13:13:00 -0600 Subject: [PATCH 22/36] Chat: Show provider instance name for duplicate BYOK models in model picker (#312028) * Chat: show provider group name in model picker detail When resolving grouped language model providers, replace model metadata detail with the configured group name. This lets users distinguish duplicate models from multiple instances of the same vendor (for example Local vs Remote Ollama). Also adds a unit test that verifies two groups from the same vendor produce distinct detail labels in the picker metadata. Signed-off-by: Charlie Fish * Chat: only override model detail with group name when needed Co-authored-by: Copilot * Chat: use group name as default model detail instead of provider name --------- Signed-off-by: Charlie Fish Co-authored-by: Copilot --- .../src/extension/byok/common/byokProvider.ts | 6 +- .../contrib/chat/common/languageModels.ts | 11 + .../chat/test/common/languageModels.test.ts | 217 ++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts index 54237bd90e507..7bfe1c0722894 100644 --- a/extensions/copilot/src/extension/byok/common/byokProvider.ts +++ b/extensions/copilot/src/extension/byok/common/byokProvider.ts @@ -143,7 +143,11 @@ export function byokKnownModelToAPIInfo(providerName: string, id: string, capabi version: '1.0.0', maxOutputTokens: capabilities.maxOutputTokens, maxInputTokens: capabilities.maxInputTokens, - detail: providerName, + // `detail` is intentionally omitted: when this model is resolved + // via a configured provider group, `LanguageModelsService` will + // fall back to the group name so multiple instances of the same + // vendor (e.g. multiple Ollama servers) are distinguishable in + // the model picker. family: id, tooltip: `${capabilities.name} is contributed via the ${providerName} provider.`, multiplierNumeric: 0, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e4c806a417cdf..cc8ed426f8524 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -906,6 +906,17 @@ export class LanguageModelsService implements ILanguageModelsService { try { const models = await provider.provideLanguageModelChatInfo({ group: group.name, silent, configuration }, CancellationToken.None); if (models.length) { + // Provide a sensible default for `metadata.detail` so that + // multiple instances of the same vendor (e.g. multiple + // Ollama servers) are distinguishable in the model picker. + // Providers that supply their own `detail` keep it; when + // the provider does not set one, fall back to the user- + // configured group name. + for (let i = 0; i < models.length; i++) { + if (!models[i].metadata.detail) { + models[i] = { ...models[i], metadata: { ...models[i].metadata, detail: group.name } }; + } + } allModels.push(...models); languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); } diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 6908d48f9d4d0..c2594ec58aa02 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -1143,3 +1143,220 @@ suite('LanguageModels - Per-Model Configuration', function () { assert.deepStrictEqual(receivedOptions, { configuration: { temperature: 0.2 } }); }); }); + +suite('LanguageModels - Provider Group Detail Fallback', function () { + + const disposables = new DisposableStore(); + + teardown(function () { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('model.detail falls back to the group name so multiple instances of the same vendor are distinguishable', async function () { + const languageModelsService = disposables.add(new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + disposables.add(new TestStorageService()), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [ + { vendor: 'multi-vendor', name: 'Local' }, + { vendor: 'multi-vendor', name: 'Remote' } + ]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + )); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + // Cast needed: TypeFromJsonSchema resolves the `anyOf`+`$ref` configuration + // field to `undefined`, but the runtime value must be truthy so the + // service treats this vendor as a configurable (BYOK) provider and + // resolves models for every group rather than stopping after the first. + { vendor: 'multi-vendor', displayName: 'Multi Vendor', configuration: {} as unknown as undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('multi-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (!options.group) { + return []; + } + // Provider returns the same model id for each group, but the + // identifier is namespaced by group so they don't collide. + // The provider does not set `detail`; the service should fall + // back to the per-instance group name. + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Shared Model', + vendor: 'multi-vendor', + family: 'shared', + version: '1.0', + id: 'shared-model', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: `multi-vendor/${options.group}/shared-model` + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + const local = languageModelsService.lookupLanguageModel('multi-vendor/Local/shared-model'); + const remote = languageModelsService.lookupLanguageModel('multi-vendor/Remote/shared-model'); + + assert.deepStrictEqual( + { localDetail: local?.detail, remoteDetail: remote?.detail }, + { localDetail: 'Local', remoteDetail: 'Remote' } + ); + }); + + test('model.detail falls back to the group name even when there is only a single group for the vendor', async function () { + const languageModelsService = disposables.add(new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + disposables.add(new TestStorageService()), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [ + { vendor: 'single-vendor', name: 'Only Instance' } + ]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + )); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + { vendor: 'single-vendor', displayName: 'Single Vendor', configuration: undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('single-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (!options.group) { + return []; + } + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Solo Model', + vendor: 'single-vendor', + family: 'solo', + version: '1.0', + id: 'solo-model', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: `single-vendor/${options.group}/solo-model` + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + const solo = languageModelsService.lookupLanguageModel('single-vendor/Only Instance/solo-model'); + + assert.strictEqual(solo?.detail, 'Only Instance'); + }); + + test('a provider-supplied detail is preserved when multiple groups exist', async function () { + const languageModelsService = disposables.add(new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + disposables.add(new TestStorageService()), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [ + { vendor: 'detail-vendor', name: 'Local' }, + { vendor: 'detail-vendor', name: 'Remote' } + ]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + )); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + // Cast needed: see equivalent comment in the multi-vendor test above. + { vendor: 'detail-vendor', displayName: 'Detail Vendor', configuration: {} as unknown as undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('detail-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (!options.group) { + return []; + } + // Provider supplies its own detail. The service should leave + // it untouched and only fall back to the group name when the + // provider does not set one. + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Detailed Model', + vendor: 'detail-vendor', + family: 'detailed', + version: '1.0', + id: 'detailed-model', + detail: `Detailed (${options.group})`, + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: `detail-vendor/${options.group}/detailed-model` + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + const local = languageModelsService.lookupLanguageModel('detail-vendor/Local/detailed-model'); + const remote = languageModelsService.lookupLanguageModel('detail-vendor/Remote/detailed-model'); + + assert.deepStrictEqual( + { localDetail: local?.detail, remoteDetail: remote?.detail }, + { localDetail: 'Detailed (Local)', remoteDetail: 'Detailed (Remote)' } + ); + }); +}); From a9c0f03fb70cd24a3932f2a8898cbd21291178d2 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Fri, 24 Apr 2026 12:24:34 -0700 Subject: [PATCH 23/36] fix: prevent tool deferral when tool_search is not in request (#312406) fix: prevent tool deferral when tool_search is not in request (#311946) When a custom agent restricts tools to only MCP tools, tool_search gets filtered out but toolSearchEnabled remained true. This caused all tools to get defer_loading=true, which Anthropic rejects with 'At least one tool must have defer_loading=false'. Add a defense-in-depth check in both Messages API and Responses API: verify tool_search is actually present in the request's tool list before enabling tool deferral. This aligns with the BYOK path which already had this guard. --- .../src/platform/endpoint/node/messagesApi.ts | 3 +- .../platform/endpoint/node/responsesApi.ts | 3 +- .../node/test/responsesApiToolSearch.spec.ts | 30 +++++ .../endpoint/test/node/messagesApi.spec.ts | 109 ++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index 24cc6056d0cf3..fc3c6469535bb 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -101,7 +101,8 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I const experimentationService = accessor.get(IExperimentationService); const toolDeferralService = accessor.get(IToolDeferralService); - const toolSearchEnabled = !!endpoint.supportsToolSearch; + const toolSearchEnabled = !!endpoint.supportsToolSearch + && !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME); // Split tools into non-deferred and deferred up front so we can build finalTools // with non-deferred first. This ensures the cache_control breakpoint on the last diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 700fb85ab48e2..a42223b0aa5ac 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -65,7 +65,8 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: const toolSearchEnabled = isResponsesApiToolSearchEnabled(endpoint, configService, expService); const isAllowedConversationAgent = options.location === ChatLocation.Agent || options.location === ChatLocation.MessagesProxy; const isSubagent = options.telemetryProperties?.subType?.startsWith('subagent') ?? false; - const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent; + const toolSearchInRequest = !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME); + const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent && toolSearchInRequest; const toolDeferralService = shouldDeferTools ? accessor.get(IToolDeferralService) : undefined; type ResponsesFunctionTool = OpenAI.Responses.FunctionTool & OpenAiResponsesFunctionTool; diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts index 3d4dd218aacec..728c106eac42a 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts @@ -57,6 +57,7 @@ function createMockOptions(overrides: Partial = {}): { type: 'function', function: { name: 'grep_search', description: 'Search for text', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }, { type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] } } }, { type: 'function', function: { name: 'another_deferred_tool', description: 'Another tool', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'tool_search', description: 'Search tools', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } }, ] }, ...overrides, @@ -149,6 +150,35 @@ describe('createResponsesRequestBody tools', () => { expect(tools.every(t => !t.defer_loading)).toBe(true); }); + it('does not defer tools when tool_search is not in the request tool list', () => { + // Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent with + // `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate, every + // MCP tool would be marked deferred and stripped from the request, leaving the + // agent with nothing to call. + const endpoint = createMockEndpoint('gpt-5.4-preview'); + const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true); + + const options = createMockOptions({ + requestOptions: { + tools: [ + { type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'another_mcp_tool', description: 'Another MCP tool', parameters: { type: 'object', properties: {} } } }, + ] + } + }); + const body = accessor.get(IInstantiationService).invokeFunction( + createResponsesRequestBody, options, endpoint.model, endpoint + ); + + const tools = body.tools as any[]; + // No client tool_search should be added. + expect(tools.find(t => t.type === 'tool_search')).toBeUndefined(); + // All user-listed tools should be sent to the model, not stripped. + expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined(); + expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined(); + }); + it('always filters tool_search function tool from tools array', () => { const endpoint = createMockEndpoint('gpt-5.4-preview'); const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService; diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index da00bbb684bd2..798481a0466d5 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -819,3 +819,112 @@ describe('createMessagesRequestBody reasoning effort', () => { expect(body.output_config).toEqual({ effort: 'low' }); }); }); + +describe('createMessagesRequestBody tool search deferral', () => { + let disposables: DisposableStore; + let instantiationService: IInstantiationService; + + function createMockEndpoint(supportsToolSearch: boolean): IChatEndpoint { + return { + model: 'claude-sonnet-4.6', + family: 'claude-sonnet-4.6', + modelProvider: 'Anthropic', + maxOutputTokens: 8192, + modelMaxPromptTokens: 200000, + supportsToolCalls: true, + supportsVision: true, + supportsPrediction: false, + supportsToolSearch, + showInModelPicker: true, + isFallback: false, + name: 'test', + version: '1.0', + policy: 'enabled', + urlOrRequestMetadata: 'https://test.com', + tokenizer: 0, + isDefault: false, + processResponseFromChatEndpoint: () => { throw new Error('not implemented'); }, + acceptChatPolicy: () => { throw new Error('not implemented'); }, + makeChatRequest2: () => { throw new Error('not implemented'); }, + createRequestBody: () => { throw new Error('not implemented'); }, + cloneWithTokenOverride: () => { throw new Error('not implemented'); }, + interceptBody: () => { }, + getExtraHeaders: () => ({}), + } as unknown as IChatEndpoint; + } + + function makeTool(name: string) { + return { type: 'function' as const, function: { name, description: `${name} tool`, parameters: { type: 'object', properties: {} } } }; + } + + function createOptions(tools: ReturnType[]): ICreateEndpointBodyOptions { + return { + debugName: 'test', + requestId: 'test-request-id', + finishedCb: undefined, + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }], + }], + postOptions: { max_tokens: 8192 }, + location: ChatLocation.Agent, + modelCapabilities: { enableToolSearch: true }, + requestOptions: { tools }, + } as ICreateEndpointBodyOptions; + } + + beforeEach(() => { + disposables = new DisposableStore(); + const services = disposables.add(createPlatformServices(disposables)); + // Non-deferred allowlist matches production: core tools + tool_search itself. + const nonDeferred = new Set(['read_file', 'grep_search', CUSTOM_TOOL_SEARCH_NAME]); + services.define(IToolDeferralService, { + _serviceBrand: undefined, + isNonDeferredTool: (name: string) => nonDeferred.has(name), + }); + const accessor = services.createTestingAccessor(); + instantiationService = accessor.get(IInstantiationService); + }); + + test('does not set defer_loading when tool_search is not in the request tool list', () => { + // Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent + // with `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate, + // every MCP tool gets defer_loading=true and Anthropic rejects the request with + // "At least one tool must have defer_loading=false." + const endpoint = createMockEndpoint(true); + const options = createOptions([makeTool('some_mcp_tool'), makeTool('another_mcp_tool')]); + + const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint); + + const tools = body.tools as AnthropicMessagesTool[]; + expect(tools.every(t => !t.defer_loading)).toBe(true); + expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined(); + expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined(); + }); + + test('defers MCP tools when tool_search is in the request tool list', () => { + const endpoint = createMockEndpoint(true); + const options = createOptions([ + makeTool('read_file'), + makeTool('some_mcp_tool'), + makeTool(CUSTOM_TOOL_SEARCH_NAME), + ]); + + const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint); + + const tools = body.tools as AnthropicMessagesTool[]; + expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined(); + expect(tools.find(t => t.name === CUSTOM_TOOL_SEARCH_NAME)?.defer_loading).toBeUndefined(); + expect(tools.find(t => t.name === 'some_mcp_tool')?.defer_loading).toBe(true); + }); + + test('does not defer when endpoint does not support tool search', () => { + const endpoint = createMockEndpoint(false); + const options = createOptions([makeTool('read_file'), makeTool('some_mcp_tool'), makeTool(CUSTOM_TOOL_SEARCH_NAME)]); + + const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint); + + const tools = body.tools as AnthropicMessagesTool[]; + expect(tools.every(t => !t.defer_loading)).toBe(true); + }); +}); From 96d2420edc7ebf52d678409c7f14c60a2bfbaf9f Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:25:01 -0700 Subject: [PATCH 24/36] Mobile titlebar: live session title + web host widget on welcome (#312261) * Mobile titlebar: live session title + web host widget on welcome On phone layouts the desktop titlebar is hidden and replaced by a mobile-specific bar above the grid. Two issues fixed here: 1. The title was hardcoded to "New Session" because `setTitle()` was never called. It now subscribes to `ISessionsManagementService.activeSession.title` via `autorun` and updates live. 2. On the home/empty (welcome) screen there was no way to pick an agent host or connect on mobile web. The host filter widget (dropdown + connection status/connect button) from the web titlebar is now surfaced in the mobile titlebar's center slot while the welcome view is visible. ## Implementation - New `MobileTitlebarPart` under `browser/parts/mobile/` replaces the old `MobileTopBar`. It hosts a hamburger, a center slot with a title element and a `MenuWorkbenchToolBar` for a new `Menus.MobileTitleBarCenter` menu id, and a new-session button. The center slot switches between title and toolbar based on `SessionsWelcomeVisibleContext` AND whether the toolbar has any contributed items, so empty menus (e.g., desktop/electron) fall back to showing the title. - Instantiated via `IInstantiationService.createInstance` so it can consume `ISessionsManagementService` / `IContextKeyService`. - `hostFilter.contribution.ts` registers a second menu entry on `Menus.MobileTitleBarCenter` (gated by `IsWeb && !AuxiliaryWindow && HasAgentHosts && SessionsWelcomeVisible`) plus a matching `IActionViewItemService.register()` call reusing the existing `HostFilterActionViewItem`. No new widget code. - Layering is preserved: `browser/` never imports from `contrib/`; the mobile part only knows about the menu id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: touch + a11y for mobile titlebar widgets - MobileTitlebarPart: render the session title as a