diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index 9f1c87c87674d8..819aac28463afb 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ -import { IChatEndpoint, IChatEndpointTokenPricing } from '../../../platform/networking/common/networking'; +import { IChatEndpoint, IChatEndpointTokenPricing, PENDING_DEPRECATION_CODE } from '../../../platform/networking/common/networking'; import * as l10n from '@vscode/l10n'; import type { LanguageModelChatInformation, LanguageModelConfigurationSchema } from 'vscode'; @@ -129,6 +129,23 @@ export function buildAutoModeTierSchemaProperty(tiers: readonly string[], defaul }; } +/** + * Resolves the model picker's warning presentation. All warnings show as hover banners, + * but only a degradation or a pending deprecation flags the row, and `rowWarning` is the + * message explaining it. Callers must skip the synthetic Auto model, which wraps another + * endpoint and must not inherit its warnings. + */ +export function resolveModelWarnings(endpoint: Pick): { texts: Record; rowWarning: string | undefined } | undefined { + const texts: Record = { ...endpoint.warningText }; + if (endpoint.degradationReason) { + texts['degradation'] = endpoint.degradationReason; + } + if (Object.keys(texts).length === 0) { + return undefined; + } + return { texts, rowWarning: endpoint.degradationReason ?? texts[PENDING_DEPRECATION_CODE] }; +} + /** * Returns a description of the model's capabilities and intended use cases. * This is shown in the rich hover when selecting models. diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index cb04669e20b504..8b037c1cbcc448 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -45,7 +45,7 @@ import { IExtensionContribution } from '../../common/contributions'; import { PromptRenderer } from '../../prompts/node/base/promptRenderer'; import { isImageDataPart } from '../common/languageModelChatMessageHelpers'; import { LanguageModelAccessPrompt } from './languageModelAccessPrompt'; -import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty, buildAutoModeTierSchemaProperty } from '../common/languageModelAccess'; +import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, resolveModelWarnings, buildReasoningEffortSchemaProperty, buildAutoModeTierSchemaProperty } from '../common/languageModelAccess'; /** * Builds a configurationSchema for the model picker based on the endpoint's supported capabilities. @@ -339,9 +339,13 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib const sanitizedModelName = endpoint.name .replace(/\([^)]*\bcontext\)/gi, '') .trim(); + + // Auto wraps another endpoint, so it must not inherit that model's warnings. + const warnings = endpoint instanceof AutoChatEndpoint ? undefined : resolveModelWarnings(endpoint); + let modelTooltip: string | undefined; - if (endpoint.degradationReason) { - modelTooltip = endpoint.degradationReason; + if (warnings?.rowWarning) { + modelTooltip = warnings.rowWarning; } else if (endpoint instanceof AutoChatEndpoint) { modelTooltip = getAutoModelDescription(endpoint.discountRange); } else { @@ -384,7 +388,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib priceCategory: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.priceCategory, category: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.modelPickerCategory, detail: modelDetail, - statusIcon: endpoint.degradationReason ? new vscode.ThemeIcon('warning') : undefined, + statusIcon: warnings?.rowWarning ? new vscode.ThemeIcon('warning') : undefined, version: endpoint.version, maxInputTokens: endpoint.modelMaxPromptTokens - baseCount - BaseTokensPerCompletion, maxOutputTokens: endpoint.maxOutputTokens, @@ -396,13 +400,8 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib [ApiChatLocation.Editor]: endpoint instanceof AutoChatEndpoint, // inline chat gets 'Auto' by default }, isUserSelectable: endpoint.showInModelPicker, - warningText: endpoint instanceof AutoChatEndpoint ? undefined : (() => { - const texts: Record = { ...endpoint.warningText }; - if (endpoint.degradationReason) { - texts['degradation'] = endpoint.degradationReason; - } - return Object.keys(texts).length > 0 ? texts : undefined; - })(), + warningText: warnings?.texts, + infoText: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.infoText, promo: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.promo, capabilities: { imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision, diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts new file mode 100644 index 00000000000000..9a1f96697d4d66 --- /dev/null +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelWarnings.spec.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { resolveModelWarnings } from '../../common/languageModelAccess'; + +const DEPRECATION = 'Claude Sonnet 4.6 has a planned deprecation date of 2026-09-01.'; +const DEGRADATION = 'This model is currently degraded.'; +const RETENTION = 'Prompts are retained for 30 days.'; + +describe('resolveModelWarnings', () => { + it('flags a pending deprecation even though it arrives without a degradation', () => { + expect(resolveModelWarnings({ warningText: { model_pending_deprecation: DEPRECATION } })).toEqual({ + texts: { model_pending_deprecation: DEPRECATION }, + rowWarning: DEPRECATION, + }); + }); + + it('shows a banner-only warning without flagging the row', () => { + expect(resolveModelWarnings({ warningText: { data_retention: RETENTION } })).toEqual({ + texts: { data_retention: RETENTION }, + rowWarning: undefined, + }); + }); + + it('lets a degradation explain the model even when other warnings are present', () => { + expect(resolveModelWarnings({ + warningText: { data_retention: RETENTION }, + degradationReason: DEGRADATION, + })).toEqual({ + texts: { data_retention: RETENTION, degradation: DEGRADATION }, + rowWarning: DEGRADATION, + }); + }); + + it('has no warning presentation when the model carries no warnings', () => { + expect(resolveModelWarnings({})).toBeUndefined(); + }); +}); diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index b791013fedcee5..75f3bd7f5d28c8 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/logService'; import { isAnthropicContextEditingEnabled, isExtendedCacheTtlEnabled } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, ICopilotToolCall, OptionalChatRequestParams } from '../../networking/common/fetch'; import { IFetcherService, Response } from '../../networking/common/fetcherService'; -import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride } from '../../networking/common/networking'; +import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride, PENDING_DEPRECATION_CODE } from '../../networking/common/networking'; import { CAPIChatMessage, ChatCompletion, FinishedCompletionReason, RawMessageConversionCallback } from '../../networking/common/openai'; import { prepareChatCompletionForReturn } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; @@ -152,6 +152,23 @@ export async function defaultNonStreamChatResponseProcessor(response: Response, return AsyncIterableObject.fromArray(completions); } +/** Splits CAPI `info_messages` into warning and info banners keyed by their code. */ +function splitInfoMessages(infoMessages: { code: string; message: string }[] | undefined): { warningText: Record; infoText: Record } { + const warningText: Record = {}; + const infoText: Record = {}; + for (const { code, message } of infoMessages ?? []) { + if (message) { + const target = code === PENDING_DEPRECATION_CODE ? warningText : infoText; + target[code || 'info'] = message; + } + } + return { warningText, infoText }; +} + +function undefinedIfEmpty(record: Record): Record | undefined { + return Object.keys(record).length > 0 ? record : undefined; +} + export class ChatEndpoint implements IChatEndpoint { private readonly _maxTokens: number; private readonly _maxOutputTokens: number; @@ -182,6 +199,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly customModel?: CustomModel | undefined; public readonly maxPromptImages?: number | undefined; public readonly warningText?: Record | undefined; + public readonly infoText?: Record | undefined; public readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string } | undefined; private readonly _supportsStreaming: boolean; @@ -233,7 +251,9 @@ export class ChatEndpoint implements IChatEndpoint { this._supportsStreaming = !!modelMetadata.capabilities.supports.streaming; this.customModel = modelMetadata.custom_model; this.maxPromptImages = modelMetadata.capabilities.limits?.vision?.max_prompt_images; - this.warningText = modelMetadata.warning_text; + const infoMessages = splitInfoMessages(modelMetadata.info_messages); + this.warningText = undefinedIfEmpty({ ...modelMetadata.warning_text, ...infoMessages.warningText }); + this.infoText = undefinedIfEmpty(infoMessages.infoText); this.promo = modelMetadata.billing?.promo ? { id: modelMetadata.billing.promo.id, discountPercent: modelMetadata.billing.promo.discount_percent, diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index b77b936c6dc4cf..44c6f8ce013197 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -664,3 +664,50 @@ describe('ChatEndpoint - CAPI reasoning effort', () => { expect(body.reasoning_effort).toBeUndefined(); }); }); + +describe('ChatEndpoint - model picker notices', () => { + let mockServices: ReturnType; + + beforeEach(() => { + mockServices = createMockServices(); + }); + + const createEndpoint = (metadata: IChatModelInformation) => + new ChatEndpoint( + metadata, + mockServices.domainService, + mockServices.chatMLFetcher, + mockServices.tokenizerProvider, + mockServices.instantiationService, + mockServices.configurationService, + mockServices.expService, + mockServices.chatWebSocketService, + mockServices.logService + ); + + it('shows a pending deprecation as a warning and other info messages as info', () => { + const endpoint = createEndpoint({ + ...createNonAnthropicModelMetadata('gpt-4.1'), + warning_text: { data_retention: 'Prompts are retained for 30 days.' }, + warning_messages: [{ code: 'model_degraded', message: 'GPT-4.1 is currently degraded.' }], + info_messages: [ + { code: 'model_pending_deprecation', message: 'GPT-4.1 has a planned deprecation date of 2026-06-01.' }, + { code: 'model_relocated', message: 'GPT-4.1 now serves from a new region.' }, + ], + }); + + expect({ warningText: endpoint.warningText, infoText: endpoint.infoText, degradationReason: endpoint.degradationReason }).toEqual({ + warningText: { + data_retention: 'Prompts are retained for 30 days.', + model_pending_deprecation: 'GPT-4.1 has a planned deprecation date of 2026-06-01.', + }, + infoText: { model_relocated: 'GPT-4.1 now serves from a new region.' }, + degradationReason: 'GPT-4.1 is currently degraded.', + }); + }); + + it('has no notices when CAPI sends none', () => { + const endpoint = createEndpoint({ ...createNonAnthropicModelMetadata('gpt-4.1'), info_messages: [] }); + expect({ warningText: endpoint.warningText, infoText: endpoint.infoText }).toEqual({ warningText: undefined, infoText: undefined }); + }); +}); diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 9bd8936fe37157..f3384ef4e72fa1 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -321,6 +321,9 @@ export interface IChatEndpointTokenPricing { readonly longContext?: ITokenPriceTier; } +/** CAPI notice code that shows as a warning banner and also flags the model picker row. */ +export const PENDING_DEPRECATION_CODE = 'model_pending_deprecation'; + export interface IChatEndpoint extends IEndpoint { readonly maxOutputTokens: number; /** The model ID- this may change and will be `copilot-utility` for the utility (fallback) model. Use `family` to switch behavior based on model type. */ @@ -341,7 +344,10 @@ export interface IChatEndpoint extends IEndpoint { readonly showInModelPicker: boolean; readonly isPremium?: boolean; readonly degradationReason?: string; + /** Category-keyed warning banners for the model picker. */ readonly warningText?: Record; + /** Category-keyed info banners for the model picker. Unlike {@link warningText} these never signal a problem. */ + readonly infoText?: Record; readonly promo?: { id: string; discountPercent: number; endsAt?: string; message: string }; readonly multiplier?: number; readonly restrictedToSkus?: string[]; diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 6f7672cb5d0d88..b3ae2f31be9fcd 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -766,7 +766,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT id: rule.id, source: rule.uriPattern.source, flags: rule.uriPattern.flags, - initialKind: rule.initialKind, + initialKind: rule.initialKind === 'chat' ? 'session' : rule.initialKind, })), }); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts b/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts index 44569bfbd00e80..9e09616d74b59e 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorRichLinks.ts @@ -106,15 +106,26 @@ class ApiLinkPresentationEntry extends Disposable { } const watcher = this._register(vscode.window.createLinkPresentationWatcher(rule.id, resource)); - publishPresentation(watcher.presentation); - this._register(watcher.onDidChangePresentation(() => publishPresentation(watcher.presentation))); + publishPresentation(toMarkdownEditorPresentation(watcher.presentation)); + this._register(watcher.onDidChangePresentation(() => publishPresentation(toMarkdownEditorPresentation(watcher.presentation)))); } catch (error) { logger.trace('Markdown rich link', `Failed to resolve ${href}`, error); if (!this.isDisposed) { publishPresentation(undefined); } } + + } +} + +function toMarkdownEditorPresentation(presentation: vscode.LinkPresentationData | undefined): LinkPresentation | undefined { + if (!presentation) { + return undefined; } + return { + ...presentation, + kind: presentation.kind === 'chat' ? 'session' : presentation.kind, + }; } async function resolveLinkResource(href: string, documentUri: vscode.Uri, linkOpener: MdLinkOpener): Promise { diff --git a/package.json b/package.json index d19766a08bbd34..3d3716499032fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.135.0", - "distro": "5475972d5042caa842cdccf21488c4d0728ca0c1", + "distro": "c842171bd42ca4aef20b0a186c94d99edd763842", "author": { "name": "Microsoft Corporation" }, diff --git a/scripts/mock-policy-server/public/app.ts b/scripts/mock-policy-server/public/app.ts index ef012609f4fddd..8994bb99d0346c 100644 --- a/scripts/mock-policy-server/public/app.ts +++ b/scripts/mock-policy-server/public/app.ts @@ -631,11 +631,18 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; const table = document.createElement('table'); table.className = 'validation-table'; + const columns = document.createElement('colgroup'); + for (const columnName of ['key', 'status', 'description']) { + const column = document.createElement('col'); + column.className = `validation-column-${columnName}`; + columns.appendChild(column); + } const head = document.createElement('thead'); const headRow = document.createElement('tr'); for (const heading of ['Key', 'Status', 'Description']) { const th = document.createElement('th'); th.textContent = heading; + th.scope = 'col'; headRow.appendChild(th); } head.appendChild(headRow); @@ -669,15 +676,22 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; } keyCell.appendChild(keyCode); const statusCell = document.createElement('td'); - statusCell.className = cls; + statusCell.classList.add('validation-status'); + if (cls) { + statusCell.classList.add(cls); + } statusCell.textContent = statusText; const descCell = document.createElement('td'); + descCell.className = 'validation-description'; descCell.textContent = (validation.schema?.description || '').split('.')[0]; row.append(keyCell, statusCell, descCell); tbody.appendChild(row); } - table.append(head, tbody); + table.append(columns, head, tbody); + const tableContainer = document.createElement('div'); + tableContainer.className = 'validation-table-container'; + tableContainer.appendChild(table); const schemaRows = rows.filter(row => row.inSchema && !row.dynamic); const presentCount = schemaRows.filter(row => row.inBody).length; @@ -690,7 +704,7 @@ declare const MOCK_POLICY_ENDPOINTS: EndpointDef[]; summary.classList.add('validation-warn'); } - container.replaceChildren(table, summary); + container.replaceChildren(tableContainer, summary); container.hidden = false; setStatus(unknownCount ? `${unknownCount} key${unknownCount > 1 ? 's' : ''} not in schema.` : '', unknownCount ? 'warn' : ''); } diff --git a/scripts/mock-policy-server/public/style.css b/scripts/mock-policy-server/public/style.css index 7c2b4b2324525a..26f9f47c4ef69e 100644 --- a/scripts/mock-policy-server/public/style.css +++ b/scripts/mock-policy-server/public/style.css @@ -115,6 +115,7 @@ main { display: flex; flex-direction: column; gap: 20px; + min-width: 0; } .sidebar-panel { @@ -132,6 +133,7 @@ main { display: flex; flex-direction: column; gap: 20px; + min-width: 0; } .section-block { @@ -205,6 +207,7 @@ main { display: flex; flex-direction: column; gap: 8px; + min-width: 0; } label { @@ -573,6 +576,7 @@ code { #validation-results { + min-width: 0; margin-top: 8px; } @@ -580,12 +584,28 @@ code { display: none; } +.validation-table-container { + width: 100%; + min-width: 0; + overflow-x: auto; +} + .validation-table { width: 100%; + min-width: 640px; border-collapse: collapse; + table-layout: fixed; font-size: 12px; } +.validation-column-key { + width: 32%; +} + +.validation-column-status { + width: 112px; +} + .validation-table th { text-align: left; font-size: 11px; @@ -598,8 +618,9 @@ code { } .validation-table td { - padding: 4px 8px; + padding: 6px 8px; border-bottom: 1px solid var(--border); + vertical-align: top; } .validation-table code { @@ -616,6 +637,9 @@ code { padding-inline: calc(8px + var(--validation-depth) * 16px) 8px; background: transparent; border-radius: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .validation-key code.nested::before { @@ -624,6 +648,15 @@ code { color: var(--text-secondary); } +.validation-status { + white-space: nowrap; +} + +.validation-description { + color: var(--text-secondary); + line-height: 1.5; +} + .validation-ok { color: var(--ok); } diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 4447cd745403d8..052186b1b35f3f 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -227,6 +227,12 @@ Provider-private discovery helpers name their concrete source: Claude uses `_lis For every provider, migration and discovery partition the same native catalog: migration returns known entries as plain metadata, while discovery emits unknown entries with provider-classified provenance (external for Claude and Codex, and for Copilot everything except an unknown legacy extension-host chat, which is emitted as internal and adoptable). The partition is not quite exhaustive for Copilot: a chat whose session database exists but holds none of the metadata keys `listChatsToMigrate` requires is rejected by both halves. That is deliberate — an empty database is how Agent Host records a chat it already touched — and is asserted by `copilotAgent.test.ts`'s "does not discover an extension-host chat with an empty Agent Host database". Central `agent-host.db` remains the durable provenance authority. +`list_sessions` exposes a session's configured project URI separately from its +primary and additional working directories. `create_session` accepts those URIs +directly and can resolve a unique project display name, preferring the +configured project root over a transient worktree. Ambiguous names require an +explicit project URI. + --- ## 4. Capabilities Gating diff --git a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts index 22fce8f0b3e294..23a6aa0342b05d 100644 --- a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../base/common/lifecycle.js'; -import { derived, IObservable } from '../../../base/common/observable.js'; +import { derived, IObservable, observableFromEvent } from '../../../base/common/observable.js'; import { isWeb } from '../../../base/common/platform.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../chat/common/chatSettings.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { bindContextKey, observableConfigValue } from '../../observable/common/platformObservableUtils.js'; +import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService } from '../../policy/common/copilotManagedSettings.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY, IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; export class AgentHostEnablementService extends Disposable implements IAgentHostEnablementService { @@ -18,16 +19,22 @@ export class AgentHostEnablementService extends Disposable implements IAgentHost declare readonly _serviceBrand: undefined; readonly enabled: IObservable; + readonly managedSandboxEnforced: IObservable; constructor( private readonly _isAgentHostRuntimeAvailable: boolean, configurationService: IConfigurationService, contextKeyService: IContextKeyService, + managedSettingsService: IManagedSettingsService, ) { super(); const aiFeaturesDisabled = observableConfigValue(ChatAIDisabledSettingId, false, configurationService); this.enabled = derived(this, reader => this._isAgentHostRuntimeAvailable && !aiFeaturesDisabled.read(reader)); this._register(bindContextKey(AGENT_HOST_ENABLED_CONTEXT_KEY, contextKeyService, reader => this.enabled.read(reader))); + + this.managedSandboxEnforced = observableFromEvent(this, + managedSettingsService.onDidChangeManagedSettings, + () => managedSettingsService.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY) === true); } } @@ -35,8 +42,9 @@ class BrowserAgentHostEnablementService extends AgentHostEnablementService { constructor( @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, + @IManagedSettingsService managedSettingsService: IManagedSettingsService, ) { - super(!isWeb, configurationService, contextKeyService); + super(!isWeb, configurationService, contextKeyService, managedSettingsService); } } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 82028582f5377c..a2e836063057c0 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -17,7 +17,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; -import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from '../common/agentService.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; @@ -40,7 +40,7 @@ import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ip import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; -import { getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; +import { formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; import { managedPermissionsConfigurationIds, resolveManagedSettingsPermissions, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { AgentHostClientConnectionKind, toAgentHostClientMeta } from '../common/agentHostTelemetry.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; @@ -228,6 +228,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; + private readonly _onDidFatalClose = this._register(new Emitter()); + readonly onDidFatalClose = this._onDidFatalClose.event; + private readonly _onDidChangeConnectionState = this._register(new Emitter()); readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; @@ -354,6 +357,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return; } const patch: Record = {}; + // These keys are host-level and last-writer-wins across windows. + const mirrored: string[] = []; for (const entry of getAgentHostConfigurationSyncEntries(this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY)) { if (!e.affectsConfiguration(entry.settingId)) { continue; @@ -361,9 +366,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const value = resolveAgentHostConfigurationSyncValue(this._configurationService, entry); if (value !== undefined) { patch[entry.sync.key] = value; + mirrored.push(`${entry.sync.key}=${formatAgentHostConfigurationSyncValueForLog(entry.settingId, value)} (${entry.settingId})`); } } if (Object.keys(patch).length) { + this._logService.info(`[RemoteAgentHostProtocol] Mirroring configuration to host root config from ${ConfigurationTargetToString(e.source)}: ${mirrored.join(', ')}`); this._dispatchRootConfig(patch); } if (e.affectsConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID)) { @@ -492,6 +499,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC throw error; } if (error instanceof NonReconnectableTransportError) { + this._onDidFatalClose.fire(protocolError); this._handleClose(protocolError); throw error; } @@ -689,7 +697,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return; } if (err instanceof NonReconnectableTransportError) { - this._handleClose(new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, err.message)); + const protocolError = new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, err.message); + this._onDidFatalClose.fire(protocolError); + this._handleClose(protocolError); return; } // Replace the gate so awaiting callers see the failure but new diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 111a1d3ad8128e..c853d7bba0c97a 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -27,6 +27,21 @@ export class AgentHostStartError extends Error { } } +export function isInvalidUtilityProcessConfigurationMessage(message: string): boolean { + return /^Invalid value for (?:args|env|execArgv)$/.test(message); +} + +export function isFatalAgentHostStartError(error: unknown): error is TypeError { + return error instanceof TypeError && isInvalidUtilityProcessConfigurationMessage(error.message); +} + +export function toFatalAgentHostStartError(error: Error): AgentHostStartError { + const startError = new AgentHostStartError(error.message, true); + startError.name = error.name; + startError.stack = error.stack; + return startError; +} + export interface IAgentHostConnection { readonly client: IChannelClient; readonly store: DisposableStore; @@ -714,6 +729,9 @@ export interface IAgentChats { /** Abort the in-flight turn for `chat`. */ abort(chat: URI, context: AgentChatOperationContext): Promise; + /** Return the model currently bound to `chat`, when the provider knows it. */ + getModel?(chat: URI, context: AgentChatOperationContext): ModelSelection | undefined; + changeModel(chat: URI, model: ModelSelection, context: AgentChatOperationContext): Promise; /** diff --git a/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts b/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts index 99853192a49e15..5519df0b6e735c 100644 --- a/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts +++ b/src/vs/platform/agentHost/common/agentHostConfigurationSync.ts @@ -128,6 +128,23 @@ export function resolveAgentHostConfigurationSyncValue(configurationService: ICo return entry.sync.transform ? entry.sync.transform(value) : value; } +/** + * Renders a mirrored value for logging, redacting anything that could carry + * user content. Mirrored settings are registry-driven and may hold paths or + * arbitrary strings, so only closed-set values (booleans, numbers, and declared + * enum members) are printed verbatim. + */ +export function formatAgentHostConfigurationSyncValueForLog(settingId: string, value: unknown): string { + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value); + } + const property = getPropertySchema(settingId); + if (typeof value === 'string' && property?.enum?.includes(value)) { + return value; + } + return `<${Array.isArray(value) ? 'array' : typeof value}>`; +} + /** * Builds the full root-config patch mirroring every applicable setting. Used on * connect and reconnect, where the host may be a freshly restarted process that diff --git a/src/vs/platform/agentHost/common/agentHostEnablementService.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts index 587f0fa2fdfdd3..96f8f8c8cb09ab 100644 --- a/src/vs/platform/agentHost/common/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts @@ -22,6 +22,17 @@ export interface IAgentHostEnablementService { * Whether Agent Host features are available and AI features are enabled in this window. */ readonly enabled: IObservable; + /** + * Whether an enterprise has mandated the Copilot SDK sandbox floor through managed settings + * (`sandbox.enabled`). The runtime owns composing and enforcing that floor; VS Code reads it + * only to retire the legacy local harness for governed users, since the sandbox is implemented + * by the Agent Host. + * + * A user- or workspace-level sandbox opt-in is not an enterprise decision and does not set + * this. Existing local chat sessions keep working; only the harness used for *new* chats is + * affected, and virtual workspaces are exempt. + */ + readonly managedSandboxEnforced: IObservable; } const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); diff --git a/src/vs/platform/agentHost/common/agentHostManagedRules.ts b/src/vs/platform/agentHost/common/agentHostManagedRules.ts new file mode 100644 index 00000000000000..2f3b1c6762d736 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostManagedRules.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Construction and validation of Copilot SDK managed permission rules. + * + * The SDK parses every rule it receives with a strict grammar, and a single + * malformed rule rejects the **entire** managed permissions document rather + * than just the offending entry. Because VS Code sends this document as part of + * session create/resume, an untranslatable rule derived from a user's settings + * would fail the session outright. Everything this bridge emits therefore goes + * through {@link buildManagedRule}, which returns `undefined` for anything the + * SDK would reject so the caller can drop it and continue. + * + * The grammar mirrored here is `parse_managed_rule` in the agent runtime + * (`src/runtime/src/permissions/managed.rs`). + */ + +/** + * Rule families the SDK understands. Any other family is a parse error for the + * whole document, so there is deliberately no escape hatch for arbitrary tool + * names — see `parse_managed_rule`'s "Unsupported managed permission rule + * family" branch. + */ +export const enum ManagedRuleFamily { + /** Shell/terminal commands. Matches `Bash` and `PowerShell` on the SDK side too. */ + Shell = 'Shell', + /** File reads. */ + Read = 'Read', + /** File writes. The SDK treats `Edit` as an alias. */ + Write = 'Write', + /** Network access, matched as a URL pattern. */ + Domain = 'Domain', +} + +/** + * A rule with no argument, which matches **every** request in its family — the + * SDK's matchers return `true` as soon as the family matches and the argument is + * absent. This is the only correct way to express "all of X"; an argument of + * `*` is *not* a universal wildcard for {@link ManagedRuleFamily.Domain}, + * because domain arguments are normalized as URL patterns whose only wildcard + * form is a `*.host` subdomain match. + */ +export function buildManagedFamilyRule(family: ManagedRuleFamily): string { + return family; +} + +/** + * Builds a single managed permission rule, or returns `undefined` when the + * argument cannot be expressed in the SDK grammar. + * + * Callers must treat `undefined` as "this restriction is not translatable" and + * skip it — never fall back to a broader rule, which would silently over-restrict. + */ +export function buildManagedRule(family: ManagedRuleFamily, argument: string): string | undefined { + // Deliberately not trimmed: the SDK parser treats the raw argument as + // significant, so `Shell( *)` is rejected for having no command prefix. + // Trimming here would turn that into a valid match-everything rule. + if (!argument.trim() || !isValidRuleArgument(argument)) { + return undefined; + } + switch (family) { + case ManagedRuleFamily.Shell: + return isValidShellArgument(argument) ? `${family}(${argument})` : undefined; + case ManagedRuleFamily.Read: + case ManagedRuleFamily.Write: + return isValidPathArgument(argument) ? `${family}(${normalizePathSeparators(argument)})` : undefined; + case ManagedRuleFamily.Domain: + return isValidDomainArgument(argument) ? `${family}(${argument})` : undefined; + } +} + +/** + * Structural constraints `parse_rule` applies to every argument regardless of + * family: a rule is delimited by the first `(` and a trailing `)`, so an + * argument containing `)` would truncate the parse. + */ +function isValidRuleArgument(argument: string): boolean { + return !argument.includes(')') && !argument.includes('\n'); +} + +/** + * The SDK rewrites a trailing `" *"` into a command-boundary match, so + * `Shell(git *)` matches `git` and `git status` but not `gitea`. A bare `" *"` + * with no command prefix is rejected there, so it is rejected here. + */ +function isValidShellArgument(argument: string): boolean { + if (argument === '*') { + return true; + } + const prefix = argument.endsWith(' *') ? argument.slice(0, -2).trimEnd() : argument; + return prefix.length > 0; +} + +/** + * Path arguments are compiled as globs with `literal_separator` enabled and + * backslash escaping disabled. The runtime's glob engine has no negation + * syntax, so a VS Code pattern such as `!foo/**` — legal in + * `base/common/glob.ts` — must be rejected rather than passed through, where it + * would fail `validate_glob` and reject the whole document. + */ +function isValidPathArgument(argument: string): boolean { + if (argument.startsWith('!')) { + return false; + } + // Unbalanced brace alternation fails to compile in the runtime's glob engine. + let depth = 0; + for (const char of argument) { + if (char === '{') { + depth++; + } else if (char === '}') { + if (--depth < 0) { + return false; + } + } + } + return depth === 0; +} + +/** The runtime normalizes Windows separators before compiling the glob. */ +function normalizePathSeparators(argument: string): string { + return argument.replace(/\\/g, '/'); +} + +/** + * Domain arguments are normalized by parsing them as a URL (with an implicit + * `https://` when no scheme is present), so anything without a host — or + * carrying shell-substitution syntax the runtime refuses — cannot be expressed. + */ +function isValidDomainArgument(argument: string): boolean { + if (argument.includes('$(') || argument.includes('${') || argument.includes('`')) { + return false; + } + const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(argument); + if (!hasScheme && argument.includes('//')) { + return false; + } + // A bare `*` parses as a host but matches nothing; the family rule is the + // correct way to express "all domains". + if (argument === '*') { + return false; + } + try { + const url = new URL(hasScheme ? argument : `https://${argument}`); + return url.hostname.length > 0; + } catch { + return false; + } +} diff --git a/src/vs/platform/agentHost/common/agentHostManagedSettings.ts b/src/vs/platform/agentHost/common/agentHostManagedSettings.ts index 6ee2ab672c01c1..989bcb1cecff72 100644 --- a/src/vs/platform/agentHost/common/agentHostManagedSettings.ts +++ b/src/vs/platform/agentHost/common/agentHostManagedSettings.ts @@ -3,10 +3,50 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/** + * Compatibility bridge from legacy VS Code settings to Copilot SDK managed + * settings. + * + * Scope and limits, all deliberate: + * + * - **Restrictions only.** Mappings may add `deny`, `ask`, or the bypass lock; + * they never widen what a session may do. In particular no `allow` list is + * contributed, even to narrow another rule's reach: the SDK's managed `allow` + * is not a scoping hint, since a covered request resolves to `managed_allow`, + * which the runtime treats as outright approval and returns without prompting. + * The runtime also intersects allow lists only when more than one managed + * source supplies one, so a lone list from VS Code would grant blanket + * auto-approval and could relax an MDM policy rather than reinforce it. + * - **Global layers only.** Values are read from policy, user, and application; + * workspace and folder values are ignored, because the agent host is shared by + * every window connected to it and one workspace's settings must not leak into + * another window's sessions. + * - **Only what survives translation.** A setting is mapped only when its VS + * Code semantics can be expressed exactly in the SDK's rule grammar. Where + * they cannot — a regular-expression terminal rule, an allow list that blocks + * what it omits — the restriction is skipped rather than approximated, since a + * near-miss silently changes what an administrator configured. + * - **Copilot sessions on a local host.** The renderer sends an empty + * contribution to remote hosts, and other agents do not consume managed + * settings, so restrictions bridged here do not reach them. Those agents + * remain governed by the root-config path (see + * `AgentHostAutoApprovePolicyRestrictedConfigKey`). + * + * New enterprise controls belong directly in the SDK's managed-settings + * contract; this table exists only so settings that predate it keep working. + */ + import type { IConfigurationService } from '../../configuration/common/configuration.js'; +import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings.js'; +import { extractDomainPattern, normalizeDomain } from '../../networkFilter/common/domainMatcher.js'; +import { buildManagedFamilyRule, buildManagedRule, ManagedRuleFamily } from './agentHostManagedRules.js'; import { getGlobalConfigurationValue, inspectValue } from './agentHostConfigurationSync.js'; -import { GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID } from './agentHostSchema.js'; +import { GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, type AgentHostTerminalAutoApproveRules, type AgentHostTerminalAutoApproveRuleValue } from './agentHostSchema.js'; +/** + * The restrictions this bridge contributes to the Copilot SDK. There is + * deliberately no `allow` list — see the module comment. + */ export interface IAgentHostManagedSettingsPermissions { disableBypassPermissionsMode?: 'disable'; deny?: string[]; @@ -15,30 +55,194 @@ export interface IAgentHostManagedSettingsPermissions { export const AgentHostMapLegacySettingsToManagedSettingsSettingId = 'chat.agentHost.copilot.mapLegacySettingsToManagedSettings'; +/** + * Which configuration layers may drive a mapping. + * + * `policyOnly` is the default for anything that removes a capability the user + * would otherwise have, so a personal preference is never promoted into an + * enterprise-grade restriction the user cannot lift. `anyGlobal` exists for + * mappings whose VS Code behavior already honors user and application values, + * where narrowing to policy would be a regression. + */ +type ManagedPermissionsSettingSources = 'policyOnly' | 'anyGlobal'; + interface IManagedPermissionsSettingMapping { readonly settingId: string; + /** Further settings whose changes must also re-resolve this mapping. */ + readonly additionalSettingIds?: readonly string[]; contribute(configurationService: IConfigurationService): IAgentHostManagedSettingsPermissions | undefined; } -function managedPermissionsSetting(settingId: string, transform: (value: T, source: 'policyValue' | 'userValue' | 'applicationValue') => IAgentHostManagedSettingsPermissions | undefined): IManagedPermissionsSettingMapping { +function managedPermissionsSetting( + settingId: string, + sources: ManagedPermissionsSettingSources, + transform: (value: T) => IAgentHostManagedSettingsPermissions | undefined, +): IManagedPermissionsSettingMapping { return { settingId, contribute: configurationService => { const configuration = inspectValue(configurationService, settingId); - return configuration === undefined ? undefined : transform(...configuration); + if (configuration === undefined) { + return undefined; + } + const [value, source] = configuration; + if (sources === 'policyOnly' && source !== 'policyValue') { + return undefined; + } + return transform(value); }, }; } +/** + * A mapping whose restriction is decided by several settings together — for + * example a list that only takes effect while a separate switch is on. The + * additional setting ids are registered so a change to any of them re-resolves + * the contribution. + */ +function managedPermissionsCompositeSetting( + settingId: string, + additionalSettingIds: readonly string[], + contribute: (configurationService: IConfigurationService) => IAgentHostManagedSettingsPermissions | undefined, +): IManagedPermissionsSettingMapping { + return { settingId, additionalSettingIds, contribute }; +} + +/** + * Translates VS Code's agent network filter into managed domain rules. + * + * Only the blocking half is expressible. VS Code denies any domain outside a + * populated allow list, but a managed `allow` entry does not block what it omits + * — unmatched requests fall through to a prompt the user can approve — so + * mapping the allow list would quietly downgrade a block into a prompt. The deny + * list and the "filter on with nothing configured" case both mean block, and + * both survive the translation intact. + */ +function contributeNetworkDomainRules(configurationService: IConfigurationService): IAgentHostManagedSettingsPermissions | undefined { + if (getGlobalConfigurationValue(configurationService, AgentNetworkDomainSettingId.NetworkFilter) !== true) { + return undefined; + } + const allowed = getGlobalConfigurationValue(configurationService, AgentNetworkDomainSettingId.AllowedNetworkDomains) ?? []; + const denied = getGlobalConfigurationValue(configurationService, AgentNetworkDomainSettingId.DeniedNetworkDomains) ?? []; + + // VS Code's restrictive default: with the filter on and neither list + // configured, every domain is blocked. + if (allowed.length === 0 && denied.length === 0) { + return { deny: [buildManagedFamilyRule(ManagedRuleFamily.Domain)] }; + } + + const deny: string[] = []; + for (const pattern of denied) { + if (typeof pattern !== 'string') { + continue; + } + // Reduce the entry the way the network filter itself does before building a + // rule from it. VS Code matches on the hostname alone, so a denial written + // as a full URL or with a port blocks the whole host; passing the raw text + // through would emit a narrower URL pattern and leave the rest of that host + // reachable. + const domain = normalizeDomain(extractDomainPattern(pattern), true); + if (!domain) { + continue; + } + // VS Code accepts a bare `*` as "every domain"; the SDK expresses that as + // the family rule rather than as an argument. + const rule = domain === '*' + ? buildManagedFamilyRule(ManagedRuleFamily.Domain) + : buildManagedRule(ManagedRuleFamily.Domain, domain); + if (rule) { + deny.push(rule); + } + } + return deny.length > 0 ? { deny } : undefined; +} + +/** + * Translates VS Code's explicit terminal auto-approve denials into managed + * shell rules. + * + * A `false` entry means "require explicit approval", not "block" — the + * setting's own enum description says so, and the host-side auto-approver's + * `denied` result only causes a prompt. It therefore maps onto `ask`, which + * keeps the user's approval path, rather than `deny`, which is terminal and has + * no ask stage. + * + * Only literal sub-command denials survive. Regular-expression keys, entries + * matched against the whole command line, and keys containing `*` are skipped: + * VS Code escapes `*` as a literal character while the SDK reads it as a + * wildcard, so translating `git *` would broaden one denial into every `git` + * command. + */ +function contributeTerminalDenialRules(rules: AgentHostTerminalAutoApproveRules): IAgentHostManagedSettingsPermissions | undefined { + if (!rules || typeof rules !== 'object') { + return undefined; + } + const ask: string[] = []; + for (const [command, value] of Object.entries(rules)) { + if (!isSubCommandDenial(value) || !isLiteralCommandKey(command)) { + continue; + } + // `Shell(cmd)` already matches both the bare command and the command with + // arguments, so the trailing-wildcard form would be redundant. + const rule = buildManagedRule(ManagedRuleFamily.Shell, command); + if (rule) { + ask.push(rule); + } + } + return ask.length > 0 ? { ask } : undefined; +} + +/** + * Whether a rule value denies approval for a sub-command. The long form + * `{ approve: false }` is equivalent to a bare `false` unless it also opts into + * whole-command-line matching, which changes what the rule matches and cannot be + * expressed here. + */ +function isSubCommandDenial(value: AgentHostTerminalAutoApproveRuleValue): boolean { + if (value === false) { + return true; + } + return typeof value === 'object' && value !== null && value.approve === false && value.matchCommandLine !== true; +} + +/** + * Mirrors the auto-approver's own test for a regular-expression key, which + * requires the trailing `/` to be followed only by valid flags. A plain absolute + * path such as `/usr/bin/rm` is a literal there, so it stays a literal here. + */ +const AUTO_APPROVE_REGEX_KEY = /^\/.+\/[dgimsuvy]*$/; + +/** + * Whether a key is a literal command that carries the same meaning on both sides + * of the translation. + */ +function isLiteralCommandKey(command: string): boolean { + return !AUTO_APPROVE_REGEX_KEY.test(command) && !command.includes('*'); +} + /** Compatibility mappings for legacy settings only; new controls belong directly in the SDK. */ const managedPermissionsSettings: readonly IManagedPermissionsSettingMapping[] = [ - managedPermissionsSetting(GLOBAL_AUTO_APPROVE_SETTING_ID, (value, source) => source === 'policyValue' && value === false ? { disableBypassPermissionsMode: 'disable' } : undefined), - managedPermissionsSetting(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, value => value === false ? { ask: ['Shell'] } : undefined), + // Disabling the SDK's bypass mode takes "Allow All" away from the user for + // good, so only an administrator may drive it. + managedPermissionsSetting(GLOBAL_AUTO_APPROVE_SETTING_ID, 'policyOnly', value => value === false ? { disableBypassPermissionsMode: 'disable' } : undefined), + // Matches VS Code, where a user or application value already suppresses + // terminal auto-approval outright. + managedPermissionsSetting(TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, 'anyGlobal', value => value === false ? { ask: [buildManagedFamilyRule(ManagedRuleFamily.Shell)] } : undefined), + // The filter and its lists are evaluated together, and VS Code honors a user + // or application value for all three. + managedPermissionsCompositeSetting( + AgentNetworkDomainSettingId.NetworkFilter, + [AgentNetworkDomainSettingId.AllowedNetworkDomains, AgentNetworkDomainSettingId.DeniedNetworkDomains], + contributeNetworkDomainRules, + ), + // Mirrors the setting's own semantics, where a user or application value + // already forces approval for the matching command. + managedPermissionsSetting(TERMINAL_AUTO_APPROVE_SETTING_ID, 'anyGlobal', contributeTerminalDenialRules), ]; export const managedPermissionsConfigurationIds = [ AgentHostMapLegacySettingsToManagedSettingsSettingId, - ...managedPermissionsSettings.map(mapping => mapping.settingId), + ...managedPermissionsSettings.flatMap(mapping => [mapping.settingId, ...mapping.additionalSettingIds ?? []]), ]; export function isManagedSettingsPermissions(value: unknown): value is IAgentHostManagedSettingsPermissions { @@ -58,23 +262,45 @@ function isStringArrayOrUndefined(value: unknown): boolean { return value === undefined || (Array.isArray(value) && value.every(item => typeof item === 'string')); } +/** + * Combines every mapping's contribution into the single document sent to the + * host, deduplicating rules that more than one setting produced. + * + * Contributing any rule at all makes the runtime's managed policy "active", + * which causes unmatched shell, read, write, URL and factory requests to require + * approval. That is broader than any individual mapping intends, but it errs + * toward prompting, and the alternative — an `allow` list — resolves to + * auto-approval. See the module comment. + */ export function resolveManagedSettingsPermissions(configurationService: IConfigurationService): IAgentHostManagedSettingsPermissions { if (getGlobalConfigurationValue(configurationService, AgentHostMapLegacySettingsToManagedSettingsSettingId) !== true) { return {}; } - const permissions: IAgentHostManagedSettingsPermissions = {}; + const deny = new Set(); + const ask = new Set(); + let disableBypassPermissionsMode: 'disable' | undefined; for (const mapping of managedPermissionsSettings) { const contribution = mapping.contribute(configurationService); - if (contribution?.disableBypassPermissionsMode) { - permissions.disableBypassPermissionsMode = contribution.disableBypassPermissionsMode; + if (!contribution) { + continue; } - if (contribution?.deny) { - permissions.deny = [...permissions.deny ?? [], ...contribution.deny]; - } - if (contribution?.ask) { - permissions.ask = [...permissions.ask ?? [], ...contribution.ask]; + if (contribution.disableBypassPermissionsMode) { + disableBypassPermissionsMode = contribution.disableBypassPermissionsMode; } + contribution.deny?.forEach(rule => deny.add(rule)); + contribution.ask?.forEach(rule => ask.add(rule)); + } + + const permissions: IAgentHostManagedSettingsPermissions = {}; + if (disableBypassPermissionsMode) { + permissions.disableBypassPermissionsMode = disableBypassPermissionsMode; + } + if (deny.size > 0) { + permissions.deny = [...deny]; + } + if (ask.size > 0) { + permissions.ask = [...ask]; } return permissions; } diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts index 220568eacb5ed3..8cdee27d6daf9c 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetry.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts @@ -102,7 +102,7 @@ export function readClientTelemetryLevel(meta: Record | undefin } } -export function telemetryLevelToAgentHostValue(telemetryLevel: TelemetryLevel): TelemetryConfiguration { +export function telemetryLevelToAgentHostValue(telemetryLevel: TelemetryLevel | undefined): TelemetryConfiguration { switch (telemetryLevel) { case TelemetryLevel.NONE: return TelemetryConfiguration.OFF; @@ -112,6 +112,8 @@ export function telemetryLevelToAgentHostValue(telemetryLevel: TelemetryLevel): return TelemetryConfiguration.ERROR; case TelemetryLevel.USAGE: return TelemetryConfiguration.ON; + default: + return TelemetryConfiguration.OFF; } } diff --git a/src/vs/platform/agentHost/common/openSessionLink.ts b/src/vs/platform/agentHost/common/openSessionLink.ts index e82e5c9ce37abe..f806de687113c0 100644 --- a/src/vs/platform/agentHost/common/openSessionLink.ts +++ b/src/vs/platform/agentHost/common/openSessionLink.ts @@ -25,15 +25,17 @@ export const AGENT_HOST_SESSION_LINK_PATTERN = /^agent-host-session:\/\/[^/?#]+\ export type AgentSessionLinkStatus = 'untitled' | 'inProgress' | 'needsInput' | 'completed' | 'error'; -export function createAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus): ILinkPresentation { +export function createAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation { const presentationStatus = getAgentSessionLinkPresentationStatus(status); return { - kind: 'session', + kind, title, ...(description ? { detail: description } : {}), status: presentationStatus, tooltip: localize('agentSessionLink.tooltip', "{0} · {1}", title, presentationStatus.label), - ariaLabel: localize('agentSessionLink.ariaLabel', "Agent session {0}, {1}", title, presentationStatus.label), + ariaLabel: kind === 'chat' + ? localize('agentChatLink.ariaLabel', "Agent chat {0}, {1}", title, presentationStatus.label) + : localize('agentSessionLink.ariaLabel', "Agent session {0}, {1}", title, presentationStatus.label), }; } @@ -124,7 +126,12 @@ export function parseOpenSessionLinkChatId(uri: URI | string): string | undefine return undefined; } const match = /(?:^|&)chat=([^&]+)/.exec(parsed.query); - const chatId = match ? decodeURIComponent(match[1]) : undefined; + let chatId: string | undefined; + try { + chatId = match ? decodeURIComponent(match[1]) : undefined; + } catch { + return undefined; + } return chatId === DEFAULT_CHAT_ID ? undefined : chatId; } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 025bd2e0a0aeb4..fb2312213280cd 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -15,10 +15,12 @@ import { getDelayedChannel, IChannelClient, IChannelServer, ProxyChannel } from import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort, MessagePortAcquisitionError } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; import { ipcRenderer } from '../../../base/parts/sandbox/electron-browser/globals.js'; +import { localize } from '../../../nls.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; +import { INotificationService } from '../../notification/common/notification.js'; import { AgentHostIpcChannelTransport } from '../browser/agentHostIpcChannelTransport.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../browser/remoteAgentHostProtocolClient.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; @@ -64,6 +66,13 @@ import type { ComponentToState, RootState, StateComponents } from '../common/sta const LOG_PREFIX = '[AgentHost:renderer]'; +function notifyOnFatalAgentHostStartError(notificationService: INotificationService): void { + notificationService.error(localize( + 'agentHost.startFailed', + "The Agent Host failed to start. Restart the application to try again. See the logs for details." + )); +} + /** * Keeps management-channel calls on the same MessagePort generation as the * connected AHP transport. @@ -165,6 +174,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService environmentService: IEnvironmentService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @INotificationService private readonly _notificationService: INotificationService, ) { super(); this._ahpLogger = this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) @@ -206,6 +216,11 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos this._clientInfo, )); this._register(this._protocolClient.onDidChangeConnectionState(state => this._handleConnectionState(state))); + this._register(this._protocolClient.onDidFatalClose(() => { + if (!this._didConnectInitially) { + notifyOnFatalAgentHostStartError(this._notificationService); + } + })); } void this._connect().catch(error => { diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index f967281ce1aab8..cfe6a999fb2d74 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -20,7 +20,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; -import { AgentHostStartError, IAgentHostConnection, IAgentHostShutdownRequest, IAgentHostStarter, IAgentHostStartRequest } from '../common/agent.js'; +import { AgentHostStartError, IAgentHostConnection, IAgentHostShutdownRequest, IAgentHostStarter, IAgentHostStartRequest, isFatalAgentHostStartError, toFatalAgentHostStartError } from '../common/agent.js'; import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js'; import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar, telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostIpcChannels, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, AgentHostRestartIpcChannel, AgentHostWillRestartIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostManagementService, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; @@ -218,6 +218,9 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt }; } catch (error) { this._disposeUtilityProcess(utilityProcess); + if (isFatalAgentHostStartError(error)) { + throw toFatalAgentHostStartError(error); + } throw error; } } diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index e1086f977455bd..bea1d2416b5016 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -142,7 +142,7 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)): void { + turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)): void { const key = this._key(session, turnId); this._turnTimings.set(key, { stopWatch: StopWatch.create(false), @@ -151,7 +151,7 @@ export class AgentHostTurnTracker extends Disposable { turnId, model, modelTelemetryKind, - modelSelectionKind: model === undefined ? 'default' : model === 'auto' ? 'auto' : 'explicit', + modelSelectionKind, permissionLevel, interactionMode, clientContext, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8f95a5d41687e9..11f8d6b7e91d8f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -119,6 +119,8 @@ import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; const SESSION_GC_GRACE_MS = 30_000; const DAY_MS = 24 * 60 * 60 * 1000; const RECENT_EXTERNAL_SESSION_LIMIT = 2; +/** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */ +const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000; type AgentHostLegacyMigrationEvent = { provider: string; @@ -601,6 +603,7 @@ export class AgentService extends Disposable implements IAgentService { if (nextMode !== externalSessionsMode) { const previousMode = externalSessionsMode; externalSessionsMode = nextMode; + this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`); this._queueSessionListReconciliation(previousMode); } // Agent Merge tools are only advertised while the feature is on, so a @@ -1303,12 +1306,18 @@ export class AgentService extends Disposable implements IAgentService { private async _awaitInitialProviderMigration(): Promise { const providers = [...this._providers.values()]; const results = await Promise.allSettled(providers.map(provider => this._initialProviderMigrations.get(provider.id) ?? Promise.resolve())); + const retries: Promise[] = []; for (let index = 0; index < results.length; index++) { const result = results[index]; if (result.status === 'rejected') { - this._logService.warn(`[AgentService] initial provider catalogs: provider ${providers[index].id} failed and will be retried on the next signal`, result.reason); + const provider = providers[index]; + this._logService.warn(`[AgentService] initial provider catalog for ${provider.id} was unavailable; retrying before listing sessions`, result.reason); + const retry = this._ensureLegacyChatsMigrated(provider, true); + this._initialProviderMigrations.set(provider.id, retry); + retries.push(retry); } } + await Promise.all(retries); } /** @@ -1471,7 +1480,7 @@ export class AgentService extends Disposable implements IAgentService { } const sessions = await this._enumerateLegacyProviderSessions(provider); if (sessions === undefined) { - return; + throw new Error(`Provider ${provider.id} cannot enumerate its native session catalog yet`); } const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const migrationLimiter = new Limiter(4); @@ -1623,6 +1632,7 @@ export class AgentService extends Disposable implements IAgentService { private async _computeSessions(mode: AgentHostExternalSessionsMode): Promise { this._logService.trace('[AgentService] listSessions computation started'); + const startedAt = Date.now(); // The first list waits for registration-time legacy migration if it is still in flight. await this._awaitInitialProviderMigration(); // The registry is the source of truth for top-level sessions. Internal @@ -1833,7 +1843,14 @@ export class AgentService extends Disposable implements IAgentService { } this._logHiddenSessions(hiddenByExternalMode, combined.length, mode); - this._logService.trace(`[AgentService] listSessions returned ${visible.length} sessions (${additions.length} state-manager fallback)`); + // A catalog pass opens every registered session's database, so it can be slow. + const duration = Date.now() - startedAt; + const message = `[AgentService] listSessions computed ${visible.length} of ${combined.length} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`; + if (duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { + this._logService.info(message); + } else { + this._logService.trace(message); + } return visible; } @@ -1976,16 +1993,13 @@ export class AgentService extends Disposable implements IAgentService { } private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { + const startedAt = Date.now(); const previouslyBroadcast = new Set(this._broadcastExternalSessions); - if (previousMode !== undefined) { - for (const session of await this.listSessions(previousMode)) { - if (readSessionExternal(session._meta)) { - previouslyBroadcast.add(session.session.toString()); - } - } - } - const listed = await this.listSessions(); + const listed = previousMode !== undefined + ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.All), previousMode, previouslyBroadcast) + : await this.listSessions(); const visible = new Set(); + let published = 0; for (const metadata of listed) { if (!readSessionExternal(metadata._meta)) { continue; @@ -1993,6 +2007,7 @@ export class AgentService extends Disposable implements IAgentService { const key = metadata.session.toString(); visible.add(key); if (!previouslyBroadcast.has(key)) { + published++; if (this._stateManager.getSessionState(key)) { this._stateManager.setSessionSummaryPublished(key, true); } else { @@ -2003,8 +2018,10 @@ export class AgentService extends Disposable implements IAgentService { } } } + let retracted = 0; for (const key of previouslyBroadcast) { if (!visible.has(key)) { + retracted++; if (this._stateManager.getSessionState(key)) { this._stateManager.setSessionSummaryPublished(key, false); } else { @@ -2017,6 +2034,45 @@ export class AgentService extends Disposable implements IAgentService { for (const key of visible) { this._broadcastExternalSessions.add(key); } + const duration = Date.now() - startedAt; + const message = `[AgentService] External session reconciliation done in ${duration}ms (mode: '${this._getExternalSessionsMode()}'${previousMode !== undefined ? `, previous: '${previousMode}'` : ''}): ${published} published, ${retracted} retracted, ${visible.size} visible`; + // A prompt no-op pass is steady-state noise. + if (published > 0 || retracted > 0 || duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { + this._logService.info(message); + } else { + this._logService.trace(message); + } + } + + /** + * Derives both the previous and current mode's visible sets from one catalog + * pass, since {@link AgentHostExternalSessionsMode.All} is a superset of every + * mode and the mode is just a parameter to {@link _shouldIncludeSession}. + * Adds what `previousMode` had published into `previouslyBroadcast`. + */ + private _resolveModeChangeVisibility( + superset: readonly IAgentSessionMetadata[], + previousMode: AgentHostExternalSessionsMode, + previouslyBroadcast: Set, + ): IAgentSessionMetadata[] { + const now = this._now(); + const recentKeysFor = (mode: AgentHostExternalSessionsMode) => mode === AgentHostExternalSessionsMode.Recent + ? this._getRecentSessionKeys(superset, now) + : undefined; + + const previousRecentKeys = recentKeysFor(previousMode); + for (const session of superset) { + if (readSessionExternal(session._meta) && this._shouldIncludeSession(session, previousMode, now, previousRecentKeys)) { + previouslyBroadcast.add(session.session.toString()); + } + } + + const mode = this._getExternalSessionsMode(); + const recentKeys = recentKeysFor(mode); + const visible = superset.filter(session => this._shouldIncludeSession(session, mode, now, recentKeys)); + // The pass ran as `All`, so report the mode actually in effect instead. + this._logHiddenSessions(superset.length - visible.length, superset.length, mode); + return visible; } private async _announceSurfacedSession(meta: IAgentSessionMetadata, provider: string): Promise { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 1f2fdfecdcbb21..648cac93a03ad0 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1263,7 +1263,7 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(parentSessionUri); if (agent) { - this._turnTracker.turnStarted(agent.id, subagentChatUri, turnId, undefined, undefined, undefined, undefined, parentClientContext); + this._turnTracker.turnStarted(agent.id, subagentChatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1337,7 +1337,7 @@ export class AgentSideEffects extends Disposable { }); const agent = this._options.getAgent(subagent.sessionUri); if (agent) { - this._turnTracker.turnStarted(agent.id, subagent.chatUri, turnId, undefined, undefined, undefined, undefined, parentClientContext); + this._turnTracker.turnStarted(agent.id, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, undefined, parentClientContext); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); @@ -1605,8 +1605,8 @@ export class AgentSideEffects extends Disposable { } const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, clientId, clientContext, channel, action.turnId, state, 'direct', attachments); - const { model, modelTelemetryKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, state, action.message.model?.id); - this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel, interactionMode, clientContext); + const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), state, action.message.model?.id); + this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext); void this._sendTurnMessage({ agent, sessionChannel, @@ -2059,8 +2059,8 @@ export class AgentSideEffects extends Disposable { const attachments = msg.message.attachments; const queuedState = this._stateManager.getSessionState(session); this._telemetryReporter.userMessageSent(agent.id, sender.clientId, sender.clientContext, session, turnId, queuedState, 'queued', attachments); - const { model, modelTelemetryKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id); - this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel, interactionMode, sender.clientContext); + const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = this._getTurnTelemetryContext(agent, session, this._chatContext(sessionChannel, session), queuedState, msg.message.model?.id); + this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, sender.clientContext); // Selection travels on the queued message; it is applied before sending. void this._sendTurnMessage({ agent, @@ -2076,14 +2076,16 @@ export class AgentSideEffects extends Disposable { } - private _getTurnTelemetryContext(agent: IAgent, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; permissionLevel: string | undefined; interactionMode: SessionMode | undefined } { + private _getTurnTelemetryContext(agent: IAgent, chat: ProtocolURI, context: IAgentChatContext, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; modelSelectionKind: 'default' | 'auto' | 'explicit'; permissionLevel: string | undefined; interactionMode: SessionMode | undefined } { const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove]; const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined; const interactionMode = getConfiguredSessionMode(state?.config); - const modelContext = modelId === undefined + const modelSelectionKind = modelId === undefined ? 'default' : modelId === 'auto' ? 'auto' : 'explicit'; + const effectiveModelId = modelId ?? agent.chats.getModel?.(URI.parse(chat), context)?.id; + const modelContext = effectiveModelId === undefined || (modelId === undefined && effectiveModelId === 'auto') ? { model: undefined, modelTelemetryKind: undefined } - : this._getModelTelemetryContext(agent, modelId); - return { ...modelContext, permissionLevel, interactionMode }; + : this._getModelTelemetryContext(agent, effectiveModelId); + return { ...modelContext, modelSelectionKind, permissionLevel, interactionMode }; } private _getModelTelemetryContext(agent: IAgent, modelId: string): { model: string; modelTelemetryKind: AgentHostModelTelemetryKind } { diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index d70f694dcc50cc..026930e22311c2 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -1111,6 +1111,7 @@ export class ClaudeAgent extends Disposable implements IAgent { abort: (chatUri, context) => { return this._abortSession(chatUri, context); }, + getModel: chatUri => this._chatBackings.get(chatUri.toString())?.model, changeModel: (chatUri, model, context) => { return this._changeModel(chatUri, model, context); }, diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index d44618a8ce3827..d59cf326ea39c1 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -3372,6 +3372,10 @@ export class CodexAgent extends Disposable implements IAgent { abort: (chat: URI, context: URI | IAgentChatContext): Promise => { return this._abort(chat, context); }, + getModel: (chat: URI, context: URI | IAgentChatContext): ModelSelection | undefined => { + const session = this._resolveConversationSession(chat, context); + return session ? this._sessions.get(AgentSession.id(session))?.model : undefined; + }, changeModel: (chat: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { return this._changeModel(chat, model, context); }, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 85aa9ed153c06a..c81dff8328039c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2701,6 +2701,7 @@ export class CopilotAgent extends Disposable implements IAgent { abort: (chatUri: URI, context: URI | IAgentChatContext): Promise => { return this._abortSession(chatUri, context); }, + getModel: (chatUri: URI): ModelSelection | undefined => this._chatBackings.get(chatUri.toString())?.model, changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { return this._changeModel(chatUri, model, context); }, @@ -2867,7 +2868,7 @@ export class CopilotAgent extends Disposable implements IAgent { project, workspaceless: isWorkspaceless, }); - this._chatBackings.set(chat.toString(), { sdkSessionId }); + this._chatBackings.set(chat.toString(), { sdkSessionId, ...(options.model ? { model: options.model } : {}) }); } this._logService.info(`[Copilot] Chat created; its backing stays deferred until the first send: ${session.toString()}`); diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index b35bbc0f42299b..ccb8f56aca0f19 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -5,6 +5,7 @@ import type { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; @@ -49,7 +50,7 @@ const listSessionsInputSchema: ToolDefinition['inputSchema'] = { items: { type: 'string', enum: [...listSessionsStatusValues] }, description: 'Only return sessions whose status matches one of these (e.g. `inputNeeded` for sessions awaiting a reply, `inProgress` for running ones, `archived` for sessions marked Done/completed — implies `includeArchived`). Omit to return every status.', }, - workspace: { type: 'string', description: 'Only return sessions whose working directory is this folder — an absolute path or a workspace URI.' }, + workspace: { type: 'string', description: 'Only return sessions for this project name, project URI, or working directory path/URI.' }, withChanges: { type: 'boolean', description: 'When true, only return sessions that have pending worktree changes.' }, unread: { type: 'boolean', description: 'When true, only return sessions with updates the user has not seen yet.' }, withPullRequest: { type: 'boolean', description: 'When true, only return sessions that have a linked GitHub pull request.' }, @@ -62,7 +63,7 @@ const listSessionsInputSchema: ToolDefinition['inputSchema'] = { const createSessionInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: { - workspace: { type: 'string', description: 'Absolute folder path, workspace URI, or a working directory from an existing session.' }, + workspace: { type: 'string', description: 'Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model.' }, }, @@ -271,8 +272,12 @@ interface ISerializedSession { /** Human-readable description of what the session is currently doing. */ readonly activity?: string; readonly workingDirectory?: string; + /** Every working-directory URI when the session has more than one. */ + readonly workingDirectories?: readonly string[]; /** Display name of the session's project/workspace. */ readonly project?: string; + /** Configured project root URI, which may differ from a transient working directory. */ + readonly projectUri?: string; /** `true` when the session has updates the user has not yet seen. */ readonly unread?: boolean; /** ISO-8601 timestamp of when the session was created. */ @@ -365,15 +370,30 @@ function parseWorkspaceUri(workspace: string): URI | undefined { } function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMetadata[]): URI { + const parsed = parseWorkspaceUri(workspace); for (const session of sessions) { - const match = session.workingDirectories?.find(d => d.toString() === workspace || d.fsPath === workspace); - if (match) { - return match; + for (const candidate of [session.project?.uri, ...(session.workingDirectories ?? [])]) { + if (candidate && parsed && isEqual(candidate, parsed)) { + return candidate; + } } } - const parsed = parseWorkspaceUri(workspace); + + const projects: { readonly uri: URI; readonly displayName: string }[] = []; + for (const session of sessions) { + const project = session.project; + if (project?.displayName.toLowerCase() === workspace.toLowerCase() && !projects.some(candidate => isEqual(candidate.uri, project.uri))) { + projects.push(project); + } + } + if (projects.length === 1) { + return projects[0].uri; + } + if (projects.length > 1) { + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: workspace "${workspace}" is ambiguous; use one of these project URIs: ${projects.map(project => project.uri.toString()).join(', ')}.`); + } if (!parsed) { - throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: workspace must match a known session workingDirectory, an absolute path, or a valid URI string.`); + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: workspace must match a unique known project name, project URI, working directory, absolute path, or valid URI string.`); } return parsed; } @@ -529,19 +549,14 @@ function sessionIsUnread(session: IAgentSessionMetadata): boolean { return session.status !== undefined && !isSessionStatusRead(session.status); } -/** Whether any of a session's working directories matches the given folder (absolute path or URI). */ +/** Whether a session's project or any working directory matches the given workspace selector. */ function sessionMatchesWorkspace(session: IAgentSessionMetadata, workspace: string): boolean { - const dirs = session.workingDirectories; - if (!dirs || dirs.length === 0) { - return false; + if (session.project?.displayName.toLowerCase() === workspace.toLowerCase()) { + return true; } const parsed = parseWorkspaceUri(workspace); - // Any-root membership: a session matches when the folder is any of its - // working directories, not only the primary. - return dirs.some(dir => - dir.toString() === workspace - || dir.fsPath === workspace - || (!!parsed && parsed.toString() === dir.toString())); + return parsed !== undefined + && [session.project?.uri, ...(session.workingDirectories ?? [])].some(candidate => candidate !== undefined && isEqual(candidate, parsed)); } /** Applies the {@link IListSessionsArgs} filters to a set of sessions. */ @@ -624,7 +639,11 @@ function serializeSession(session: IAgentSessionMetadata): ISerializedSession { ...(status !== undefined ? { status } : {}), ...(session.activity !== undefined ? { activity: session.activity } : {}), ...(session.workingDirectories?.[0] !== undefined ? { workingDirectory: session.workingDirectories[0].toString() } : {}), + ...(session.workingDirectories !== undefined && session.workingDirectories.length > 1 + ? { workingDirectories: session.workingDirectories.map(directory => directory.toString()) } + : {}), ...(session.project !== undefined ? { project: session.project.displayName } : {}), + ...(session.project !== undefined ? { projectUri: session.project.uri.toString() } : {}), ...(sessionIsUnread(session) ? { unread: true } : {}), ...(session.startTime > 0 ? { createdAt: new Date(session.startTime).toISOString() } : {}), ...(session.modifiedTime > 0 ? { modifiedAt: new Date(session.modifiedTime).toISOString() } : {}), diff --git a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts index d2fa3796d9d716..2ae3b0ef95c0f9 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts @@ -6,11 +6,13 @@ import assert from 'assert'; import { autorun } from '../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { Emitter } from '../../../../base/common/event.js'; import { AgentHostEnablementService } from '../../browser/agentHostEnablementService.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../common/agentHostEnablementService.js'; import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationOverrides } from '../../../configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../../chat/common/chatSettings.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../policy/common/copilotManagedSettings.js'; import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; class AgentHostTestConfigurationService extends TestConfigurationService { @@ -41,7 +43,7 @@ class AgentHostTestConfigurationService extends TestConfigurationService { suite('AgentHostEnablementService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(aiDisabled = false, runtimeAvailable = true): { + function createService(aiDisabled = false, runtimeAvailable = true, managedSettingsService: IManagedSettingsService = new NullManagedSettingsService()): { readonly service: AgentHostEnablementService; readonly configurationService: AgentHostTestConfigurationService; readonly contextKeyService: MockContextKeyService; @@ -49,7 +51,12 @@ suite('AgentHostEnablementService', () => { const configurationService = new AgentHostTestConfigurationService(aiDisabled); disposables.add(configurationService.onDidChangeConfigurationEmitter); const contextKeyService = disposables.add(new MockContextKeyService()); - const service = disposables.add(new AgentHostEnablementService(runtimeAvailable, configurationService, contextKeyService)); + const service = disposables.add(new AgentHostEnablementService( + runtimeAvailable, + configurationService, + contextKeyService, + managedSettingsService, + )); return { service, configurationService, contextKeyService }; } @@ -105,4 +112,31 @@ suite('AgentHostEnablementService', () => { }); }); + test('tracks the effective managed sandbox floor', () => { + let sandboxEnabled = false; + const managedSettingsEmitter = disposables.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsEmitter.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ENABLED_KEY ? sandboxEnabled : undefined, + }; + + const { service } = createService(false, true, managedSettingsService); + const changes: boolean[] = []; + disposables.add(autorun(reader => changes.push(service.managedSandboxEnforced.read(reader)))); + + sandboxEnabled = true; + managedSettingsEmitter.fire(); + sandboxEnabled = false; + managedSettingsEmitter.fire(); + + assert.deepStrictEqual({ + enforced: service.managedSandboxEnforced.get(), + changes, + }, { + enforced: false, + changes: [false, true, false], + }); + }); + }); diff --git a/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts b/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts index 0a72be33801e46..cef88f67fda5ce 100644 --- a/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts @@ -8,12 +8,14 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { IConfigurationService, IConfigurationValue } from '../../../configuration/common/configuration.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../configuration/common/configurationRegistry.js'; import { Registry } from '../../../registry/common/platform.js'; -import { getAgentHostConfigurationSyncEntries, getGlobalConfigurationValue, inspectValue, resolveAgentHostConfigurationSyncPatch } from '../../common/agentHostConfigurationSync.js'; +import { formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, getGlobalConfigurationValue, inspectValue, resolveAgentHostConfigurationSyncPatch } from '../../common/agentHostConfigurationSync.js'; const ALL_HOSTS_SETTING = 'test.agentHostSync.allHosts'; const LOCAL_ONLY_SETTING = 'test.agentHostSync.localOnly'; const HIDDEN_SETTING = 'test.agentHostSync.hidden'; const UNSYNCED_SETTING = 'test.agentHostSync.unsynced'; +const ENUM_SETTING = 'test.agentHostSync.enum'; +const FREEFORM_SETTING = 'test.agentHostSync.freeform'; /** * Stands in for `IConfigurationService` with per-layer control over `inspect`, @@ -58,6 +60,17 @@ suite('AgentHostConfigurationSync', () => { type: 'boolean' as const, default: true, }, + [ENUM_SETTING]: { + type: 'string' as const, + enum: ['none', 'all'], + default: 'none', + agentHost: { key: 'enumValue' }, + }, + [FREEFORM_SETTING]: { + type: 'string' as const, + default: '', + agentHost: { key: 'freeformValue' }, + }, }, }; @@ -116,6 +129,17 @@ suite('AgentHostConfigurationSync', () => { ]); }); + test('formats closed-set values for logging and redacts everything else', () => { + assert.deepStrictEqual([ + formatAgentHostConfigurationSyncValueForLog(ALL_HOSTS_SETTING, true), + formatAgentHostConfigurationSyncValueForLog(ENUM_SETTING, 'all'), + formatAgentHostConfigurationSyncValueForLog(ENUM_SETTING, 'c:\\Users\\someone\\secret'), + formatAgentHostConfigurationSyncValueForLog(FREEFORM_SETTING, 'c:\\Users\\someone\\secret'), + formatAgentHostConfigurationSyncValueForLog(UNSYNCED_SETTING, { '**/secret/**': true }), + formatAgentHostConfigurationSyncValueForLog(UNSYNCED_SETTING, ['c:\\Users\\someone']), + ], ['true', 'all', '', '', '', '']); + }); + test('builds a patch applying transforms, including for hidden settings', () => { const configurationService = createConfigurationService({ [ALL_HOSTS_SETTING]: { defaultValue: true }, diff --git a/src/vs/platform/agentHost/test/common/agentHostManagedRules.test.ts b/src/vs/platform/agentHost/test/common/agentHostManagedRules.test.ts new file mode 100644 index 00000000000000..906525d0993f5c --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostManagedRules.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { buildManagedFamilyRule, buildManagedRule, ManagedRuleFamily } from '../../common/agentHostManagedRules.js'; + +suite('AgentHostManagedRules', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('builds family rules that match every request in the family', () => { + assert.strictEqual(buildManagedFamilyRule(ManagedRuleFamily.Shell), 'Shell'); + assert.strictEqual(buildManagedFamilyRule(ManagedRuleFamily.Domain), 'Domain'); + }); + + test('builds shell rules including the command-boundary wildcard form', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Shell, 'npm run build'), 'Shell(npm run build)'); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Shell, 'git *'), 'Shell(git *)'); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Shell, '*'), 'Shell(*)'); + }); + + test('rejects a wildcard shell rule with no command prefix', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Shell, ' *'), undefined); + }); + + test('rejects arguments that would truncate the rule parse', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Shell, 'echo (hi)'), undefined); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Read, 'src/**/*)'), undefined); + }); + + test('builds path rules and normalizes windows separators', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Write, '**/*.json'), 'Write(**/*.json)'); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Read, 'src\\**'), 'Read(src/**)'); + assert.strictEqual( + buildManagedRule(ManagedRuleFamily.Write, '**/*.{csproj,props}'), + 'Write(**/*.{csproj,props})', + ); + }); + + test('rejects path patterns the runtime glob engine cannot compile', () => { + // Negation is legal in VS Code globs but has no equivalent in the runtime, + // where it would fail validation and reject the whole document. + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Write, '!**/*.json'), undefined); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Write, '**/*.{a,b'), undefined); + }); + + test('builds domain rules for hosts and subdomain wildcards', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Domain, 'example.com'), 'Domain(example.com)'); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Domain, '*.example.com'), 'Domain(*.example.com)'); + }); + + test('rejects a bare wildcard domain in favor of the family rule', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Domain, '*'), undefined); + }); + + test('rejects domains the runtime url normalizer refuses', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Domain, '$(curl evil.com)'), undefined); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Domain, 'no//scheme'), undefined); + }); + + test('rejects empty and whitespace-only arguments', () => { + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Shell, ''), undefined); + assert.strictEqual(buildManagedRule(ManagedRuleFamily.Domain, ' '), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts b/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts index 6eb5841390d303..c303ea09a676d1 100644 --- a/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostManagedSettings.test.ts @@ -7,7 +7,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationService, IConfigurationValue } from '../../../configuration/common/configuration.js'; import { AgentHostMapLegacySettingsToManagedSettingsSettingId, resolveManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; -import { GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID } from '../../common/agentHostSchema.js'; +import { AgentNetworkDomainSettingId } from '../../../networkFilter/common/settings.js'; +import { GLOBAL_AUTO_APPROVE_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID } from '../../common/agentHostSchema.js'; function createConfigurationService(values: Record>): IConfigurationService { return { @@ -75,4 +76,167 @@ suite('AgentHostManagedSettings', () => { assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); }); + + test('deduplicates a rule that more than one entry produces', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false, policyValue: true }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { defaultValue: [] }, + // Three spellings of the same host, which all normalize to one rule. + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { + defaultValue: [], + policyValue: ['evil.example', 'https://evil.example/path', 'evil.example:8443'], + }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + deny: ['Domain(evil.example)'], + }); + }); + + test('reduces denied domains to the host the network filter matches on', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false, policyValue: true }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { defaultValue: [] }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { + defaultValue: [], + policyValue: ['https://blocked.example/some/path', 'ported.example:8443', '*.wild.example'], + }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + deny: ['Domain(blocked.example)', 'Domain(ported.example)', 'Domain(*.wild.example)'], + }); + }); + + test('denies configured domains while the network filter is on', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false, policyValue: true }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { defaultValue: [], policyValue: ['evil.com', '*.tracker.example'] }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { defaultValue: [], policyValue: ['github.com'] }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + deny: ['Domain(evil.com)', 'Domain(*.tracker.example)'], + }); + }); + + test('denies every domain when the filter is on and neither list is configured', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false, policyValue: true }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { defaultValue: [] }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { defaultValue: [] }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { deny: ['Domain'] }); + }); + + test('contributes nothing from domain lists while the network filter is off', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { defaultValue: [], policyValue: ['evil.com'] }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), {}); + }); + + test('skips denied domain patterns the SDK cannot express', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false, policyValue: true }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { defaultValue: [], policyValue: ['$(evil)', 'ok.example'] }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { defaultValue: [] }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + deny: ['Domain(ok.example)'], + }); + }); + + test('maps a bare wildcard denial onto the all-domains family rule', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [AgentNetworkDomainSettingId.NetworkFilter]: { defaultValue: false, policyValue: true }, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { defaultValue: [], policyValue: ['*'] }, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { defaultValue: [] }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { deny: ['Domain'] }); + }); + + test('requires approval for explicitly denied terminal commands', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { + defaultValue: {}, + policyValue: { rm: false, 'git push': false, npm: true }, + }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + ask: ['Shell(rm)', 'Shell(git push)'], + }); + }); + + test('skips terminal denials the SDK shell grammar cannot express', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { + defaultValue: {}, + policyValue: { + '/^rm\\s/i': false, + 'curl': { approve: false, matchCommandLine: true }, + 'wget': false, + }, + }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + ask: ['Shell(wget)'], + }); + }); + + test('keeps an absolute command path that VS Code treats as a literal', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + // Starts and ends with `/` but the trailing segment is not a flag list, + // so the auto-approver reads it as a path rather than a regular expression. + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { defaultValue: {}, policyValue: { '/usr/bin/rm': false } }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + ask: ['Shell(/usr/bin/rm)'], + }); + }); + + test('skips a wildcard command key rather than broadening it', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + // `*` is a literal in VS Code but a command-boundary wildcard in the SDK, + // so bridging this would require approval for every git command. + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { defaultValue: {}, policyValue: { 'git *': false, 'rm': false } }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + ask: ['Shell(rm)'], + }); + }); + + test('treats a long-form sub-command denial like a bare false', () => { + const configurationService = createConfigurationService({ + [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { defaultValue: false, userValue: true }, + [TERMINAL_AUTO_APPROVE_SETTING_ID]: { + defaultValue: {}, + policyValue: { rm: { approve: false }, ls: { approve: true } }, + }, + }); + + assert.deepStrictEqual(resolveManagedSettingsPermissions(configurationService), { + ask: ['Shell(rm)'], + }); + }); }); diff --git a/src/vs/platform/agentHost/test/common/agentHostTelemetry.test.ts b/src/vs/platform/agentHost/test/common/agentHostTelemetry.test.ts new file mode 100644 index 00000000000000..3a681d0d755a45 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostTelemetry.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TelemetryConfiguration, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { telemetryLevelToAgentHostValue } from '../../common/agentHostTelemetry.js'; + +suite('AgentHostTelemetry', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('telemetryLevelToAgentHostValue always produces a launch argument', () => { + assert.deepStrictEqual([ + telemetryLevelToAgentHostValue(TelemetryLevel.USAGE), + telemetryLevelToAgentHostValue(TelemetryLevel.ERROR), + telemetryLevelToAgentHostValue(TelemetryLevel.CRASH), + telemetryLevelToAgentHostValue(TelemetryLevel.NONE), + telemetryLevelToAgentHostValue(undefined), + ], [ + TelemetryConfiguration.ON, + TelemetryConfiguration.ERROR, + TelemetryConfiguration.CRASH, + TelemetryConfiguration.OFF, + TelemetryConfiguration.OFF, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts index c944199643c42c..430b75d80de2d5 100644 --- a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts +++ b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts @@ -56,6 +56,7 @@ suite('openSessionLink', () => { test('parseOpenSessionLinkChatId treats chat=default as absent', () => { assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=default'), undefined); assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=peer1'), 'peer1'); + assert.strictEqual(parseOpenSessionLinkChatId('agent-host-session://copilotcli/abc-123?chat=%ZZ'), undefined); }); test('buildOpenSessionLinkForChatResource maps chat resources to session links', () => { @@ -78,9 +79,11 @@ suite('openSessionLink', () => { }); test('creates generic link presentations for agent sessions', () => { - assert.deepStrictEqual( - createAgentSessionLinkPresentation('Implement rich links', 'Updating core', 'needsInput'), - { + assert.deepStrictEqual({ + session: createAgentSessionLinkPresentation('Implement rich links', 'Updating core', 'needsInput'), + chat: createAgentSessionLinkPresentation('Investigate tests', 'Updating core', 'completed', 'chat'), + }, { + session: { kind: 'session', title: 'Implement rich links', detail: 'Updating core', @@ -88,6 +91,14 @@ suite('openSessionLink', () => { tooltip: 'Implement rich links · Needs input', ariaLabel: 'Agent session Implement rich links, Needs input', }, - ); + chat: { + kind: 'chat', + title: 'Investigate tests', + detail: 'Updating core', + status: { kind: 'success', label: 'Completed' }, + tooltip: 'Investigate tests · Completed', + ariaLabel: 'Agent chat Investigate tests, Completed', + }, + }); }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts index 7ec4fb9487dbed..4328908b56f063 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -4,18 +4,39 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { constObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; import { IChannelClient, IChannelServer, IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IEnvironmentService } from '../../../environment/common/environment.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { INotificationService } from '../../../notification/common/notification.js'; +import { TestNotificationService } from '../../../notification/test/common/testNotificationService.js'; import { ITelemetryData } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; +import { isFatalAgentHostStartError, toFatalAgentHostStartError } from '../../common/agent.js'; import { AGENT_HOST_CLIENT_PROXY_CHANNEL } from '../../common/agentHostClientProxyChannel.js'; import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; -import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; +import { AgentHostClientType, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostStartupTelemetry } from '../../common/agentHostStartupTelemetry.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; -import { LocalAgentHostManagementConnection, registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; +import { ProtocolError } from '../../common/state/sessionProtocol.js'; +import { LocalAgentHostManagementConnection, LocalAgentHostServiceClient, registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; + +class CapturingNotificationService extends TestNotificationService { + readonly errors: (string | Error)[] = []; + + override error(error: string | Error) { + this.errors.push(error); + return super.error(error); + } +} class TestTelemetryService extends NullTelemetryServiceShape { readonly events: { eventName: string; data: ITelemetryData | undefined }[] = []; @@ -71,6 +92,72 @@ suite('registerAgentHostClientChannels', () => { assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_PROXY_CHANNEL, AGENT_HOST_CLIENT_BYOK_LM_CHANNEL]); }); + test('classifies only utility process validation errors as fatal', () => { + const originalError = new TypeError('Invalid value for args'); + originalError.stack = 'original stack'; + const fatalError = toFatalAgentHostStartError(originalError); + + assert.deepStrictEqual({ + classification: [ + isFatalAgentHostStartError(originalError), + isFatalAgentHostStartError(new TypeError('unrelated')), + isFatalAgentHostStartError(new Error('Invalid value for args')), + ], + fatal: fatalError.fatal, + name: fatalError.name, + stack: fatalError.stack, + }, { + classification: [true, false, false], + fatal: true, + name: 'TypeError', + stack: 'original stack', + }); + }); + + test('surfaces fatal startup only before the initial connection', () => { + const notifications = new CapturingNotificationService(); + const onDidChangeConnectionState = disposables.add(new Emitter()); + const onDidFatalClose = disposables.add(new Emitter()); + const protocolClient = { + clientId: 'test-client', + connect: () => Promise.resolve(), + onDidChangeConnectionState: onDidChangeConnectionState.event, + onDidFatalClose: onDidFatalClose.event, + initializeResult: constObservable(undefined), + rootState: { + value: undefined, + verifiedValue: undefined, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + dispose: () => { }, + }; + const startupTelemetry = { + protocolConnected: () => { }, + connectionFailed: () => { }, + dispose: () => { }, + }; + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IEnvironmentService, { logsHome: URI.file('/logs') } as Partial); + instantiationService.stub(INotificationService, notifications); + instantiationService.stubInstance(RemoteAgentHostProtocolClient, protocolClient); + instantiationService.stubInstance(AgentHostStartupTelemetry, startupTelemetry); + instantiationService.set(IInstantiationService, instantiationService); + const service = disposables.add(instantiationService.createInstance(LocalAgentHostServiceClient, editorWindowAgentHostClientInfo)); + service.startAgentHost(); + + onDidFatalClose.fire(new ProtocolError(-32000, 'fatal before connect')); + onDidChangeConnectionState.fire(AgentHostClientState.Connected); + onDidFatalClose.fire(new ProtocolError(-32000, 'fatal after connect')); + + assert.deepStrictEqual(notifications.errors, [ + 'The Agent Host failed to start. Restart the application to try again. See the logs for details.', + ]); + }); + suite('LocalAgentHostManagementConnection', () => { const client: IChannelClient = { diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 180161822ef987..b4db0e0cf44c46 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -1920,6 +1920,8 @@ suite('RemoteAgentHostProtocolClient', () => { test('does not retry a non-reconnectable initial transport failure', async () => { const { client, transports } = createFactoryClient(); + const fatalErrors: string[] = []; + disposables.add(client.onDidFatalClose(error => fatalErrors.push(error.message))); const connectPromise = client.connect(); transports[0].connectDeferred.error(new NonReconnectableTransportError('terminal failure')); @@ -1928,9 +1930,33 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual({ state: client.connectionState, transportCount: transports.length, + fatalErrors, }, { state: AgentHostClientState.Closed, transportCount: 1, + fatalErrors: ['terminal failure'], + }); + }); + + test('surfaces a non-reconnectable failure reached during initial reconnect', async function () { + this.timeout(10_000); + const { client, transports } = createFactoryClient(); + const fatalError = Event.toPromise(client.onDidFatalClose); + const connectPromise = client.connect(); + transports[0].connectDeferred.error(new Error('transient failure')); + await assert.rejects(connectPromise, /transient failure/); + + const reconnectTransport = await waitForTransport(transports, 1); + reconnectTransport.connectDeferred.error(new NonReconnectableTransportError('terminal failure')); + + assert.deepStrictEqual({ + fatalError: (await fatalError).message, + state: client.connectionState, + transportCount: transports.length, + }, { + fatalError: 'terminal failure', + state: AgentHostClientState.Closed, + transportCount: 2, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index a901f0cb38ee6d..9ad80f1698c7e0 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -424,7 +424,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { await runWithFakedTimers({}, async () => { for (const item of cases) { - tracker.turnStarted('mock', item.session, 'turn', undefined, undefined, undefined, undefined); + tracker.turnStarted('mock', item.session, 'turn', undefined, undefined, 'default', undefined, undefined); tracker.setCurrentStage(item.session, 'turn', item.stage); } await timeout(TURN_HANG_THRESHOLD_MS); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 992e93ec995154..e5e8699fc0ccd7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -319,6 +319,9 @@ suite('AgentSideEffects — turn tracker telemetry', () => { fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-byok', duration: 1000 }); startTurn('turn-unknown', 'hello', 'unadvertised/private-model'); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-unknown', duration: 1000 }); + agent.chatModel = { id: 'openrouter/private-model' }; + startTurn('turn-default'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-default', duration: 1000 }); assert.deepStrictEqual(completedEvents().map(event => { const data = event.data as Record; @@ -326,6 +329,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }), [ { model: 'byokModel', modelSelectionKind: 'explicit', isBYOK: true }, { model: 'unknown', modelSelectionKind: 'explicit', isBYOK: false }, + { model: 'byokModel', modelSelectionKind: 'default', isBYOK: true }, ]); }); @@ -350,6 +354,53 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); }); + test('uses the concrete provider default across turn outcomes while preserving Default selection', () => { + setupSession(); + agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); + agent.chatModel = { id: 'gpt-5.5' }; + + startTurn('turn-success'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-success', duration: 1000 }); + startTurn('turn-error'); + fire({ type: ActionType.ChatError, turnId: 'turn-error', duration: 1000, error: { errorType: 'oops', message: 'fail' } }); + startTurn('turn-cancelled'); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-cancelled', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { + model: capturedModel(data), + modelSelectionKind: data.modelSelectionKind, + result: data.result, + }; + }), [ + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'default', result: 'success' }, + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'default', result: 'error' }, + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'default', result: 'cancelled' }, + ]); + }); + + test('does not treat an Auto provider default as the effective model', () => { + setupSession(); + agent.setModels([ + { provider: 'mock', id: 'auto', name: 'Auto', supportsVision: false }, + { provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }, + ]); + agent.chatModel = { id: 'auto' }; + startTurn('turn-default'); + + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-default', duration: 1000 }); + + const data = completedEvents()[0].data as Record; + assert.deepStrictEqual({ + model: data.model, + modelSelectionKind: data.modelSelectionKind, + }, { + model: undefined, + modelSelectionKind: 'default', + }); + }); + test('timeToFirstProgress is undefined when no visible progress arrives before completion', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 652a32ea2a8867..c425b3ed923224 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2944,6 +2944,46 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('a mode change reconciles with a single catalog pass', async () => { + const day = 24 * 60 * 60 * 1000; + const now = Date.now(); + const svc = createExternalSessionService(() => now); + const agent = disposables.add(new TimedExternalAgent('copilot')); + agent.addSession('recent', now); + agent.addSession('yesterday', now - day); + agent.addSession('last-week', now - 6 * day); + svc.registerProvider(agent); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + await waitForSessionListReconciliation(svc); + + // Each `listSessions` is one walk over every registered session's + // database, so the modes it is asked for are the catalog passes. + const listedModes: (AgentHostExternalSessionsMode | undefined)[] = []; + const listSessions = svc.listSessions; + svc.listSessions = mode => { + listedModes.push(mode); + return Reflect.apply(listSessions, svc, [mode]); + }; + + // `Recent` is the mode whose visibility depends on the whole catalog, + // so it is the one most likely to regress into a second pass. + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 2); + // Await the transition's own reconciliation: publishing into `Recent` + // moves summaries, which queues a further pass of its own. + await (svc as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; + const transitionModes = [...listedModes]; + await waitForSessionListReconciliation(svc); + svc.listSessions = listSessions; + + assert.deepStrictEqual({ + transitionModes, + visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), + }, { + transitionModes: [AgentHostExternalSessionsMode.All], + visible: ['recent', 'yesterday'], + }); + }); + test('recent replaces the oldest visible external session when a newer session is discovered', async () => { const now = Date.now(); const svc = createExternalSessionService(() => now); @@ -3925,14 +3965,16 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.listExternalChatsCalls, 1); }); - test('a discovery signal does not bypass completed legacy migration semantics', async () => { + test('listSessions rejects an unavailable migration catalog and retries it on the next call', async () => { class NotYetMigratableAgent extends MockAgent { migrationCalls = 0; enumerable = false; } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); (agent as unknown as { listChatsToMigrate: () => Promise }).listChatsToMigrate = async () => { agent.migrationCalls++; return agent.enumerable @@ -3940,18 +3982,20 @@ suite('AgentService (node dispatcher)', () => { : undefined; }; svc.registerProvider(agent); - await svc.listSessions(); + await assert.rejects(svc.listSessions(), /cannot enumerate its native session catalog yet/); + const callsAfterFailure = agent.migrationCalls; agent.enumerable = true; - agent.fireDiscoveredChats([]); await timeout(0); - + const listed = await svc.listSessions(); assert.deepStrictEqual({ - migrationCalls: agent.migrationCalls, - registered: (await svc.getRegisteredSessions()).map(session => session.toString()), + retriedBeforeFailure: callsAfterFailure > 1, + retriedAfterFailure: agent.migrationCalls > callsAfterFailure, + listed: listed.map(session => session.session.toString()), }, { - migrationCalls: 1, - registered: [], + retriedBeforeFailure: true, + retriedAfterFailure: true, + listed: [legacy.toString()], }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 26f475cb9784b8..57d442dd69c15b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -6867,6 +6867,36 @@ suite('CopilotAgent', () => { } }); + test('getModel reports the creation model while the backing is still deferred', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await provisionSession(agent, { + session: AgentSession.uri('copilotcli', 'prov-default-model'), + model: { id: 'gpt-x' }, + workingDirectories: [URI.file('/workspace')], + }); + const chat = defaultChatUri(result.session); + const context = exactChatContext(result.session, chat, result.session); + + // The first turn's telemetry reads the bound model before the + // send materializes the session, so the reserved backing must + // already carry it. + const beforeSend = agent.chats.getModel?.(chat, context); + await agent.chats.sendMessage(chat, 'hello', undefined, undefined, undefined, undefined, context); + + assert.deepStrictEqual({ beforeSend, afterMaterialize: agent.chats.getModel?.(chat, context) }, { + beforeSend: { id: 'gpt-x' }, + afterMaterialize: { id: 'gpt-x' }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('disposeSession on provisional session does not touch SDK or worktree', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([]); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index cbcce373d693af..52211d26406d52 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -1189,7 +1189,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1236,7 +1236,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index 6b8e0eb482451e..679280c6660b2b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -1189,7 +1189,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1236,7 +1236,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index b2075303a3410b..efae17e7c1a104 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -1189,7 +1189,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1236,7 +1236,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 1c58fe6beaa920..4ef9d1dc41b894 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -1195,7 +1195,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1242,7 +1242,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md index e259348106fb29..43b3418fb5c629 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md @@ -1199,7 +1199,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1246,7 +1246,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 4080a77b5eed76..b9d6ad748e8e7c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -1199,7 +1199,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1246,7 +1246,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index 6795798c54fa14..d1210fdc7553b4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -1189,7 +1189,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1236,7 +1236,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index abff6c5d8cb9ab..555278099665e6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -1189,7 +1189,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1236,7 +1236,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index 19ac9055008308..08eb88a7241a70 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -1198,7 +1198,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1245,7 +1245,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 22ac6851450a40..150b7d521a02d4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -1234,7 +1234,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1281,7 +1281,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 61926a274aef78..90c9b93db52e2a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -1143,7 +1143,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1190,7 +1190,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index 6baf8fa74a08ff..c6b673bac18543 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -1181,7 +1181,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1228,7 +1228,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index 12d4fb8bd7c92c..4a04bb95a4ebe2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -1195,7 +1195,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1242,7 +1242,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index 3611812ed1e374..843a75e2a68e29 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -1143,7 +1143,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1190,7 +1190,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index a87df4aa0cd901..a500d910636bf5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -1143,7 +1143,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1190,7 +1190,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 3876bc5b2e32ea..aac467868c99a9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -1195,7 +1195,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1242,7 +1242,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index f188bfce32a076..921eccacdfa04e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -1157,7 +1157,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1204,7 +1204,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 4c153d125b4336..8d05588d224da3 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -1157,7 +1157,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1204,7 +1204,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index 59f14665e17d64..8fab5cdc8bd46d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -1157,7 +1157,7 @@ List sessions and their compact metadata (status, activity, working directory, p }, "workspace": { "type": "string", - "description": "Only return sessions whose working directory is this folder — an absolute path or a workspace URI." + "description": "Only return sessions for this project name, project URI, or working directory path/URI." }, "withChanges": { "type": "boolean", @@ -1204,7 +1204,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show "properties": { "workspace": { "type": "string", - "description": "Absolute folder path, workspace URI, or a working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." }, "prompt": { "type": "string", diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index bc8db48c3e91fd..e02b46afbb64dc 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -112,6 +112,7 @@ export class MockAgent implements IAgent { sessionMessages: IHistoryRecord[] = []; /** Usage stamped onto every reconstructed turn (e.g. an Auto-model stub). */ turnUsageOverride: UsageInfo | undefined = undefined; + chatModel: ModelSelection | undefined; /** Optional overrides applied to session metadata from listSessions. */ sessionMetadataOverrides: Partial> = {}; @@ -362,6 +363,7 @@ export class MockAgent implements IAgent { const { session } = this._resolveChatTarget(chat, context); return this.abortSession(session); }, + getModel: (): ModelSelection | undefined => this.chatModel, changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { this._recordContext('changeModel', chatUri, context); const { session, chat } = this._resolveChatTarget(chatUri, context); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts index a06029e1c1eef7..f059dd51b3e764 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts @@ -104,6 +104,7 @@ suite('Agent Host Provider Integration — Codex Customizations', function () { }); suiteTeardown(async function () { + this.timeout(60_000); await stopServer(server); await rm(userHomeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); }); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 3c920a9829c1ff..1c664a3059cf91 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -62,6 +62,7 @@ import { import { AhpSnapshotRecorder, type IAhpSnapshotNormalization, type IAhpSnapshotOptions } from './e2e/harness/ahpSnapshot.js'; import { recordAhpSurface } from './ahpSurfaceCoverage.js'; import { isCI, isWindows } from '../../../../base/common/platform.js'; +import { killTree } from '../../../../base/node/processes.js'; import { createIsolatedProviderEnvironment } from './providerTestEnvironment.js'; const AGENT_HOST_E2E_COVERAGE = process.env['AGENT_HOST_E2E_COVERAGE'] === '1'; @@ -649,10 +650,11 @@ export async function stopServer(server: IServerHandle | undefined): Promise true), SERVER_SHUTDOWN_TIMEOUT_MS)) { try { if (serverProcess.exitCode === null && serverProcess.signalCode === null) { - const killed = serverProcess.kill('SIGKILL'); - if (!killed && serverProcess.exitCode === null && serverProcess.signalCode === null) { - throw new Error('Failed to terminate Agent Host test server'); + const pid = serverProcess.pid; + if (pid === undefined) { + throw new Error('Agent Host test server has no process id'); } + await killTree(pid, true); } } catch (error) { if (serverProcess.exitCode === null && serverProcess.signalCode === null) { diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index d32edb3881268a..ea1e0215a9e541 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -209,6 +209,7 @@ suite('SessionServerTools', () => { activity: 'Running tests', workingDirectory: workspace.toString(), project: 'app', + projectUri: workspace.toString(), unread: true, modifiedAt: new Date(1700000000000).toISOString(), changes: { files: 1, additions: 2, deletions: 0 }, @@ -218,6 +219,27 @@ suite('SessionServerTools', () => { }); }); + test('serializeSessions preserves remote project roots and multiple working directories', () => { + const project = URI.parse('vscode-remote://ssh-remote+example/home/me/app'); + const primary = URI.parse('vscode-remote://ssh-remote+example/home/me/app-worktree'); + const secondary = URI.parse('vscode-remote://ssh-remote+example/home/me/shared'); + const remote: IAgentSessionMetadata = { + ...sessionMeta('remote', SessionStatus.Idle, primary), + workingDirectories: [primary, secondary], + project: { uri: project, displayName: 'Remote App' }, + }; + + assert.deepStrictEqual(JSON.parse(serializeSessions([remote])).sessions[0], { + session: 'copilot:/remote', + title: 'title-remote', + status: 'idle', + workingDirectory: primary.toString(), + workingDirectories: [primary.toString(), secondary.toString()], + project: 'Remote App', + projectUri: project.toString(), + }); + }); + test('serializeSessions reports archived status from the IsArchived status bit', () => { const archived: IAgentSessionMetadata = { ...sessionMeta('archived', SessionStatus.Idle | SessionStatus.IsArchived, workspace) }; const notArchived: IAgentSessionMetadata = { ...sessionMeta('notArchived', SessionStatus.Idle, workspace) }; @@ -259,6 +281,35 @@ suite('SessionServerTools', () => { assert.strictEqual(byName.model?.name, 'GPT-4o'); }); + test('getCreateSessionArgs resolves a unique project name to its configured root', () => { + const project = URI.parse('file:///workspace/vscode'); + const worktree = URI.parse('file:///worktrees/pr-331525'); + const sessions = [{ + ...sessionMeta('worktree', SessionStatus.Idle, worktree), + project: { uri: project, displayName: 'Visual Studio Code' }, + }]; + + assert.deepStrictEqual({ + byName: getCreateSessionArgs({ workspace: 'visual studio code', prompt: 'hi' }, sessions, []).workspace.toString(), + byProjectUri: getCreateSessionArgs({ workspace: project.toString(), prompt: 'hi' }, sessions, []).workspace.toString(), + }, { + byName: project.toString(), + byProjectUri: project.toString(), + }); + }); + + test('getCreateSessionArgs reports ambiguous project names', () => { + const sessions = [ + { ...sessionMeta('one', SessionStatus.Idle, URI.parse('file:///worktrees/one')), project: { uri: URI.parse('file:///projects/one'), displayName: 'App' } }, + { ...sessionMeta('two', SessionStatus.Idle, URI.parse('file:///worktrees/two')), project: { uri: URI.parse('file:///projects/two'), displayName: 'App' } }, + ]; + + assert.throws( + () => getCreateSessionArgs({ workspace: 'app', prompt: 'hi' }, sessions, []), + /ambiguous; use one of these project URIs: file:\/\/\/projects\/one, file:\/\/\/projects\/two/i, + ); + }); + test('getCreateSessionArgs accepts an absolute filesystem path as workspace', () => { const resolved = getCreateSessionArgs({ workspace: '/Users/me/work/repo', prompt: 'hi' }, [], []); assert.strictEqual(resolved.workspace.scheme, 'file'); @@ -352,6 +403,34 @@ suite('SessionServerTools', () => { }); }); + test('create_session uses a remote project root with a model from another provider', async () => { + const remoteProject = URI.parse('vscode-remote://ssh-remote+example/home/me/app'); + const remoteWorktree = URI.parse('vscode-remote://ssh-remote+example/home/me/app-worktree'); + const claudeModel: IAgentModelInfo = { provider: 'claude', id: 'claude-sonnet', name: 'Claude Sonnet', supportsVision: false }; + let created: IAgentCreateSessionConfig | undefined; + const accessor = createAccessor({ + listSessions: async () => [{ + ...sessionMeta('remote', SessionStatus.Idle, remoteWorktree), + project: { uri: remoteProject, displayName: 'Remote App' }, + }], + getModels: () => [claudeModel], + getCreationDefaults: () => ({ provider: 'copilot', model: { id: 'gpt-4o' } }), + onCreate: config => { created = config; }, + }); + + await applyCreateSessionTool(accessor, { + workspace: 'Remote App', + prompt: 'do it', + model: 'claude-sonnet', + }, URI.parse('copilot:/source')); + + assert.deepStrictEqual(created, { + workingDirectories: [remoteProject], + provider: 'claude', + model: { id: 'claude-sonnet' }, + }); + }); + test('list_sessions execute returns serialized sessions', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); @@ -404,6 +483,28 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('list_sessions filters by project name, project URI, and secondary working directory', () => { + const project = URI.parse('vscode-remote://ssh-remote+example/home/me/app'); + const primary = URI.parse('vscode-remote://ssh-remote+example/home/me/app-worktree'); + const secondary = URI.parse('vscode-remote://ssh-remote+example/home/me/shared'); + const remote = { + ...sessionMeta('remote', SessionStatus.Idle, primary), + workingDirectories: [primary, secondary], + project: { uri: project, displayName: 'Remote App' }, + }; + const sessions = [remote, sessionMeta('local', SessionStatus.Idle, workspace)]; + + assert.deepStrictEqual({ + byProjectName: filterSessions(sessions, getListSessionsArgs({ workspace: 'remote app' })), + byProjectUri: filterSessions(sessions, getListSessionsArgs({ workspace: project.toString() })), + bySecondaryDirectory: filterSessions(sessions, getListSessionsArgs({ workspace: secondary.toString() })), + }, { + byProjectName: [remote], + byProjectUri: [remote], + bySecondaryDirectory: [remote], + }); + }); + test('getListSessionsArgs validates filter input', () => { assert.deepStrictEqual(getListSessionsArgs({}), { session: undefined, status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined }); assert.throws(() => getListSessionsArgs({ status: ['bogus'] }), /status/); diff --git a/src/vs/platform/dataChannel/common/dataChannel.ts b/src/vs/platform/dataChannel/common/dataChannel.ts index 64fc69d03c853c..624ce7ef81b0db 100644 --- a/src/vs/platform/dataChannel/common/dataChannel.ts +++ b/src/vs/platform/dataChannel/common/dataChannel.ts @@ -38,6 +38,7 @@ export type LinkPresentationKind = | 'file' | 'folder' | 'session' + | 'chat' | 'repository' | 'branch'; @@ -123,6 +124,7 @@ function isLinkPresentationKind(value: unknown): value is LinkPresentationKind { || value === 'file' || value === 'folder' || value === 'session' + || value === 'chat' || value === 'repository' || value === 'branch'; } diff --git a/src/vs/platform/hover/browser/hoverService.ts b/src/vs/platform/hover/browser/hoverService.ts index eb058d65763e05..0285023388b9d6 100644 --- a/src/vs/platform/hover/browser/hoverService.ts +++ b/src/vs/platform/hover/browser/hoverService.ts @@ -443,6 +443,11 @@ export class HoverService extends Disposable implements IHoverService { private _hideHoverAndDescendants(hover: HoverWidget): void { const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover); if (stackIndex < 0) { + // The hover is not on the stack, so it may still be waiting for its delay to + // elapse. Cancel it, otherwise it would show up after this dismiss request. + if (hover === this._currentDelayedHover) { + this._cancelPendingDelayedHover(); + } return; } @@ -453,6 +458,20 @@ export class HoverService extends Disposable implements IHoverService { this._hoverStack.length = stackIndex; } + /** + * Cancels a delayed hover that was created but whose delay has not elapsed yet, so it + * never gets shown. + */ + private _cancelPendingDelayedHover(): void { + if (!this._currentDelayedHover || this._currentDelayedHoverWasShown) { + return; + } + + this._currentDelayedHover.dispose(); + this._currentDelayedHover = undefined; + this._currentDelayedHoverGroupId = undefined; + } + /** * Hides all hovers in the stack. */ @@ -464,12 +483,17 @@ export class HoverService extends Disposable implements IHoverService { } hideHover(force?: boolean): void { - if (this._hoverStack.length === 0) { + // If not forcing and the topmost hover is locked, don't hide + if (!force && this._currentHover?.isLocked) { return; } - // If not forcing and the topmost hover is locked, don't hide - if (!force && this._currentHover?.isLocked) { + // A delayed hover that has not been shown yet is not part of the stack, so it has to + // be cancelled explicitly. Otherwise it pops up once its delay elapses, on top of + // whatever took over in the meantime such as a context menu. + this._cancelPendingDelayedHover(); + + if (this._hoverStack.length === 0) { return; } diff --git a/src/vs/platform/hover/test/browser/hoverService.test.ts b/src/vs/platform/hover/test/browser/hoverService.test.ts index a0d3478b0b56f9..4bd39b3d2aef6f 100644 --- a/src/vs/platform/hover/test/browser/hoverService.test.ts +++ b/src/vs/platform/hover/test/browser/hoverService.test.ts @@ -358,6 +358,20 @@ suite('HoverService', () => { assert.strictEqual(hover.isDisposed, true, 'Locked hover should be disposed with force=true'); assertNotInDOM(hover, 'Locked hover should be removed from DOM with force'); }); + + test('should cancel a delayed hover that has not been shown yet', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('workbench.hover.delay', 500); + + const hover = hoverService.showDelayedHover({ content: 'Manage', target: createTarget() }, {}); + assert.ok(hover, 'Hover should be created'); + assertNotInDOM(hover, 'Hover should not be visible before the delay elapses'); + + // Simulates something else taking over, e.g. a context menu opening + hoverService.hideHover(); + + await timeout(500); + assertNotInDOM(hover, 'Cancelled delayed hover should never be shown'); + })); }); suite('nested hovers', () => { @@ -567,6 +581,20 @@ suite('HoverService', () => { disposable.dispose(); hoverService.hideHover(true); })); + + test('should not show a pending hover after the target was clicked', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('workbench.hover.delay', 500); + + const disposable = hoverService.setupDelayedHover(target, { content: 'Manage' }); + target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + target.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await timeout(500); + assert.strictEqual(mainWindow.document.querySelectorAll('.monaco-hover').length, 0, 'Pending hover should be cancelled by the click'); + + disposable.dispose(); + })); }); suite('setupManagedHover', () => { diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index c8c981ae197574..3796ecef8a51b8 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -55,12 +55,22 @@ export const COPILOT_ALLOW_MANAGED_HOOKS_ONLY_KEY = 'allowManagedHooksOnly'; /** Managed-settings transport control that requires a fresh server fetch on startup. */ export const COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY = 'forceRemoteSettingsRefresh'; +/** + * Enterprise-mandated sandbox floor (`sandbox.enabled` in the runtime's managed-settings schema). + * The runtime owns composing and enforcing this floor — it is `force-on-wins`, so a managed `true` + * cannot be loosened by the user. VS Code only *reads* it to decide which chat harness to offer, + * and deliberately declares no configuration policy for it: the control is runtime-owned, and + * mirroring it as a VS Code policy would invert ownership. + */ +export const COPILOT_SANDBOX_ENABLED_KEY = 'sandbox.enabled'; + /** * Managed-settings controls consumed by the delivery pipeline itself rather than by a * configuration policy. Native MDM must watch these even though no setting declares them. */ export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinitions = { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, }; /** Policy-only configuration delivery slot for {@link COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY}. */ @@ -152,6 +162,24 @@ export function shouldForceRemoteSettingsRefresh(nativeMdm: ManagedSettingsData return server?.[COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY] === true; } +export const IManagedSettingsService = createDecorator('managedSettingsService'); + +/** Read-only access to effective managed settings after channel resolution. */ +export interface IManagedSettingsService { + readonly _serviceBrand: undefined; + readonly onDidChangeManagedSettings: Event; + getManagedSettingValue(key: string): ManagedSettingValue | undefined; +} + +export class NullManagedSettingsService implements IManagedSettingsService { + readonly _serviceBrand: undefined; + readonly onDidChangeManagedSettings = Event.None; + + getManagedSettingValue(): ManagedSettingValue | undefined { + return undefined; + } +} + let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; /** Trim a managed-settings model value, treating a blank/whitespace-only string as unset. */ diff --git a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts index 2e2bd68f7b4d6c..9d98995a30bbeb 100644 --- a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts @@ -10,7 +10,7 @@ import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY } from '../../common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; import { NativeManagedSettingsChannelClient } from '../../common/nativeManagedSettingsIpc.js'; import { PolicyValue } from '../../common/policy.js'; import { NativeManagedSettingsService, NativePolicyWatcherFactory } from '../../node/nativeManagedSettingsService.js'; @@ -26,6 +26,7 @@ suite('NativeManagedSettingsService', () => { assert.deepStrictEqual(policies, { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' }, [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, }); onDidChange = callback; callback({}); @@ -64,7 +65,10 @@ suite('NativeManagedSettingsService', () => { watchedSettings, managedSettings: service.managedSettings, }, { - watchedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' } }, + watchedSettings: { + [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + }, managedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, }); }); diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index 0b284fd57f80f6..e534f2f84d6b0f 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -146,7 +146,7 @@ export function getTelemetryLevel(configurationService: IConfigurationService): } // Maps new telemetry setting to a telemetry level - switch (newConfig ?? TelemetryConfiguration.ON) { + switch (newConfig === undefined ? TelemetryConfiguration.ON : newConfig) { case TelemetryConfiguration.ON: return TelemetryLevel.USAGE; case TelemetryConfiguration.ERROR: @@ -155,6 +155,8 @@ export function getTelemetryLevel(configurationService: IConfigurationService): return TelemetryLevel.CRASH; case TelemetryConfiguration.OFF: return TelemetryLevel.NONE; + default: + return TelemetryLevel.NONE; } } diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index 0915dfeb9c2de3..baf364cd13dae4 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -874,12 +874,13 @@ suite('TelemetryService', () => { test('Telemetry Service checks with config service', function () { - let telemetryLevel = TelemetryConfiguration.OFF; + let telemetryLevel: string = TelemetryConfiguration.OFF; const emitter = new Emitter(); const testAppender = new TestTelemetryAppender(); const service = new TelemetryService({ - appenders: [testAppender] + appenders: [testAppender], + sendErrorTelemetry: true, }, new class extends TestConfigurationService { override onDidChangeConfiguration = emitter.event; override getValue(): T { @@ -897,6 +898,18 @@ suite('TelemetryService', () => { emitter.fire({ affectsConfiguration: () => true }); assert.strictEqual(service.telemetryLevel, TelemetryLevel.ERROR); + telemetryLevel = 'invalid'; + emitter.fire({ affectsConfiguration: () => true }); + service.publicLog('invalidTelemetryLevel'); + service.publicLogError('invalidTelemetryLevelError'); + assert.deepStrictEqual({ + telemetryLevel: service.telemetryLevel, + eventCount: testAppender.getEventsCount(), + }, { + telemetryLevel: TelemetryLevel.NONE, + eventCount: 0, + }); + service.dispose(); }); diff --git a/src/vs/platform/telemetry/test/common/telemetryUtils.test.ts b/src/vs/platform/telemetry/test/common/telemetryUtils.test.ts index 651fb39d965c82..35fe4536c0d6ba 100644 --- a/src/vs/platform/telemetry/test/common/telemetryUtils.test.ts +++ b/src/vs/platform/telemetry/test/common/telemetryUtils.test.ts @@ -5,12 +5,38 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { cleanRemoteAuthority } from '../../common/telemetryUtils.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { TelemetryConfiguration, TelemetryLevel } from '../../common/telemetry.js'; +import { cleanRemoteAuthority, getTelemetryLevel } from '../../common/telemetryUtils.js'; suite('TelemetryUtils', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('getTelemetryLevel fails closed for invalid configuration', () => { + assert.deepStrictEqual([ + getTelemetryLevel(new TestConfigurationService()), + getTelemetryLevel(new TestConfigurationService({ 'telemetry.telemetryLevel': TelemetryConfiguration.ON })), + getTelemetryLevel(new TestConfigurationService({ 'telemetry.telemetryLevel': TelemetryConfiguration.ERROR })), + getTelemetryLevel(new TestConfigurationService({ 'telemetry.telemetryLevel': TelemetryConfiguration.CRASH })), + getTelemetryLevel(new TestConfigurationService({ 'telemetry.telemetryLevel': TelemetryConfiguration.OFF })), + getTelemetryLevel(new TestConfigurationService({ 'telemetry.telemetryLevel': 'invalid' })), + getTelemetryLevel(new class extends TestConfigurationService { + override getValue(): T { + return null!; + } + }()), + ], [ + TelemetryLevel.USAGE, + TelemetryLevel.USAGE, + TelemetryLevel.ERROR, + TelemetryLevel.CRASH, + TelemetryLevel.NONE, + TelemetryLevel.NONE, + TelemetryLevel.NONE, + ]); + }); + suite('cleanRemoteAuthority', () => { test('returns "none" when remoteAuthority is undefined', () => { diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 476ce1d2705d83..72e164303054b3 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -34,18 +34,18 @@ Each session row displays: - **Status icon** — animated indicator for InProgress / NeedsInput / Error / Completed / Unread; unread takes precedence over completed-state glyphs such as a pull request, while quick chats never show a PR glyph (they have no GitHub PR association) and no per-row chat icon is shown either (the Chats section header, Pinned section, or custom group already conveys their identity) - **Title** — the session's display title (observable) - **Type icon** — regular workspace sessions show a folder/worktree/cloud icon indicating the workspace kind. Compact quick-chat rows omit this icon; regular quick-chat rows show the Chats icon. -- **Workspace or chat badge** — workspace sessions render their workspace label inline after the type icon. It is hidden only when a workspace section header already carries the same label; date, custom-group, Pinned, and Done rows show it unless live status temporarily hides row details. Regular quick-chat rows show `Chat` in the same position. +- **Workspace or chat badge** — workspace sessions render their workspace label inline after the type icon. It is hidden only when a workspace section header already carries the same label; date, custom-group, Pinned, and Done rows show it unless live status temporarily hides row details. Regular quick-chat rows show `No workspace` in the same position. - **Diff stats** (regular sessions only) — `+insertions −deletions` when the session has pending changes; omitted for quick chats - **Status description or timestamp** — InProgress and NeedsInput show a status message instead of a timestamp; Error shows both, and other terminal states show a relative timestamp. Compact quick-chat rows in the primary Sessions list omit this second row; automation history presents quick-chat-backed runs as regular history rows with timestamps. - **Approval row** (optional) — pending agent approvals with an "Allow" button -Compact quick-chat rows use `.session-item.quick-chat` when `useCompactQuickChatRows` is enabled (the default). Driven by the reactive `ISession.isQuickChat` observable, they are single-line entries: the details row is hidden and its content is never built, with a smaller icon and tighter row height (see `SessionsTreeDelegate.ITEM_HEIGHT_QUICK_CHAT`). When compact rendering is disabled, quick chats use the regular two-line row with a Chats icon, `Chat` badge, and status/timestamp metadata while continuing to omit workspace and diff metadata. +Compact quick-chat rows use `.session-item.quick-chat` when `useCompactQuickChatRows` is enabled (the default). Driven by the reactive `ISession.isQuickChat` observable, they are single-line entries: the details row is hidden and its content is never built, with a smaller icon and tighter row height (see `SessionsTreeDelegate.ITEM_HEIGHT_QUICK_CHAT`). When compact rendering is disabled, quick chats use the regular two-line row with a Chats icon, `No workspace` badge, and status/timestamp metadata while continuing to omit workspace and diff metadata. Continuous row animations preserve their existing appearance while limiting rendering work: the title shimmer follows the same three-second path with at most 30 visual updates per second, then rests for three seconds before repeating. Both it and the shared pixel spinner pause outside the viewport and whenever their document is hidden, while their visibility tracking survives temporary row-template detachment. Status icons cross-fade only for state changes within the same session; when virtualization rebinds a row template to another session, the new icon renders immediately so stale status is never shown. `SessionsFlatList` reuses the same session row renderer for sectionless surfaces, including the approval row and dynamic row height updates. Consumers that size their own container listen for content-height changes and relayout the list. When embedded inside another hover, consumers disable row hovers so moving over the list does not replace the parent hover. -Automation run history uses `SessionsFlatList` for runs backed by a live session. Quick-chat-backed runs use the regular two-line history-row presentation so all run entries have consistent height and status-icon sizing; their details row shows the Chats icon, a `Chat` badge in place of a workspace label, and the timestamp while continuing to omit diff metadata. Pending and running runs without a resolved session use a lightweight `Working...` row; date grouping and run actions remain owned by the Automations view. +Automation run history uses `SessionsFlatList` for runs backed by a live session. Quick-chat-backed runs use the regular two-line history-row presentation so all run entries have consistent height and status-icon sizing; their details row shows the Chats icon, a `No workspace` badge in place of a workspace label, and the timestamp while continuing to omit diff metadata. Pending and running runs without a resolved session use a lightweight `Working...` row; date grouping and run actions remain owned by the Automations view. ### Grouping @@ -65,7 +65,7 @@ Each quick chat is its **own single-chat session** (New Quick Chat = a new sessi Two grouping modes (user-switchable): -- **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them. A workspace header includes a **Create Session from Pull Request** icon action unless the section is backed only by `github-remote-file` cloud workspaces. The Quick Pick opens immediately in a disabled busy state while repository identity resolves. Identity comes from hydrated session metadata when available; otherwise the action opens the checkout through `IGitService`, waits for its repository-state remotes to hydrate, and parses the GitHub remote. Closing the picker cancels that wait. The picker then runs fresh Waiting for My Review and Assigned to Me queries in parallel with the lightweight first-100 catalog query. Each group query returns complete rows, so Waiting can render without waiting for the full catalog; groups append in final display order so visible entries never move during enrichment. PRs that already have a local or remote-host checkout session are excluded; an existing `github-remote-file` cloud-agent session does not prevent creating a separate worktree session for the same PR. Typing a query that matches none of the loaded entries fetches subsequent pages until a match is found or the catalog is exhausted. After selection, the picker remains busy while it loads the PR details and all paged file patches, issue comments, and review comments and waits for the folder to advertise a worktree-capable session type; Escape cancels this wait. The provisional session then activates immediately and starts a worktree that tracks the PR head branch. The initial request and response are retained as hidden model context, while the PR JSON appears as a one-time context pill that moves into the first visible request. +- **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them. A workspace header presents **New Session** as the primary half of a split button, with **Create Session from Pull Request** in its dropdown unless the section is backed only by `github-remote-file` cloud workspaces. When the pull-request action is unavailable, **New Session** remains a standalone action. The Quick Pick opens immediately in a disabled busy state while repository identity resolves. Identity comes from hydrated session metadata when available; otherwise the action opens the checkout through `IGitService`, waits for its repository-state remotes to hydrate, and parses the GitHub remote. Closing the picker cancels that wait. The picker then runs fresh Waiting for My Review and Assigned to Me queries in parallel with the lightweight first-100 catalog query. Each group query returns complete rows, so Waiting can render without waiting for the full catalog; groups append in final display order so visible entries never move during enrichment. PRs that already have a local or remote-host checkout session are excluded; an existing `github-remote-file` cloud-agent session does not prevent creating a separate worktree session for the same PR. Typing a query that matches none of the loaded entries fetches subsequent pages until a match is found or the catalog is exhausted. After selection, the picker remains busy while it loads the PR details and all paged file patches, issue comments, and review comments and waits for the folder to advertise a worktree-capable session type; Escape cancels this wait. The provisional session then activates immediately and starts a worktree that tracks the PR head branch. The initial request and response are retained as hidden model context, while the PR JSON appears as a one-time context pill that moves into the first visible request. - **By Date** — user groups form a contiguous, user-ordered block directly below Pinned; the non-grouped sessions follow in the fixed date sections (Recent, Older), where Recent holds up to 10 sessions from the last 7 days and Older holds the rest. Groups never mix into the date sections. User groups are **fully user-managed**: their order is owned by `ISessionSectionOrderService`, defaults to newest-first, and is shared across both grouping modes (it no longer derives from the recency of a group's member sessions). Groups remain visible and persisted until explicitly deleted. A group with no currently-visible member rows renders a muted **"No session" placeholder row** like the empty Chats section; its hover briefly explains that sessions can be added through the session context menu or drag and drop. This includes genuinely empty groups and groups whose members currently render in Pinned or are hidden by a filter. Archiving a session removes its group membership, so a group whose last member is marked done becomes empty and can be deleted. @@ -197,7 +197,7 @@ The Open Pull Request action shared by the session context menu and header uses | Menu | Constant | Where it appears | Use for | |------|----------|------------------|---------| -| `SessionSectionToolbar` | `SessionSectionToolbarMenuId` | Toolbar on section headers (Pinned, workspace groups, Done) | Section-scoped actions like "New Session for Workspace", GitHub-backed "Create Session from Pull Request", and the selected "Archive All"/"Mark All as Done" action. The Done section restores/unarchives sessions individually (or via multi-selection) rather than with a section-wide action. Section headers also show a collapsible chevron on hover/focus; the chevron uses the same ghost icon hover background token as toolbar icon buttons. | +| `SessionSectionToolbar` | `SessionSectionToolbarMenuId` | Toolbar on section headers (Pinned, workspace groups, Done) | Section-scoped actions like the workspace `DropdownWithPrimaryActionViewItem` whose fixed primary action is "New Session" and whose dropdown contains actions contributed to `Menus.SessionSectionNewSession`, including the GitHub-backed "Create Session from Pull Request" action. When that menu is empty, the toolbar renders the ordinary "New Session" action. The toolbar also contains the selected "Archive All"/"Mark All as Done" action. The Done section restores/unarchives sessions individually (or via multi-selection) rather than with a section-wide action. Section headers also show a collapsible chevron on hover/focus; while a section action dropdown is open, both the toolbar and chevron remain visible. The chevron uses the same ghost icon hover background token as toolbar icon buttons. | ### Group Header Menu diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index fd2fdd285f1350..1e7f84f1f6cace 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -26,6 +26,7 @@ export const Menus = { PanelTitle: new MenuId('SessionsPanelTitle'), SidebarTitle: new MenuId('SessionsSidebarTitle'), SidebarSessionsHeader: new MenuId('SessionsSidebarSessionsHeader'), + SessionSectionNewSession: new MenuId('SessionsSessionSectionNewSession'), SessionsViewExternalFilter: new MenuId('SessionsViewExternalFilter'), AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), SidebarFooter: new MenuId('SessionsSidebarFooter'), diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 5e9a3e23cfd1ab..e37dca5953c298 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -21,7 +21,7 @@ import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../wo import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { $, addDisposableListener, append, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, clearNode, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -30,7 +30,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { registerUpdateTitleBarMenuPlacement } from '../../../../workbench/contrib/update/browser/updateTitleBarEntry.js'; -import { ChatEntitlement, ChatEntitlementService, getChatPlanName, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementService, getChatPlanName, getQuotaReset, getQuotaUsage, IChatEntitlementService, IQuotaSnapshot, QuotaUsageKind } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -663,24 +663,54 @@ class TitleBarAccountWidget extends BaseActionViewItem { } private appendCopilotUsage(accountSection: HTMLElement, panelStore: DisposableStore): void { - const quota = this.chatEntitlementService.quotas.premiumChat ?? this.chatEntitlementService.quotas.chat; const usage = append(accountSection, $('.sessions-account-titlebar-panel-provider-usage')); + const contentStore = panelStore.add(new DisposableStore()); + + const render = () => { + contentStore.clear(); + clearNode(usage); + this.renderCopilotUsage(usage, contentStore); + }; + render(); + + // The panel is built from the cached snapshot while the embedded dashboard kicks off a + // fresh entitlement request, so rebuild the row once that lands rather than leaving it + // stale until the panel is reopened. + panelStore.add(this.chatEntitlementService.onDidChangeQuotaRemaining(render)); + panelStore.add(this.chatEntitlementService.onDidChangeEntitlement(render)); + } + + private renderCopilotUsage(usage: HTMLElement, store: DisposableStore): void { + const quota = this.chatEntitlementService.quotas.premiumChat ?? this.chatEntitlementService.quotas.chat; const planRow = append(usage, $('.sessions-account-titlebar-panel-provider-metric-row.primary')); append(planRow, $('span.sessions-account-titlebar-panel-provider-plan', undefined, this.getCopilotPlanLabel())); - if (quota && !quota.unlimited) { - const usedPercentage = Math.max(0, Math.floor(100 - quota.percentRemaining)); - const usageValue = append(planRow, $('span.sessions-account-titlebar-panel-provider-usage-value', { tabIndex: 0 })); + + const quotaUsage = getQuotaUsage(quota); + if (!quota || !quotaUsage) { + return; + } + + const formatter = safeIntl.NumberFormat(language, { maximumFractionDigits: 2, minimumFractionDigits: 0 }); + + if (quotaUsage.kind === QuotaUsageKind.CreditsUsed) { + const creditsFormatted = formatter.value.format(quotaUsage.creditsUsed); + append(planRow, $('span.sessions-account-titlebar-panel-provider-usage-value', { + 'aria-label': localize('copilotCreditsUsedTotal', "{0} credits used", creditsFormatted) + }, creditsFormatted)); + } else { + const usedPercentage = Math.floor(quotaUsage.usedPercentage); const percentageLabel = localize('copilotCreditsUsedPercentageValue', "{0}%", usedPercentage); const percentageAriaLabel = localize('copilotCreditsUsedPercentage', "{0}% credits used", usedPercentage); + const { used, total } = quotaUsage; + + // Revealing the ratio is the only interaction, so this is a tab stop only when there is a ratio to reveal. + const usageValue = append(planRow, $('span.sessions-account-titlebar-panel-provider-usage-value', used !== undefined && total !== undefined ? { tabIndex: 0 } : undefined)); usageValue.textContent = percentageLabel; usageValue.setAttribute('aria-label', percentageAriaLabel); - if (quota.entitlement) { - const formatter = safeIntl.NumberFormat(language, { maximumFractionDigits: 2, minimumFractionDigits: 0 }); - const used = quota.creditsUsed ?? (quota.quotaRemaining !== undefined - ? quota.entitlement - quota.quotaRemaining - : quota.entitlement * (100 - quota.percentRemaining) / 100); - const creditsValue = localize('copilotCreditsUsedRatioValue', "{0} / {1}", formatter.value.format(used), formatter.value.format(quota.entitlement)); - const creditsAriaLabel = localize('copilotCreditsUsedRatio', "{0} / {1} credits used", formatter.value.format(used), formatter.value.format(quota.entitlement)); + + if (used !== undefined && total !== undefined) { + const creditsValue = localize('copilotCreditsUsedRatioValue', "{0} / {1}", formatter.value.format(used), formatter.value.format(total)); + const creditsAriaLabel = localize('copilotCreditsUsedRatio', "{0} / {1} credits used", formatter.value.format(used), formatter.value.format(total)); const showCredits = () => { usageValue.textContent = creditsValue; usageValue.setAttribute('aria-label', creditsAriaLabel); @@ -689,20 +719,21 @@ class TitleBarAccountWidget extends BaseActionViewItem { usageValue.textContent = percentageLabel; usageValue.setAttribute('aria-label', percentageAriaLabel); }; - panelStore.add(addDisposableListener(usageValue, EventType.MOUSE_ENTER, showCredits)); - panelStore.add(addDisposableListener(usageValue, EventType.MOUSE_LEAVE, showPercentage)); - panelStore.add(addDisposableListener(usageValue, EventType.FOCUS, showCredits)); - panelStore.add(addDisposableListener(usageValue, EventType.BLUR, showPercentage)); - } - const detailRow = append(usage, $('.sessions-account-titlebar-panel-provider-metric-row.secondary')); - const resetLabel = this.getCopilotResetLabel(quota.resetAt); - if (resetLabel) { - append(detailRow, $('span.sessions-account-titlebar-panel-provider-reset', undefined, resetLabel)); - } else { - detailRow.classList.add('without-reset'); + store.add(addDisposableListener(usageValue, EventType.MOUSE_ENTER, showCredits)); + store.add(addDisposableListener(usageValue, EventType.MOUSE_LEAVE, showPercentage)); + store.add(addDisposableListener(usageValue, EventType.FOCUS, showCredits)); + store.add(addDisposableListener(usageValue, EventType.BLUR, showPercentage)); } - append(detailRow, $('span.sessions-account-titlebar-panel-provider-usage-label', undefined, localize('copilotCreditsUsedLabel', "Credits used"))); } + + const detailRow = append(usage, $('.sessions-account-titlebar-panel-provider-metric-row.secondary')); + const resetLabel = this.getCopilotResetLabel(quota); + if (resetLabel) { + append(detailRow, $('span.sessions-account-titlebar-panel-provider-reset', undefined, resetLabel)); + } else { + detailRow.classList.add('without-reset'); + } + append(detailRow, $('span.sessions-account-titlebar-panel-provider-usage-label', undefined, localize('copilotCreditsUsedLabel', "Credits used"))); } private appendChatGPTUsage(accountSection: HTMLElement): void { @@ -734,20 +765,15 @@ class TitleBarAccountWidget extends BaseActionViewItem { append(detailRow, $('span.sessions-account-titlebar-panel-provider-usage-label', undefined, localize('chatGPTLimitUsedLabel', "Limit used"))); } - private getCopilotResetLabel(resetAt: number | undefined): string | undefined { - if (resetAt) { - const resetDate = new Date(resetAt * 1000); - return localize('copilotCreditsResetAt', "Resets {0} at {1}", accountDateFormatter.value.format(resetDate), accountTimeFormatter.value.format(resetDate)); - } - - const { resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - if (!resetDate) { + private getCopilotResetLabel(quota: IQuotaSnapshot | undefined): string | undefined { + const reset = getQuotaReset(quota, this.chatEntitlementService.quotas); + if (!reset) { return undefined; } - const date = new Date(resetDate); - return resetDateHasTime - ? localize('copilotCreditsResetAt', "Resets {0} at {1}", accountDateFormatter.value.format(date), accountTimeFormatter.value.format(date)) - : localize('copilotCreditsReset', "Resets {0}", accountDateFormatter.value.format(date)); + + return reset.hasTime + ? localize('copilotCreditsResetAt', "Resets {0} at {1}", accountDateFormatter.value.format(reset.date), accountTimeFormatter.value.format(reset.date)) + : localize('copilotCreditsReset', "Resets {0}", accountDateFormatter.value.format(reset.date)); } private getChatGPTLimitLabel(windowDurationMins: number | undefined): string { diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index dc8a9b2e1ca1f5..b52d2570a8ecce 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -120,43 +120,6 @@ min-width: 0; } -/* Chat status dashboard embedded in the agents-app titlebar account panel */ -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip { - max-width: 360px; - padding: 2px 0 4px 0; -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip div.header { - padding: 8px 10px 8px 8px; -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .quota-indicator .quota-title { - font-size: var(--vscode-agents-fontSize-body1); - margin-bottom: 0; - color: var(--vscode-foreground); -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .collapsible-inner { - padding-top: 0; -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .header { - padding: 0 10px 0 8px; - margin-bottom: 0; - line-height: 18px; - color: var(--vscode-descriptionForeground); -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip div.header .monaco-action-bar { - color: var(--vscode-foreground); -} - -.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .body { - padding: 0 10px 0 8px; - line-height: 16px; - color: var(--vscode-descriptionForeground); -} - .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { width: auto; max-width: none; diff --git a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts index 6b847a3670adcd..120369b98d91b8 100644 --- a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { derivedOpts, IObservable, IReader, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AGENT_HOST_SESSION_LINK_PATTERN, AgentSessionLinkStatus, createAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../platform/dataChannel/common/dataChannel.js'; @@ -62,7 +63,6 @@ export class OpenSessionLinkOpenerContribution extends Disposable implements IWo } const chatId = parseOpenSessionLinkChatId(resource); if (chatId) { - // Peer chats carry their chatId in the session resource's fragment. await this._sessionsService.openChat(session, session.resource.with({ fragment: chatId })); return true; } @@ -102,11 +102,13 @@ export function readSessionState( reader: IReader, ): ILinkPresentation { const chat = findChat(session, chatId, reader); + const sessionTitle = session.title.read(reader); const description = session.description.read(reader)?.value; return createAgentSessionLinkPresentation( - chat?.title.read(reader) ?? session.title.read(reader), + chat?.title.read(reader) ?? (chatId ? localize('agentChatLink.unresolvedTitle', "Chat · {0}", sessionTitle) : sessionTitle), description, sessionStatusName(chat?.status.read(reader) ?? session.status.read(reader)), + chatId ? 'chat' : 'session', ); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b6dc7e8277cfd6..17b8bfa94906fe 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -40,7 +40,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); - content.push(localize('sessionsChat.pullRequestSession', "In a repository section of the sessions list, activate Create Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); + content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate Create Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills in the session header open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); content.push(localize('sessionsChat.failingChecksPullRequest', "When the active session has failing checks, use Reveal in the banner above the input to open its pull request, or use Fix Checks to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); diff --git a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts index 6a156ac7587460..e39597b2a52eb7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts @@ -4,15 +4,157 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { SessionStatus } from '../../../../services/sessions/common/session.js'; -import { ISessionLinkChatState, ISessionLinkState, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; +import { ILinkPresentationProvider, ILinkPresentationService } from '../../../../../platform/dataChannel/common/dataChannel.js'; +import { IOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionLinkChatState, ISessionLinkState, OpenSessionLinkOpenerContribution, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; suite('OpenSessionLinkOpenerContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('opens deep session and chat links in the Agents window', async () => { + let registeredOpener: IOpener | undefined; + const openerService = new class extends mock() { + override registerOpener(opener: IOpener): IDisposable { + registeredOpener = opener; + return Disposable.None; + } + }; + const sessionResource = URI.parse('copilotcli:/session-1'); + const session = upcastPartial({ resource: sessionResource }); + const sessionsManagementService = new class extends mock() { + override getSessions(): ISession[] { + return [session]; + } + }; + const opened: string[] = []; + const sessionsService = new class extends mock() { + override async openSession(resource: URI): Promise { + opened.push(`session:${resource.toString()}`); + } + + override async openChat(_session: ISession, resource: URI): Promise { + opened.push(`chat:${resource.toString()}`); + } + }; + const connectionsService = new class extends mock() { + override resolveSessionResource() { + return undefined; + } + }; + const linkPresentationService = new class extends mock() { + override registerLinkPresentationProvider(): IDisposable { + return Disposable.None; + } + }; + store.add(new OpenSessionLinkOpenerContribution( + openerService, + sessionsManagementService, + sessionsService, + connectionsService, + linkPresentationService, + )); + + if (!registeredOpener) { + throw new Error('Expected the contribution to register an opener'); + } + + assert.deepStrictEqual({ + results: [ + await registeredOpener.open(buildOpenSessionLinkUri(sessionResource)), + await registeredOpener.open(buildOpenSessionLinkUri(sessionResource, 'chat-2')), + ], + opened, + }, { + results: [true, true], + opened: [ + 'session:copilotcli:/session-1', + 'chat:copilotcli:/session-1#chat-2', + ], + }); + }); + + test('uses a contextual placeholder without opening the linked chat', () => { + const sessionResource = URI.parse('copilotcli:/session-1'); + const chatResource = sessionResource.with({ fragment: 'chat-2' }); + const chat = upcastPartial({ + resource: chatResource, + title: observableValue('chatTitle', 'Resolved chat'), + status: observableValue('chatStatus', SessionStatus.Completed), + }); + const chats = observableValue('chats', []); + const session = upcastPartial({ + resource: sessionResource, + title: observableValue('sessionTitle', 'Parent session'), + description: observableValue('sessionDescription', undefined), + status: observableValue('sessionStatus', SessionStatus.Completed), + chats, + }); + const sessionsManagementService = new class extends mock() { + override readonly onDidChangeSessions = Event.None; + + override getSessions(): ISession[] { + return [session]; + } + }; + let presentationProvider: ILinkPresentationProvider | undefined; + const linkPresentationService = new class extends mock() { + override registerLinkPresentationProvider(_registration: Parameters[0], provider: ILinkPresentationProvider): IDisposable { + presentationProvider = provider; + return Disposable.None; + } + }; + store.add(new OpenSessionLinkOpenerContribution( + new class extends mock() { + override registerOpener(): IDisposable { return Disposable.None; } + }, + sessionsManagementService, + new class extends mock() { }, + new class extends mock() { + override resolveSessionResource() { return undefined; } + }, + linkPresentationService, + )); + + const watcher = presentationProvider?.createLinkPresentationWatcher(URI.parse(buildOpenSessionLinkUri(sessionResource, 'chat-2'))); + if (!watcher) { + throw new Error('Expected the contribution to register a link presentation provider'); + } + store.add(watcher); + + const placeholder = watcher.presentation.get(); + chats.set([chat], undefined); + assert.deepStrictEqual({ + placeholder, + resolved: watcher.presentation.get(), + }, { + placeholder: { + kind: 'chat', + title: 'Chat · Parent session', + status: { kind: 'success', label: 'Completed' }, + tooltip: 'Chat · Parent session · Completed', + ariaLabel: 'Agent chat Chat · Parent session, Completed', + }, + resolved: { + kind: 'chat', + title: 'Resolved chat', + status: { kind: 'success', label: 'Completed' }, + tooltip: 'Resolved chat · Completed', + ariaLabel: 'Agent chat Resolved chat, Completed', + }, + }); + }); + test('reactively reads the targeted chat state', () => { const chatStatus = observableValue('chatStatus', SessionStatus.Completed); const chat: ISessionLinkChatState = { @@ -37,28 +179,28 @@ suite('OpenSessionLinkOpenerContribution', () => { assert.deepStrictEqual(values, [ { - kind: 'session', - title: 'Parent session', + kind: 'chat', + title: 'Chat · Parent session', detail: 'Session details', status: { kind: 'pending', label: 'Working' }, - tooltip: 'Parent session · Working', - ariaLabel: 'Agent session Parent session, Working', + tooltip: 'Chat · Parent session · Working', + ariaLabel: 'Agent chat Chat · Parent session, Working', }, { - kind: 'session', + kind: 'chat', title: 'Peer chat', detail: 'Session details', status: { kind: 'success', label: 'Completed' }, tooltip: 'Peer chat · Completed', - ariaLabel: 'Agent session Peer chat, Completed', + ariaLabel: 'Agent chat Peer chat, Completed', }, { - kind: 'session', + kind: 'chat', title: 'Peer chat', detail: 'Session details', status: { kind: 'warning', label: 'Needs input' }, tooltip: 'Peer chat · Needs input', - ariaLabel: 'Agent session Peer chat, Needs input', + ariaLabel: 'Agent chat Peer chat, Needs input', }, ]); }); diff --git a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts index 0b80529110523f..229ebdb7c5c44e 100644 --- a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts +++ b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts @@ -24,7 +24,8 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../browser/workbench.js'; -import { ISessionSection, SessionSectionHasNonCloudRepositoryContext, SessionSectionToolbarMenuId, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISessionSection, SessionSectionHasNonCloudRepositoryContext, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js'; import { IGitHubService } from './githubService.js'; import { IGitHubPullRequestSummary } from '../common/types.js'; import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getGitHubRepositoryFromRemotes, hasExistingPullRequest, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js'; @@ -40,7 +41,7 @@ registerAction2(class CreateSessionFromPullRequestAction extends Action2 { icon: Codicon.gitPullRequestCreate, precondition: ChatContextKeys.enabled, menu: { - id: SessionSectionToolbarMenuId, + id: Menus.SessionSectionNewSession, group: 'navigation', order: 2, when: ContextKeyExpr.and( diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 1c275327ba9250..12430098468244 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -244,7 +244,7 @@ function createProviderWithConfig( instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled, managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IDialogService, { @@ -377,7 +377,7 @@ function createProviderForSendTests( getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(IUriIdentityService, { extUri }); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(opts?.agentHostEnabled ?? true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(opts?.agentHostEnabled ?? true), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IContextKeyService, new MockContextKeyService()); instantiationService.stub(IGitHubService, new TestGitHubService()); instantiationService.stub(IPullRequestIconCache, new TestPullRequestIconCache()); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index a8bb7ec1c77de3..9dfb5575735ff6 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -539,7 +539,8 @@ } .monaco-list-row:hover .session-section .session-section-toolbar, -.monaco-list-row.focused .session-section .session-section-toolbar { +.monaco-list-row.focused .session-section .session-section-toolbar, +.monaco-list-row .session-section.dropdown-active .session-section-toolbar { display: block; } @@ -562,7 +563,8 @@ } .monaco-list-row:hover .session-section .session-section-chevron.collapsible, -.monaco-list-row.focused .session-section .session-section-chevron.collapsible { +.monaco-list-row.focused .session-section .session-section-chevron.collapsible, +.monaco-list-row .session-section.dropdown-active .session-section-chevron.collapsible { display: flex; align-items: center; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 8e472d2657bf22..f040bf7697c693 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -31,6 +31,7 @@ import { Menus } from '../../../browser/menus.js'; import { OmniCIFailureContribution } from './omniCIFailureContribution.js'; import { BlockedSessionsCIFixModel, IBlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; import './sessionDetailsAction.js'; +import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -103,5 +104,6 @@ registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessi registerWorkbenchContribution2(SessionConversationsActionViewItemContribution.ID, SessionConversationsActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsMouseNavigationContribution.ID, SessionsMouseNavigationContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTelemetryContribution.ID, SessionsTelemetryContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionsWindowNotifier.ID, SessionsWindowNotifier, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionConversationsMenuContribution.ID, SessionConversationsMenuContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionNewChatActionViewItemContribution.ID, SessionNewChatActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWindowNotifier.ts b/src/vs/sessions/contrib/sessions/browser/sessionsWindowNotifier.ts new file mode 100644 index 00000000000000..7886a200dff4ee --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsWindowNotifier.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../base/browser/window.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Disposable, DisposableMap, DisposableResourceMap, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorunDelta } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { FocusMode } from '../../../../platform/native/common/native.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ChatConfiguration, ChatNotificationMode } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export class SessionsWindowNotifier extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsWindowNotifier'; + + private readonly _statusListeners = this._register(new DisposableMap()); + private readonly _activeNotifications = this._register(new DisposableResourceMap()); + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IHostService private readonly _hostService: IHostService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + + for (const session of this._sessionsManagementService.getSessions()) { + this._trackSession(session); + } + + this._register(this._sessionsManagementService.onDidChangeSessions(event => { + for (const session of event.removed) { + this._statusListeners.deleteAndDispose(session.sessionId); + this._clearNotification(session); + } + for (const session of event.added) { + this._trackSession(session); + } + })); + } + + private _trackSession(session: ISession): void { + this._statusListeners.set(session.sessionId, autorunDelta(session.status, ({ lastValue, newValue }) => { + if (lastValue === undefined || lastValue === newValue) { + return; + } + + this._clearNotification(session); + if (newValue === SessionStatus.NeedsInput || newValue === SessionStatus.Completed || newValue === SessionStatus.Error) { + void this._notify(session, newValue); + } + })); + } + + private async _notify(session: ISession, status: SessionStatus): Promise { + const setting = status === SessionStatus.NeedsInput + ? ChatConfiguration.NotifyWindowOnConfirmation + : ChatConfiguration.NotifyWindowOnResponseReceived; + const mode = this._configurationService.getValue(setting); + if (mode === ChatNotificationMode.Off || (mode !== ChatNotificationMode.Always && this._hostService.hasFocus)) { + return; + } + + const cts = new CancellationTokenSource(); + this._activeNotifications.set(session.resource, toDisposable(() => cts.dispose(true))); + + try { + if (!this._hostService.hasFocus) { + await this._hostService.focus(mainWindow, { mode: FocusMode.Notify }); + } + if (cts.token.isCancellationRequested) { + return; + } + + const result = await this._hostService.showToast({ + title: this._sanitizeOSToastText(localize('sessions.notification.title', "Session: {0}", session.title.get())), + body: this._sanitizeOSToastText(this._getNotificationBody(session, status)), + actions: [localize('sessions.notification.openSession', "Open Session")], + }, cts.token); + + if (result.clicked || typeof result.actionIndex === 'number') { + await this._hostService.focus(mainWindow, { mode: FocusMode.Force }); + await this._sessionsService.openSession(session.resource); + } + } finally { + if (!cts.token.isCancellationRequested) { + this._clearNotification(session); + } + } + } + + private _getNotificationBody(session: ISession, status: SessionStatus): string { + const workspaceLabel = session.workspace.get()?.label; + switch (status) { + case SessionStatus.NeedsInput: + return workspaceLabel + ? localize('sessions.notification.needsInputWithWorkspace', "Input needed in {0}.", workspaceLabel) + : localize('sessions.notification.needsInput', "Input needed."); + case SessionStatus.Completed: + return workspaceLabel + ? localize('sessions.notification.completedWithWorkspace', "Completed in {0}.", workspaceLabel) + : localize('sessions.notification.completed', "Session completed."); + case SessionStatus.Error: + return workspaceLabel + ? localize('sessions.notification.failedWithWorkspace', "Failed in {0}.", workspaceLabel) + : localize('sessions.notification.failed', "Session failed."); + default: + return ''; + } + } + + private _sanitizeOSToastText(text: string): string { + return text.replace(/`/g, '\''); + } + + private _clearNotification(session: ISession): void { + this._activeNotifications.deleteAndDispose(session.resource); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index c79664fd2303f1..7005541a92c7ab 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -1015,7 +1015,7 @@ function formatHourMinute(hour: number, minute: number): string { } function getAutomationTargetLabel(target: AutomationTarget): string { - return target.kind === 'workspace' ? basename(target.folderUri) : localize('quickChat', "Quick Chat"); + return target.kind === 'workspace' ? basename(target.folderUri) : localize('quickChat', "No workspace"); } function groupRunsByDate(runs: readonly IAutomationRun[]): { key: string; label: string; runs: IAutomationRun[] }[] { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index f4ec28af9774c2..8ee3001aa5760c 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -26,6 +26,8 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { MenuId, IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { DropdownWithPrimaryActionViewItem } from '../../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; +import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; @@ -48,7 +50,7 @@ import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprova import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; -import { ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../../base/common/actions.js'; +import { Action, ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../../base/common/actions.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; @@ -87,6 +89,7 @@ import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/ import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; +import { Menus } from '../../../../browser/menus.js'; const $ = DOM.$; @@ -98,6 +101,7 @@ export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); export const SessionItemContextMenuId = MenuId.SessionItemContextMenu; export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); export const SessionGroupToolbarMenuId = new MenuId('SessionGroupToolbar'); +export const NEW_SESSION_FOR_WORKSPACE_ACTION_ID = 'sessionsView.sectionNewSession'; /** Controls whether the empty default Chats group is shown in the sessions list. */ export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups'; @@ -655,7 +659,7 @@ class SessionItemRenderer implements ITreeRenderer, private readonly uriIdentityService: IUriIdentityService, private readonly customViewService: ICustomViewService, + private readonly menuService: IMenuService, ) { } renderTemplate(container: HTMLElement): ISessionSectionTemplate { const disposables = new DisposableStore(); const elementDisposables = disposables.add(new DisposableStore()); + const actionViewItemDisposables = disposables.add(new DisposableStore()); + const dropdownAction = disposables.add(new Action( + 'sessionsView.sectionNewSession.moreActions', + localize('newSessionForWorkspaceMoreActions', "More Actions"), + )); container.classList.add('session-section'); const icon = DOM.append(container, $('span.session-section-icon')); @@ -974,6 +984,42 @@ export class SessionSectionRenderer implements ITreeRenderer { + actionViewItemDisposables.clear(); + + if (action.id !== NEW_SESSION_FOR_WORKSPACE_ACTION_ID || !(action instanceof MenuItemAction)) { + return undefined; + } + + const dropdownActions = getFlatContextMenuActions(this.menuService.getMenuActions( + Menus.SessionSectionNewSession, + contextKeyService, + { shouldForwardArgs: true }, + )); + if (dropdownActions.length === 0) { + return undefined; + } + + const item = scopedInstantiationService.createInstance( + DropdownWithPrimaryActionViewItem, + action, + dropdownAction, + dropdownActions, + '', + { + hoverDelegate: options.hoverDelegate, + menuAsChild: false + }, + ); + + actionViewItemDisposables.add(item.onDidChangeDropdownVisibility(visible => + container.classList.toggle('dropdown-active', visible))); + + actionViewItemDisposables.add(toDisposable(() => + container.classList.remove('dropdown-active'))); + + return item; + }, })); return { container, icon, statusIndicator, label, count, toolbarContainer, toolbar, chevron, contextKeyService, elementDisposables, disposables }; @@ -2010,7 +2056,7 @@ export class SessionsList extends Disposable implements ISessionsList { this.tree.setFocus([element], event); this.tree.setSelection([element], event); }; - const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, selectHeader, instantiationService, contextKeyService, this.automationService, this.automationSessions, this.uriIdentityService, this.customViewService); + const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, selectHeader, instantiationService, contextKeyService, this.automationService, this.automationSessions, this.uriIdentityService, this.customViewService, this.menuService); this._sectionRenderer = sectionRenderer; const groupRenderer = new SessionGroupRenderer({ commitEdit: (group, name) => this.commitGroupEdit(group, name), diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 6ec54b77698dc3..e3d5e8e40a77e6 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -24,7 +24,7 @@ import { EditorsVisibleContext, EditorAreaFocusContext, IsSessionsWindowContext import { SessionsCategories } from '../../../../common/categories.js'; import { RENAME_SESSION_COMMAND_ID, UNARCHIVE_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js'; -import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem } from './sessionsList.js'; +import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, SessionSectionHasNonCloudRepositoryContext, SessionGroupHasVisibleSessionsContext, SessionGroupIsEmptyContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem, NEW_SESSION_FOR_WORKSPACE_ACTION_ID } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext, openSessionToTheSide } from './sessionsView.js'; @@ -439,15 +439,31 @@ registerAction2(class FindSessionAction extends Action2 { registerAction2(class NewSessionForWorkspaceAction extends Action2 { constructor() { super({ - id: 'sessionsView.sectionNewSession', + id: NEW_SESSION_FOR_WORKSPACE_ACTION_ID, title: localize2('newSessionForWorkspace', "New Session"), icon: Codicon.plus, - menu: [{ - id: SessionSectionToolbarMenuId, - group: 'navigation', - order: 1, - when: ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), - }] + menu: [ + { + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + SessionSectionHasNonCloudRepositoryContext, + ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace')) + }, + { + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), + ContextKeyExpr.or( + ChatContextKeys.enabled.negate(), + SessionSectionHasNonCloudRepositoryContext.negate()), + ), + }, + ] }); } async run(accessor: ServicesAccessor, context?: ISessionSection): Promise { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index bd194f1485d9b3..5dd49f4e5b3d76 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -11,6 +11,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -101,6 +102,7 @@ suite('Sessions - SessionsList', () => { override readonly extUri = new ExtUri(() => true); }, new class extends mock() { }, + new class extends mock() { }, ); const container = document.createElement('div'); const template = renderer.renderTemplate(container); @@ -155,6 +157,7 @@ suite('Sessions - SessionsList', () => { automationSessions, uriIdentityService, new class extends mock() { }, + new class extends mock() { }, ); const runResource = URI.parse('test-session:/workspace/automation'); const statuses: (SessionStatus | undefined)[] = []; @@ -211,6 +214,7 @@ suite('Sessions - SessionsList', () => { constObservable([runningSession, needsInputSession]), uriIdentityService, new class extends mock() { }, + new class extends mock() { }, ); runs.set([ { @@ -773,7 +777,7 @@ suite('Sessions - SessionsList', () => { isShorterThanStandardRow: false, hasCompactClass: false, hasChatIcon: true, - badge: 'Chat', + badge: 'No workspace', time: 'now', hasDiff: false, ariaLabel: 'Investigate failure, chat, updated now', diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowNotifier.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowNotifier.test.ts new file mode 100644 index 00000000000000..1889d43295b341 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowNotifier.test.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { FocusMode } from '../../../../../platform/native/common/native.js'; +import { ChatConfiguration, ChatNotificationMode } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { IHostService, IToastOptions, IToastResult } from '../../../../../workbench/services/host/browser/host.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { SessionsWindowNotifier } from '../../browser/sessionsWindowNotifier.js'; + +class TestSessionsManagementService extends mock() { + + private readonly _onDidChangeSessions = new Emitter(); + override readonly onDidChangeSessions = this._onDidChangeSessions.event; + + constructor(private readonly _sessions: ISession[]) { + super(); + } + + override getSessions(): ISession[] { + return this._sessions; + } + + dispose(): void { + this._onDidChangeSessions.dispose(); + } +} + +class TestSessionsService extends mock() { + + readonly opened: URI[] = []; + + override async openSession(sessionResource: URI): Promise { + this.opened.push(sessionResource); + } +} + +class TestHostService extends mock() { + + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + override readonly onDidChangeFullScreen = Event.None; + readonly toasts: IToastOptions[] = []; + readonly focusModes: (FocusMode | undefined)[] = []; + override hasFocus = false; + toastResult: IToastResult = { supported: true, clicked: false }; + + override async focus(_targetWindow: Window, options?: { mode?: FocusMode }): Promise { + this.focusModes.push(options?.mode); + } + + override async showToast(options: IToastOptions, _token: CancellationToken): Promise { + this.toasts.push(options); + return this.toastResult; + } +} + +function createSession(id: string, initialStatus: SessionStatus, workspaceLabel = 'vscode'): { session: ISession; status: ReturnType> } { + const status = observableValue(`status-${id}`, initialStatus); + const session = new class extends mock() { + override readonly sessionId = id; + override readonly resource = URI.parse(`test:///${id}`); + override readonly title = observableValue(`title-${id}`, `Fix ${id}`); + override readonly status = status; + override readonly workspace = observableValue(`workspace-${id}`, new class extends mock() { + override readonly label = workspaceLabel; + }); + }; + return { session, status }; +} + +suite('SessionsWindowNotifier', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createNotifier( + session: ISession, + configuration: Record, + ): { notifier: SessionsWindowNotifier; sessions: TestSessionsService; host: TestHostService } { + const management = new TestSessionsManagementService([session]); + const sessions = new TestSessionsService(); + const host = new TestHostService(); + const notifier = store.add(new SessionsWindowNotifier( + management, + sessions, + host, + new TestConfigurationService(configuration), + )); + store.add(management); + return { notifier, sessions, host }; + } + + test('uses confirmation setting for needs-input transitions', async () => { + const { session, status } = createSession('needs-input', SessionStatus.InProgress); + const { host } = createNotifier(session, { + [ChatConfiguration.NotifyWindowOnConfirmation]: ChatNotificationMode.WindowNotFocused, + }); + + status.set(SessionStatus.NeedsInput, undefined); + await timeout(0); + + assert.deepStrictEqual({ + toasts: host.toasts, + focusModes: host.focusModes, + }, { + toasts: [{ + title: 'Session: Fix needs-input', + body: 'Input needed in vscode.', + actions: ['Open Session'], + }], + focusModes: [FocusMode.Notify], + }); + }); + + test('uses response setting for completed and failed transitions', async () => { + const { session, status } = createSession('finished', SessionStatus.InProgress); + const { host } = createNotifier(session, { + [ChatConfiguration.NotifyWindowOnResponseReceived]: ChatNotificationMode.Always, + }); + host.hasFocus = true; + + status.set(SessionStatus.Completed, undefined); + await timeout(0); + status.set(SessionStatus.InProgress, undefined); + status.set(SessionStatus.Error, undefined); + await timeout(0); + + assert.deepStrictEqual(host.toasts.map(toast => toast.body), [ + 'Completed in vscode.', + 'Failed in vscode.', + ]); + }); + + test('does not notify for initial or duplicate state and respects focus', async () => { + const { session, status } = createSession('quiet', SessionStatus.NeedsInput); + const { host } = createNotifier(session, { + [ChatConfiguration.NotifyWindowOnConfirmation]: ChatNotificationMode.WindowNotFocused, + }); + host.hasFocus = true; + + status.set(SessionStatus.InProgress, undefined); + status.set(SessionStatus.NeedsInput, undefined); + await timeout(0); + + assert.deepStrictEqual(host.toasts, []); + }); + + test('opens the exact session when the toast is activated', async () => { + const { session, status } = createSession('open-me', SessionStatus.InProgress); + const { sessions, host } = createNotifier(session, { + [ChatConfiguration.NotifyWindowOnResponseReceived]: ChatNotificationMode.WindowNotFocused, + }); + host.toastResult = { supported: true, clicked: true }; + + status.set(SessionStatus.Completed, undefined); + await timeout(0); + + assert.deepStrictEqual({ + opened: sessions.opened.map(resource => resource.toString()), + focusModes: host.focusModes, + }, { + opened: ['test:/open-me'], + focusModes: [FocusMode.Notify, FocusMode.Force], + }); + }); +}); diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index b1f9ec4d6499ae..b72abdc5b4d1ed 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -50,7 +50,7 @@ import { IUserDataProfilesService, reviveProfile } from '../../platform/userData import { UserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { PolicyChannelClient } from '../../platform/policy/common/policyIpc.js'; import { NativeManagedSettingsChannelClient } from '../../platform/policy/common/nativeManagedSettingsIpc.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; import { FileManagedSettingsChannelClient } from '../../platform/policy/common/fileManagedSettingsIpc.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; import { UserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfileService.js'; @@ -227,6 +227,7 @@ export class SessionsMain extends Disposable { const fileManagedSettings = this._register(new FileManagedSettingsChannelClient(mainProcessService.getChannel('fileManagedSettings'))); serviceCollection.set(IFileManagedSettingsService, fileManagedSettings); const accountPolicy = this._register(new AccountPolicyService(logService, defaultAccountService, policyChannel, nativeManagedSettings, fileManagedSettings)); + serviceCollection.set(IManagedSettingsService, accountPolicy); if (policyChannel) { policyService = this._register(new MultiplexPolicyService([policyChannel, accountPolicy], logService)); } else { diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index db5b0006326ef8..99af8667831d50 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -245,6 +245,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { targetChatSessionType: m.targetChatSessionType, configurationSchema: m.configurationSchema as IJSONSchema | undefined, warningText: m.warningText, + infoText: m.infoText, promo: m.promo, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index c7141aee59ea33..f394728f063cea 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -50,6 +50,8 @@ import { IDefaultAccountService } from '../../../platform/defaultAccount/common/ import { IAuthenticationService } from '../../services/authentication/common/authentication.js'; import { IAuthenticationAccessService } from '../../services/authentication/browser/authenticationAccessService.js'; import { IPolicyService, PolicyValueSource } from '../../../platform/policy/common/policy.js'; +import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js'; +import { isVirtualWorkspace } from '../../../platform/workspace/common/virtualWorkspace.js'; import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, INativeManagedSettingsService, IFileManagedSettingsService, ManagedSettingsChannel, ManagedSettingsSource, normalizeManagedSettings, projectManagedSettings, pickManagedSettings } from '../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingPolicyDefinition, ManagedSettingsData } from '../../../base/common/policy.js'; import { APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, IAccountPolicyGateService } from '../../services/policies/common/accountPolicyService.js'; @@ -735,6 +737,7 @@ interface IPolicyDiagnosticsSummary { effectiveManagedSettings: string; managedSettingsIssues: string; agentRuntime: string; + chatHarnessEnforcement: string; policyControlledSettings: string; } @@ -751,6 +754,7 @@ interface IPolicyDiagnosticsServices { accountPolicyGateService: IAccountPolicyGateService; agentHostService: IAgentHostService; agentHostEnablementService: IAgentHostEnablementService; + workspaceContextService: IWorkspaceContextService; nativeManagedSettingsService: INativeManagedSettingsService | undefined; fileManagedSettingsService: IFileManagedSettingsService | undefined; } @@ -779,6 +783,7 @@ class PolicyDiagnosticsAction extends Action2 { const accountPolicyGateService = accessor.get(IAccountPolicyGateService); const agentHostService = accessor.get(IAgentHostService); const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + const workspaceContextService = accessor.get(IWorkspaceContextService); const progressService = accessor.get(IProgressService); // Native MDM is a desktop-only channel, registered in the renderer service collection on // desktop and Agents windows but absent in web. Resolve it now, synchronously, because the @@ -815,6 +820,7 @@ class PolicyDiagnosticsAction extends Action2 { accountPolicyGateService, agentHostService, agentHostEnablementService, + workspaceContextService, nativeManagedSettingsService, fileManagedSettingsService, })); @@ -834,6 +840,7 @@ class PolicyDiagnosticsAction extends Action2 { accountPolicyGateService, agentHostService, agentHostEnablementService, + workspaceContextService, nativeManagedSettingsService, fileManagedSettingsService, } = services; @@ -845,6 +852,7 @@ class PolicyDiagnosticsAction extends Action2 { effectiveManagedSettings: 'Unavailable', managedSettingsIssues: 'Unavailable', agentRuntime: 'Unavailable', + chatHarnessEnforcement: 'Unavailable', policyControlledSettings: 'Unavailable' }; @@ -1229,6 +1237,29 @@ class PolicyDiagnosticsAction extends Action2 { content += '*No policy-controlled settings found*\n\n'; } + content += '## Chat Harness Enforcement\n\n'; + try { + const sandboxEnforced = agentHostEnablementService.managedSandboxEnforced.get(); + const virtualWorkspace = isVirtualWorkspace(workspaceContextService.getWorkspace()); + const agentHostEnabled = agentHostEnablementService.enabled.get(); + + if (!sandboxEnforced) { + summary.chatHarnessEnforcement = 'Not enforced'; + } else if (virtualWorkspace) { + summary.chatHarnessEnforcement = 'Mandated, not applied (virtual workspace)'; + } else if (!agentHostEnabled) { + summary.chatHarnessEnforcement = 'Mandated, not applied (Agent Host disabled)'; + } else { + summary.chatHarnessEnforcement = 'Local harness hidden, new chats use the Agent Host Copilot SDK'; + } + + content += `**Effective decision:** ${summary.chatHarnessEnforcement}.\n\n`; + } catch (error) { + const message = getErrorMessage(error); + summary.chatHarnessEnforcement = `Unavailable (${message})`; + content += `*Error resolving chat harness enforcement: ${markdownText(message)}*\n\n`; + } + // Authentication diagnostics content += '## Authentication Information\n\n'; try { @@ -1292,6 +1323,7 @@ class PolicyDiagnosticsAction extends Action2 { ['Effective managed settings', summary.effectiveManagedSettings], ['Managed-settings issues', summary.managedSettingsIssues], ['Agent Runtime', summary.agentRuntime], + ['Chat harness enforcement', summary.chatHarnessEnforcement], ['Policy-controlled settings', summary.policyControlledSettings] ] ) + diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 13e17008289477..263a02c4af5a56 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -194,10 +194,10 @@ margin-top: var(--vscode-spacing-size20); } -/* At the default (non-compact) size, separate the activity bar items with a 4px gap +/* At the default (non-compact) size, separate the activity bar items with an 8px gap * so they read as distinct floating targets. Compact keeps the tighter default stack. */ .monaco-workbench.floating-panels .part.activitybar:not(.compact) > .content .monaco-action-bar .action-item + .action-item { - margin-top: var(--vscode-spacing-size40); + margin-top: var(--vscode-spacing-size80); } /* Inset and vertically center status bar items within the full-width bottom rail. */ diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 5e4099094acd9b..c7f154e0459b41 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -69,7 +69,7 @@ import { DelayedLogChannel } from '../services/output/common/delayedLogChannel.j import { dirname, joinPath } from '../../base/common/resources.js'; import { IUserDataProfile, IUserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfile.js'; import { IPolicyService } from '../../platform/policy/common/policy.js'; -import { INativeManagedSettingsService, NullNativeManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingsService, INativeManagedSettingsService, NullNativeManagedSettingsService } from '../../platform/policy/common/copilotManagedSettings.js'; import { IRemoteExplorerService } from '../services/remote/common/remoteExplorerService.js'; import { DisposableTunnel, TunnelProtocol } from '../../platform/tunnel/common/tunnel.js'; import { ILabelService } from '../../platform/label/common/label.js'; @@ -372,6 +372,7 @@ export class BrowserMain extends Disposable { const policyService = new AccountPolicyService(logService, defaultAccountService); serviceCollection.set(IPolicyService, policyService); serviceCollection.set(IAccountPolicyGateService, policyService); + serviceCollection.set(IManagedSettingsService, policyService); const configurationService = await this.createWorkspaceAndDependentServices(serviceCollection, workspace, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, policyService, logService, loggerService, remoteAuthorityResolverService, productService); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 507621beff6607..73b189f0ac5200 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -30,6 +30,8 @@ export interface VoiceWidgetCallbacks { disconnect(): void; pttDown(): void; pttUp(): void; + /** Toggle whether the microphone is muted while keeping the session connected. */ + toggleMute(): void; closeWindow(): void; stopPlayback(): void; openSession(resource: URI): void; @@ -149,6 +151,7 @@ export class AgentsVoiceWidget extends Disposable { private readonly _isConnected: ISettableObservable = observableValue(this, false); private readonly _isConnecting: ISettableObservable = observableValue(this, false); private readonly _isReconnecting: ISettableObservable = observableValue(this, false); + private readonly _isMuted: ISettableObservable = observableValue(this, false); private readonly _voiceState: ISettableObservable = observableValue(this, 'idle'); private readonly _expanded: ISettableObservable = observableValue(this, false); private readonly _workingCount: ISettableObservable = observableValue(this, 0); @@ -199,6 +202,7 @@ export class AgentsVoiceWidget extends Disposable { private readonly _inputBoxToolbar: HTMLElement | undefined; private readonly _inputBoxMicBtn: HTMLElement | undefined; private readonly _inputBoxConnIndicator: HTMLElement | undefined; + private readonly _inputBoxMuteBtn: HTMLElement | undefined; /** Ambient voice glow on the input box (input-box layout only). */ private readonly _glowController: IVoiceGlowController | undefined; private readonly _inputBoxFeedbackBtn: HTMLElement | undefined; @@ -370,6 +374,13 @@ export class AgentsVoiceWidget extends Disposable { localize('agentsVoice.disconnect', "Disconnect"), localize('agentsVoice.disconnect', "Disconnect")); + // Mute microphone button — color/label managed reactively in update. + this._inputBoxMuteBtn = dom.$('span.codicon.codicon-mic'); + this._inputBoxMuteBtn.role = 'button'; + this._inputBoxMuteBtn.tabIndex = 0; + this._inputBoxMuteBtn.style.cssText = `font-size:${FONT_SIZE.iconSm};color:var(--vscode-descriptionForeground);cursor:pointer;-webkit-app-region:no-drag;padding:2px;`; + addKeyboardActivation(this._inputBoxMuteBtn); + // Feedback button this._inputBoxFeedbackBtn = toolbarBtn('codicon-feedback', localize('agentsVoice.sendFeedback', "Send feedback"), @@ -395,6 +406,7 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxToolbar.append( this._inputBoxMicBtn, this._inputBoxConnIndicator, + this._inputBoxMuteBtn, toolbarSpacer, this._inputBoxFeedbackBtn, this._inputBoxSessionsBtn, @@ -799,6 +811,23 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxConnIndicator!.style.display = !voiceControlsSuppressed && showConnected ? '' : 'none'; this._inputBoxConnIndicator!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }; + // Mute microphone button — visible when connected, keeps the session alive + const muted = this._isMuted.read(reader); + this._inputBoxMuteBtn!.style.display = !voiceControlsSuppressed && showConnected ? '' : 'none'; + this._inputBoxMuteBtn!.classList.toggle('codicon-mic', !muted); + this._inputBoxMuteBtn!.classList.toggle('codicon-mute', muted); + const muteColor = muted ? 'var(--vscode-editorError-foreground)' : 'var(--vscode-descriptionForeground)'; + this._inputBoxMuteBtn!.style.color = muteColor; + const muteLabel = muted + ? localize('agentsVoice.unmuteMic', "Unmute Microphone") + : localize('agentsVoice.muteMic', "Mute Microphone"); + this._inputBoxMuteBtn!.title = muteLabel; + this._inputBoxMuteBtn!.ariaLabel = muteLabel; + this._inputBoxMuteBtn!.setAttribute('aria-pressed', muted ? 'true' : 'false'); + this._inputBoxMuteBtn!.onmouseenter = () => { this._inputBoxMuteBtn!.style.color = 'var(--vscode-foreground)'; }; + this._inputBoxMuteBtn!.onmouseleave = () => { this._inputBoxMuteBtn!.style.color = muteColor; }; + this._inputBoxMuteBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.toggleMute(); }; + // Feedback button — always visible this._inputBoxFeedbackBtn!.style.display = voiceControlsSuppressed ? 'none' : ''; this._inputBoxFeedbackBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }; @@ -862,6 +891,7 @@ export class AgentsVoiceWidget extends Disposable { showPopout: !!this.callbacks.openPopout && this._popoutAvailable.read(reader), hideDisconnect: this.callbacks.hideDisconnect, centerConnectButton: opts.centerConnectButton, + isMuted: this._isMuted.read(reader), onMicDown: (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }, onMicUp: () => { this.callbacks.pttUp(); }, onConnectClick: (e: MouseEvent) => { @@ -878,6 +908,7 @@ export class AgentsVoiceWidget extends Disposable { onCloseClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.closeWindow(); }, onToggleClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._expanded.set(!this._expanded.get(), undefined); }, onMicContextMenu: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.showVoiceContextMenu(e); }, + onMuteClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.toggleMute(); }, onPopoutClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPopout?.(); }, onFeedbackClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }, pttKeyLabel: this._pttKeyLabel.read(reader), @@ -974,6 +1005,10 @@ export class AgentsVoiceWidget extends Disposable { this._isReconnecting.set(reconnecting, undefined); } + setMuted(muted: boolean): void { + this._isMuted.set(muted, undefined); + } + setVoiceState(state: VoiceState): void { this._voiceState.set(state, undefined); } diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts index 9a938e37c3a3bd..26a7355f67b352 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts @@ -50,6 +50,7 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid const connected = controller.isConnected.read(reader); const connecting = controller.isConnecting.read(reader); const reconnecting = controller.isReconnecting.read(reader); + const muted = controller.isMuted.read(reader); const toolConfirmations = controller.pendingToolConfirmations.read(reader); const speakingSession = voicePlaybackService.speakingSession.read(reader); const statusText = controller.statusText.read(reader); @@ -60,6 +61,7 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid widget.setConnected(connected); widget.setConnecting(connecting); widget.setReconnecting(reconnecting); + widget.setMuted(muted); widget.setVoiceControlsSuppressed(omniInputOpen); widget.setVoiceState(omniInputOpen ? 'idle' : state); widget.setPendingToolConfirmations(toolConfirmations); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index fbffa2ce15349c..aaad3da376e05e 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -179,6 +179,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice this.voiceSessionController.pttDown(); }, pttUp: () => this.voiceSessionController.pttUp(), + toggleMute: () => this.voiceSessionController.setMuted(!this.voiceSessionController.isMuted.get()), closeWindow: () => this.closeWindow(), stopPlayback: () => this.ttsPlaybackService.stopPlayback(), openSession: (resource) => { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index 55c9c745ef7d94..fa6b8311ef9312 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -21,6 +21,7 @@ export interface HeaderProps { readonly showPopout: boolean; readonly hideDisconnect: boolean; readonly centerConnectButton: boolean; + readonly isMuted: boolean; readonly onMicDown: (e: MouseEvent) => void; readonly onMicUp: () => void; readonly onConnectClick: (e: MouseEvent) => void; @@ -28,6 +29,7 @@ export interface HeaderProps { readonly onCloseClick: (e: MouseEvent) => void; readonly onToggleClick: (e: MouseEvent) => void; readonly onMicContextMenu: (e: MouseEvent) => void; + readonly onMuteClick: (e: MouseEvent) => void; readonly onPopoutClick: (e: MouseEvent) => void; readonly onFeedbackClick: (e: MouseEvent) => void; readonly expanded: boolean; @@ -86,6 +88,14 @@ export function createHeader(): HeaderComponent { connIndicator.append(connDot, connDisc); addKeyboardActivation(connIndicator); + // Mute microphone button — toggles whether captured audio is sent to the + // backend. Shown only while connected. Visual state clearly reflects mute. + const muteBtn = dom.$('span.codicon.codicon-mic'); + muteBtn.role = 'button'; + muteBtn.tabIndex = 0; + muteBtn.style.cssText = `font-size:${FONT_SIZE.iconSm};cursor:pointer;-webkit-app-region:no-drag;flex-shrink:0;border-radius:4px;padding:2px;`; + addKeyboardActivation(muteBtn); + // Placeholder text — clickable, shows PTT keybinding const placeholderText = dom.$('span.voice-placeholder-text'); placeholderText.role = 'button'; @@ -128,7 +138,7 @@ export function createHeader(): HeaderComponent { } `; - container.append(copilotIcon, micBtn, placeholderText, connIndicator, spacer, popoutBtn, closeBtn, connStyle); + container.append(copilotIcon, micBtn, placeholderText, connIndicator, muteBtn, spacer, popoutBtn, closeBtn, connStyle); return { element: container, @@ -175,6 +185,22 @@ export function createHeader(): HeaderComponent { connIndicator.style.display = showConnected && !props.hideDisconnect ? 'inline-flex' : 'none'; connIndicator.onclick = props.onDisconnectClick; + // Mute microphone button — shown only when connected + muteBtn.style.display = showConnected ? '' : 'none'; + muteBtn.classList.toggle('codicon-mic', !props.isMuted); + muteBtn.classList.toggle('codicon-mute', props.isMuted); + const muteColor = props.isMuted ? 'var(--vscode-editorError-foreground)' : 'var(--vscode-descriptionForeground)'; + muteBtn.style.color = muteColor; + const muteLabel = props.isMuted + ? localize('agentsVoice.unmuteMic', "Unmute Microphone") + : localize('agentsVoice.muteMic', "Mute Microphone"); + muteBtn.ariaLabel = muteLabel; + muteBtn.title = muteLabel; + muteBtn.setAttribute('aria-pressed', props.isMuted ? 'true' : 'false'); + muteBtn.onmouseenter = () => { muteBtn.style.color = 'var(--vscode-foreground)'; }; + muteBtn.onmouseleave = () => { muteBtn.style.color = muteColor; }; + muteBtn.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); props.onMuteClick(e); }; + // Spacer / center connect button const showConnBtnCenter = !showConnected && props.centerConnectButton; spacer.style.cssText = 'flex:1;'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index a6c2226e82171f..6dc3c261688d8f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -110,7 +110,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.chat.toggleSpeechToText', 'To dictate your request into the input box, invoke the Dictate command{0}. Invoke it again to stop; recording start and stop are indicated by accessibility signals.', '')); content.push(localize('workbench.action.chat.cancelSpeechToText', 'While dictating, invoke the Cancel Dictation command{0} to stop and discard the dictated text.', '')); content.push(localize('chat.speechToText.contextMenu', 'To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu{0} (for example Shift+F10).', '')); - content.push(localize('chat.voiceInputMode.segmented', 'When the segmented voice input control is enabled, the input toolbar offers Dictation, Voice Mode, and, in manual Voice Mode, a Start or Stop Listening button. Stopping listening sends the completed turn. Each button can be focused and activated with Enter or Space.')); + content.push(localize('chat.voiceInputMode.segmented', 'When the segmented voice input control is enabled, the input toolbar offers Dictation and Voice Mode. A connected hands-free session also offers Mute or Unmute Microphone; manual Voice Mode instead offers Start or Stop Listening, where stopping sends the completed turn. Each button can be focused and activated with Enter or Space.')); content.push(localize('chat.voiceInputMode.holdToTalk', 'In manual Voice Mode, the Start or Stop Listening button toggles listening when tapped, or you can press and hold it to talk and release to send. You can also hold the Voice Mode: Hold to Talk keybinding{0} to talk and release to send; this interrupts the assistant to barge in.', '')); content.push(localize('chat.voiceMode.introduction', 'The first time Voice Mode starts, an introduction appears above the input box. Tab to reach it, then use the arrow keys to move between the available voices; Enter or Space plays a voice and keeps it for future conversations. Its description also contains two links: Settings, which opens the Voice Mode settings, and How It Responds, which opens a file for customizing what the agent says back. Voice Mode stays connected but does not listen while the introduction is open. Press Escape, or activate the Close button, to dismiss it and return to the input box.')); if (type === 'agentView') { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index ea142d1eff3018..3d144289c6fc1b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -597,7 +597,8 @@ export function registerChatActions() { * honoring the remembered harness preference and then the configured default. */ function getNewChatEditorSessionUri(accessor: ServicesAccessor): URI { - return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService), accessor.get(IWorkspaceContextService).getWorkspace(), accessor.get(IAgentHostEnablementService).enabled.get()); + const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService), accessor.get(IWorkspaceContextService).getWorkspace(), agentHostEnablementService.enabled.get(), undefined, agentHostEnablementService.managedSandboxEnforced.get()); } registerAction2(PrimaryOpenChatGlobalAction); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index add1ab29ea3b63..c94eab4f2aed1c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -20,12 +20,12 @@ import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatDebugService } from '../../common/chatDebugService.js'; +import { CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST, CHAT_DEBUG_HAS_ACTIVE_SESSION, IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { IChatDebugEditorOptions, CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST } from '../chatDebug/chatDebugTypes.js'; +import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; /** @@ -119,11 +119,19 @@ export function registerChatOpenAgentDebugPanelAction() { icon: Codicon.chatExport, f1: true, category: Categories.Developer, - precondition: ChatContextKeys.enabled, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + CHAT_DEBUG_HAS_ACTIVE_SESSION, + CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.negate(), + ), menu: [{ id: MenuId.EditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.negate()), + when: ContextKeyExpr.and( + ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), + CHAT_DEBUG_HAS_ACTIVE_SESSION, + CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.negate(), + ), order: 10 }], }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 9d7f6ec3d15e4d..172b242e22cb6d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -27,7 +27,7 @@ import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../.. import { readAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; -import { isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { parsePartialToolInputForDisplay } from '../../../../../../platform/agentHost/common/partialToolInput.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; @@ -1888,6 +1888,7 @@ const EXTERNAL_LINK_SCHEMES: ReadonlySet = new Set([ 'copilot-skill', product.urlProtocol, AGENT_HOST_SCHEME, + AGENT_HOST_SESSION_LINK_SCHEME, ]); /** diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index f58925dd0de11e..30d39f2b5d7ec3 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -2019,7 +2019,7 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces if (!scopedContextKeyService) { scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget)); chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString()); - setResourceContext(accessor, scopedContextKeyService, attachment.value.uri); + instantiationService.invokeFunction(accessor => setResourceContext(accessor, scopedContextKeyService!, attachment.value.uri)); } return scopedContextKeyService; }; diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 0a18ce259f074d..fc31c33734e72e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1563,7 +1563,7 @@ configurationRegistry.registerConfiguration({ }, [AgentHostMapLegacySettingsToManagedSettingsSettingId]: { type: 'boolean', - markdownDescription: nls.localize('chat.agentHost.copilot.mapLegacySettingsToManagedSettings', "When enabled, maps supported legacy VS Code settings to equivalent Copilot SDK managed settings for local Agent Host sessions. This compatibility bridge is temporary and is not used for new settings."), + markdownDescription: nls.localize('chat.agentHost.copilot.mapLegacySettingsToManagedSettings', "When enabled, maps supported legacy VS Code settings to equivalent Copilot SDK managed settings for local Agent Host sessions. Only restrictions are mapped, and only from globally-scoped values — workspace and folder values are ignored. Applies to local sessions using the Copilot agent; remote hosts and other agents are unaffected. This compatibility bridge is temporary and is not used for new settings."), default: false, scope: ConfigurationScope.APPLICATION_MACHINE, tags: ['experimental', 'advanced'], diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index 9d86ade840fa9d..27aed8258b486c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -11,7 +11,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; @@ -27,7 +27,7 @@ import { IChatDebugService } from '../../common/chatDebugService.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { AgentHostAgentDebugLogEnabledSettingId, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING } from '../../common/promptSyntax/promptTypes.js'; import { IChatWidgetService } from '../chat.js'; -import { ViewState, IChatDebugEditorOptions, CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST } from './chatDebugTypes.js'; +import { ViewState, IChatDebugEditorOptions } from './chatDebugTypes.js'; import { ChatDebugFilterState, registerFilterMenuItems } from './chatDebugFilters.js'; import { isAgentHostSession } from './agentHostLogSources.js'; import { isChatDebugLoggingEnabledForSession, isWireLogLoggingEnabled, renderChatDebugLoggingDisabledMessage, renderWireLogLoggingDisabledMessage } from './chatDebugEnablement.js'; @@ -73,7 +73,6 @@ export class ChatDebugEditor extends EditorPane { private filterState: ChatDebugFilterState | undefined; private _scopedContextKeyService: IContextKeyService | undefined; - private _activeSessionIsAgentHostContextKey: IContextKey | undefined; /** * Shared overlay shown in place of a session sub-view (Logs, Flow Chart, @@ -100,7 +99,6 @@ export class ChatDebugEditor extends EditorPane { this.chatDebugService.endSession(sessionResource); } this.chatDebugService.activeSessionResource = undefined; - this._activeSessionIsAgentHostContextKey?.set(false); } constructor( @@ -126,7 +124,6 @@ export class ChatDebugEditor extends EditorPane { this.filterState = this._register(new ChatDebugFilterState()); const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.container)); this._scopedContextKeyService = scopedContextKeyService; - this._activeSessionIsAgentHostContextKey = CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.bindTo(scopedContextKeyService); this._register(registerFilterMenuItems(this.filterState, scopedContextKeyService)); // Create sub-views via DI @@ -375,7 +372,6 @@ export class ChatDebugEditor extends EditorPane { } this.chatDebugService.activeSessionResource = sessionResource; - this._activeSessionIsAgentHostContextKey?.set(isAgentHostSession(sessionResource)); if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { this.chatDebugService.invokeProviders(sessionResource); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts index 8c534c5a025f09..0bddeae1807ef0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -38,7 +38,6 @@ export const enum LogsViewMode { } export const CHAT_DEBUG_FILTER_ACTIVE = new RawContextKey('chatDebugFilterActive', false); -export const CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST = new RawContextKey('chatDebug.activeSessionIsAgentHost', false); export const CHAT_DEBUG_KIND_TOOL_CALL = new RawContextKey('chatDebug.kindToolCall', true); export const CHAT_DEBUG_KIND_MODEL_TURN = new RawContextKey('chatDebug.kindModelTurn', true); export const CHAT_DEBUG_KIND_PROMPT_DISCOVERY = new RawContextKey('chatDebug.kindPromptDiscovery', true); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 18681e3653dad0..c460ce93d1ba22 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -39,7 +39,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { defaultButtonStyles, defaultCheckboxStyles, defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { DomWidget } from '../../../../../platform/domWidget/browser/domWidget.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; -import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName, getQuotaReset, getQuotaUsage, QuotaUsageKind } from '../../../../services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { isNewUser } from './chatStatus.js'; @@ -245,9 +245,9 @@ export class ChatStatusDashboard extends DomWidget { // Premium chat included indicator (shown when premium chat is unlimited) const hasPremiumUnlimited = !!premiumChat?.unlimited; - const creditsUsed = hasPremiumUnlimited && !isPooledQuotaDepleted ? premiumChat?.creditsUsed : undefined; - if (typeof creditsUsed === 'number') { - this.createCreditsUsedIndicator(this.element, creditsUsed, premiumChat?.resetAt); + const premiumChatUsage = getQuotaUsage(premiumChat); + if (premiumChatUsage?.kind === QuotaUsageKind.CreditsUsed) { + this.createCreditsUsedIndicator(this.element, premiumChatUsage.creditsUsed, this.formatQuotaResetLabel(premiumChat)); } else if (hasPremiumUnlimited) { const includedTitle = this.chatEntitlementService.quotas.usageBasedBilling ? localize('includedTitleTBB', "Credits") @@ -299,7 +299,6 @@ export class ChatStatusDashboard extends DomWidget { const planName = compact ? getChatPlanName(this.chatEntitlementService.entitlement) : undefined; if (chatQuota || premiumChatQuota || completionsQuota) { - const resetLabel = this.formatGlobalResetLabel(); // Global quota callout (shown at the top, before quota indicators) const globalCalloutUpdater = this.createGlobalQuotaCallout(container); @@ -320,7 +319,7 @@ export class ChatStatusDashboard extends DomWidget { const chatLabel = this.chatEntitlementService.quotas.usageBasedBilling && this.chatEntitlementService.entitlement === ChatEntitlement.Free ? localize('creditsLabel', "Credits") : localize('chatsLabel', "Chat messages"); - chatQuotaIndicator = this.createQuotaIndicator(container, chatQuota, chatLabel, resetLabel, compact ? planName : undefined); + chatQuotaIndicator = this.createQuotaIndicator(container, chatQuota, chatLabel, this.formatQuotaResetLabel(chatQuota), compact ? planName : undefined); } let premiumChatQuotaIndicator: ((quota: IQuotaSnapshot | string) => void) | undefined; @@ -329,8 +328,7 @@ export class ChatStatusDashboard extends DomWidget { const premiumChatLabel = isUBB ? localize('creditsLabel', "Credits") : this.chatEntitlementService.quotas.additionalUsageEnabled ? localize('includedPremiumChatsLabel', "Included premium requests") : localize('premiumChatsLabel', "Premium requests"); - const premiumChatResetLabel = isUBB ? this.formatResetAtLabel(premiumChatQuota.resetAt) ?? resetLabel : resetLabel; - premiumChatQuotaIndicator = this.createQuotaIndicator(container, premiumChatQuota, premiumChatLabel, premiumChatResetLabel, compact ? planName : undefined); + premiumChatQuotaIndicator = this.createQuotaIndicator(container, premiumChatQuota, premiumChatLabel, this.formatQuotaResetLabel(premiumChatQuota), compact ? planName : undefined); } // Additional Budget indicator (overage bar, shown when overage_entitlement > 0) @@ -345,9 +343,10 @@ export class ChatStatusDashboard extends DomWidget { unlimited: false, entitlement: initialOverageEntitlement, quotaRemaining: Math.max(0, initialOverageEntitlement - overageCount), + resetAt: premiumChatQuota?.resetAt, }; const additionalBudgetLabel = localize('additionalBudgetLabel', "Additional Budget"); - additionalBudgetIndicator = this.createQuotaIndicator(container, overageSnapshot, additionalBudgetLabel, resetLabel, compact ? additionalBudgetLabel : undefined); + additionalBudgetIndicator = this.createQuotaIndicator(container, overageSnapshot, additionalBudgetLabel, this.formatQuotaResetLabel(overageSnapshot), compact ? additionalBudgetLabel : undefined); additionalBudgetElement = container.lastElementChild as HTMLElement; const isPremiumExhausted = premiumChatQuota && premiumChatQuota.percentRemaining <= 0; if (!isPremiumExhausted) { @@ -359,7 +358,7 @@ export class ChatStatusDashboard extends DomWidget { const showCompletions = !compact && completionsQuota && !completionsQuota.unlimited && completionsQuota.percentRemaining >= 0 && (!this.chatEntitlementService.quotas.usageBasedBilling || this.chatEntitlementService.entitlement === ChatEntitlement.Free); if (showCompletions) { - completionsQuotaIndicator = this.createQuotaIndicator(container, completionsQuota, localize('completionsLabel', "Inline Suggestions"), resetLabel, compact ? planName : undefined); + completionsQuotaIndicator = this.createQuotaIndicator(container, completionsQuota, localize('completionsLabel', "Inline Suggestions"), this.formatQuotaResetLabel(completionsQuota), compact ? planName : undefined); } // Update indicators from current quota state @@ -759,27 +758,19 @@ export class ChatStatusDashboard extends DomWidget { this.hoverService.hideHover(true); } - private formatResetAtLabel(resetAt: number | undefined): string | undefined { - if (!resetAt) { + private formatQuotaResetLabel(quota: IQuotaSnapshot | undefined): string | undefined { + const reset = getQuotaReset(quota, this.chatEntitlementService.quotas); + if (!reset) { return undefined; } - const resetDate = new Date(resetAt * 1000); - return localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(resetDate), this.timeFormatter.value.format(resetDate)); - } - private formatGlobalResetLabel(): string | undefined { - const { resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - if (!resetDate) { - return undefined; - } - return resetDateHasTime - ? localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(new Date(resetDate)), this.timeFormatter.value.format(new Date(resetDate))) - : localize('quotaResets', "Resets {0}", this.dateFormatter.value.format(new Date(resetDate))); + return reset.hasTime + ? localize('quotaResetsAt', "Resets {0} at {1}", this.dateFormatter.value.format(reset.date), this.timeFormatter.value.format(reset.date)) + : localize('quotaResets', "Resets {0}", this.dateFormatter.value.format(reset.date)); } - private createCreditsUsedIndicator(container: HTMLElement, creditsUsed: number, resetAt: number | undefined): void { + private createCreditsUsedIndicator(container: HTMLElement, creditsUsed: number, resetLabel: string | undefined): void { const isCompact = !!this.options?.compactQuotaLayout; - const resetLabel = this.formatResetAtLabel(resetAt) ?? this.formatGlobalResetLabel(); const resetValue = $('span.quota-reset'); if (resetLabel) { @@ -858,18 +849,21 @@ export class ChatStatusDashboard extends DomWidget { }; const showCredits = () => { - if (typeof currentQuota !== 'string' && currentQuota.entitlement) { - const total = currentQuota.entitlement; - const used = currentQuota.quotaRemaining !== undefined - ? total - currentQuota.quotaRemaining - : total * (100 - currentQuota.percentRemaining) / 100; - const usedFormatted = this.quotaCreditsFormatter.value.format(used); - const totalFormatted = this.quotaCreditsFormatter.value.format(total); - quotaValueText.textContent = localize('quotaCreditsDisplay', "{0} / {1}", usedFormatted, totalFormatted); - quotaValueSuffix.textContent = isCompact - ? localize('quotaLabelUsed', "{0} used", label) - : ` ${localize('quotaUsed', "used")}`; + if (typeof currentQuota === 'string') { + return; + } + + const usage = getQuotaUsage(currentQuota); + if (usage?.kind !== QuotaUsageKind.Percentage || usage.used === undefined || usage.total === undefined) { + return; } + + const usedFormatted = this.quotaCreditsFormatter.value.format(usage.used); + const totalFormatted = this.quotaCreditsFormatter.value.format(usage.total); + quotaValueText.textContent = localize('quotaCreditsDisplay', "{0} / {1}", usedFormatted, totalFormatted); + quotaValueSuffix.textContent = isCompact + ? localize('quotaLabelUsed', "{0} used", label) + : ` ${localize('quotaUsed', "used")}`; }; const hoverTarget = isCompact ? quotaValueText : quotaPercentage; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 0599367449235c..e8a7cefacbf938 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -8,7 +8,7 @@ import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '.. import { localize } from '../../../../../nls.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../../services/statusbar/browser/statusbar.js'; -import { ChatEntitlement, ChatEntitlementContextKeys, ChatEntitlementService, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementContextKeys, ChatEntitlementService, getQuotaReset, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { disposableLongTimeout, disposableTimeout } from '../../../../../base/common/async.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -237,20 +237,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu private getQuotaResetTime(): number | undefined { const quotas = this.chatEntitlementService.quotas; - - const premiumResetAt = quotas.premiumChat?.resetAt; - if (typeof premiumResetAt === 'number') { - return premiumResetAt * 1000; - } - - if (quotas.resetDate) { - const parsed = Date.parse(quotas.resetDate); - if (!isNaN(parsed)) { - return parsed; - } - } - - return undefined; + return getQuotaReset(quotas.premiumChat, quotas)?.date.getTime(); } private scheduleQuotaResetRefresh(): void { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index b724ac4ddcdab5..0666ca22f3733b 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -965,7 +965,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // Session is live; drop the connecting spinner so the mic reads as // recording when start() transitions to the Recording state. this._setPreparingModel(false); - this._voiceClientService.sendPttStart(this._maiTurnId); + this._voiceClientService.sendPttStart(this._maiTurnId, { hasActiveSession: false }); } /** diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index 68bb0801465011..dc189a52c0660f 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -36,6 +36,8 @@ export const SHOW_VOICE_MODE_ONBOARDING_COMMAND = 'agentsVoice.showOnboarding'; const DICTATION_ENABLED_SETTING = 'dictation.enabled'; /** Setting that enables Voice Mode; toggled off by "Disable". */ const VOICE_ENABLED_SETTING = 'agents.voice.enabled'; +/** Setting that shows the live voice transcript overlay; toggled from the menu. */ +const VOICE_SHOW_TRANSCRIPT_SETTING = 'agents.voice.showTranscript'; /** * "Select Microphone" entry shared by every dictation / Voice Mode mic button @@ -161,6 +163,21 @@ function createConfigureInstructionsAction(commandService: ICommandService, comm }); } +/** + * Checkable "Show Transcript" entry: a quick per-session toggle for the live + * voice transcript overlay. Reflects and flips `agents.voice.showTranscript`, + * which also serves as the user's default preference. + */ +function createToggleTranscriptAction(configurationService: IConfigurationService): IAction { + const shown = configurationService.getValue(VOICE_SHOW_TRANSCRIPT_SETTING) === true; + return toAction({ + id: 'chat.voiceMode.toggleTranscript', + label: localize('voiceMode.showTranscript', "Show Transcript"), + checked: shown, + run: () => configurationService.updateValue(VOICE_SHOW_TRANSCRIPT_SETTING, !shown), + }); +} + /** * Actions for the Voice Mode mic button context menu. Keybinding and feature * disabling are grouped separately from configuration and onboarding. @@ -170,6 +187,7 @@ export function getVoiceModeContextMenuActions(commandService: ICommandService, [ createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), createToggleButtonAction(configurationService, AgentsVoiceSettingId.ShowButton, 'chat.voiceMode.toggleButton', localize('voiceMode.button', "Voice Mode Button")), + createToggleTranscriptAction(configurationService), createDisableVoiceModeAction(commandService, configurationService), ], [ diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index 8c03f95c366bd8..3b871a02c4c18d 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -14,6 +14,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IVoiceClientService, IVoicePriorTimelineEntry, + IVoicePttStartOptions, IVoiceSessionContext, IVoiceTranscription, IVoiceAudioResponse, @@ -628,9 +629,9 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } - sendPttStart(turnId: string, passive: boolean = false): void { + sendPttStart(turnId: string, options: IVoicePttStartOptions): void { if (this._ws?.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify({ type: 'ptt_start', turn_id: turnId, ...(passive ? { passive: true } : {}) })); + this._ws.send(JSON.stringify({ type: 'ptt_start', turn_id: turnId, has_active_session: options.hasActiveSession, ...(options.passive ? { passive: true } : {}) })); } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts index 5ceeba8113a99c..233d09e72680ed 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts @@ -18,7 +18,7 @@ import { IThemeService } from '../../../../../platform/theme/common/themeService import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IMicCaptureService } from './micCaptureService.js'; import { ITtsPlaybackService } from './ttsPlaybackService.js'; -import { readVoiceGlowIntensity, resolveVoiceGlowColors, shouldRenderVoiceInputGlow } from './voiceGlow.js'; +import { readVoiceGlowIntensity, resolveVoiceGlowColors, shouldRenderVoiceInputGlow, VoiceGlowState } from './voiceGlow.js'; import { createVoiceGlowController, IVoiceGlowController } from './voiceGlowController.js'; import { IVoiceSessionController } from './voiceSessionController.js'; @@ -139,7 +139,12 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi const voiceState = voiceSessionController.voiceState.read(reader); const active = isActive.read(reader); const ownsVoice = isSurfaceOwner(reader); - if (shouldRenderVoiceInputGlow(connected, active, ownsVoice, voiceState)) { + // A muted mic isn't heard, so the listening rim would misleadingly react to + // the user's voice; treat muted-listening as idle (no glow) until unmuted. + // Only read the mute observable while listening, so idle/disconnected surfaces + // don't depend on it. + const glowState: VoiceGlowState = voiceState === 'listening' && voiceSessionController.isMuted.read(reader) ? 'idle' : voiceState; + if (shouldRenderVoiceInputGlow(connected, active, ownsVoice, glowState)) { startGlowAnimation(); } else { stopGlowAnimation(); @@ -177,7 +182,9 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); const listening = dom.$('span.listening'); - listening.textContent = localize('voiceMode.listening', "Listening..."); + listening.textContent = voiceSessionController.isMuted.read(reader) + ? localize('voiceMode.mutedUnmuteToSpeak', "Unmute to speak...") + : localize('voiceMode.listening', "Listening..."); transcriptOverlay.append(listening); transcriptScrollable.scanDomNode(); } else if (!showTranscript && voiceState === 'speaking') { diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 44877f27f9af53..3a1bb6e1eeef2b 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -213,6 +213,8 @@ export interface IVoiceSessionController { readonly isConnected: IObservable; readonly isConnecting: IObservable; readonly isReconnecting: IObservable; + /** Whether the user has muted the microphone while keeping the session connected. */ + readonly isMuted: IObservable; readonly pendingToolConfirmations: IObservable; /** The session resource that transcriptions will be sent to. undefined = active session. */ readonly targetSession: IObservable; @@ -245,6 +247,14 @@ export interface IVoiceSessionController { */ stopListening(source?: 'explicit' | 'internal'): void; + /** + * Mute or unmute the microphone without ending the session. While muted, + * captured audio is not forwarded to the backend so background noise or + * private speech never reaches transcription, but the WebSocket stays + * connected so the user can unmute and resume instantly. + */ + setMuted(muted: boolean): void; + /** * Hold hands-free auto-listen off until released. * @@ -380,6 +390,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private readonly _isReconnecting = observableValue(this, false); readonly isReconnecting: IObservable = this._isReconnecting; + /** User-facing microphone mute. When set, captured audio is not forwarded to + * the backend, but the session stays connected so the user can unmute and + * resume without a new handshake. Reset to `false` on (re)connect. */ + private readonly _isMuted = observableValue(this, false); + readonly isMuted: IObservable = this._isMuted; + /** Set when the connection closed terminally (e.g. another window took over * the session). Suppresses the reconnect display path so the controller * settles to a clean, restartable state instead of a stuck "Reconnecting...". @@ -1197,9 +1213,17 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Streaming PTT: send start/chunks/end as they arrive this._voiceEventDisposables.add(this.micCaptureService.onPttStart((passive) => { - this.voiceClientService.sendPttStart(this._pttCurrentTurnId, passive); + this.voiceClientService.sendPttStart(this._pttCurrentTurnId, { + hasActiveSession: this._hasSessionInProgress(), + passive, + }); })); this._voiceEventDisposables.add(this.micCaptureService.onPttAudioChunk(b64 => { + // While the user has muted the microphone, keep the session alive but + // drop captured audio so nothing reaches transcription / the backend. + if (this._isMuted.get()) { + return; + } this.voiceClientService.sendPttAudioChunk(b64); })); this._voiceEventDisposables.add(this.micCaptureService.onPttEnd(() => { @@ -1758,6 +1782,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._voiceAutorunDisposable.value = connectionDisposables; this.micCaptureService.isMuted = false; + this._isMuted.set(false, undefined); this._statusText.set('Hold to speak...', undefined); this._voiceState.set('idle', undefined); @@ -2463,6 +2488,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._resetTranscriptionTurn(); this._bargeInListenActive = false; this._isConnected.set(false, undefined); + this._isMuted.set(false, undefined); this._voiceState.set('idle', undefined); this._statusText.set('Tap to start', undefined); this._transcriptTurns.set([], undefined); @@ -3117,6 +3143,20 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } + setMuted(muted: boolean): void { + if (this._isMuted.get() === muted) { + return; + } + this._isMuted.set(muted, undefined); + // Stop the source stream too so muted audio is dropped immediately even + // while a push-to-talk press is in flight (the `onPttAudioChunk` gate is + // the durable guard because `micCaptureService.isMuted` is reset on each + // press). Muting does not tear down the session or the auto-listen loop, + // so unmuting resumes instantly. + this.micCaptureService.isMuted = muted; + this.logService.trace(`[voice] setMuted: ${muted}`); + } + stopListening(source: 'explicit' | 'internal' = 'explicit'): void { // Stop the current recording / auto-listen loop WITHOUT tearing down // the WebSocket. Any in-flight press is finished through the normal @@ -7367,6 +7407,27 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }; } + private _hasSessionInProgress(): boolean { + const activeSessionId = this._getActiveSessionId(); + if (!activeSessionId) { + return false; + } + + const session = this.agentSessionsService.model.sessions.find(session => + !session.isArchived() && session.resource.toString() === activeSessionId + ); + if (session) { + const model = this.chatService.getSession(session.resource); + return session.status === AgentSessionStatus.InProgress || (model !== undefined && this._getAgentStateInfo(model).state === 'thinking'); + } + for (const model of this.chatService.chatModels.get()) { + if (model.sessionResource.toString() === activeSessionId) { + return this._getAgentStateInfo(model).state === 'thinking'; + } + } + return false; + } + private _buildSessionContext(): IVoiceSessionContext { const oneHourAgo = Date.now() - 60 * 60 * 1000; const sessions = this.agentSessionsService.model.sessions.filter(s => { diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts index 954c4a03c003b3..a00123838b4d25 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts @@ -318,10 +318,9 @@ export interface IVoiceInputModePillOptions { } /** - * A single segmented control in the chat input that hosts both voice input modes: - * a Dictation segment (speech-to-text into the input) and a Voice Mode segment (live - * conversational agent). Only one mode can be active at a time — activating one stops - * the other. Both segments stay visible (when available) so users discover both modes. + * A single segmented control in the chat input that hosts Dictation and Voice Mode, + * including the connected-session listen or mute control. Only one input mode can be + * active at a time — activating one stops the other. */ export class VoiceInputModeActionViewItem extends BaseActionViewItem { @@ -329,8 +328,10 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { private _dictationCell: HTMLElement | undefined; private _voiceCell: HTMLElement | undefined; private _listenCell: HTMLElement | undefined; + private _muteCell: HTMLElement | undefined; private _dictationIcon: HTMLElement | undefined; private _listenIcon: HTMLElement | undefined; + private _muteIcon: HTMLElement | undefined; private _voiceBars: HTMLElement | undefined; private _voiceBarEls: HTMLElement[] = []; private _barAnimationFrame: number | undefined; @@ -364,6 +365,9 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._listenCell?.setAttribute('aria-label', this._listenCell.classList.contains('active') ? this._getLabelWithKeybinding(localize('voiceInputMode.stopListening', "Stop Listening"), ChatVoiceInputModeToggleListenAction.ID) : this._getLabelWithKeybinding(localize('voiceInputMode.startListening', "Start Listening"), ChatVoiceInputModeToggleListenAction.ID)); + this._muteCell?.setAttribute('aria-label', this._muteCell.classList.contains('active') + ? localize('voiceInputMode.unmuteMicrophone', "Unmute Microphone") + : localize('voiceInputMode.muteMicrophone', "Mute Microphone")); } constructor( @@ -401,12 +405,11 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._updateVoiceStateColors(container); this._register(this.themeService.onDidColorThemeChange(() => this._updateVoiceStateColors(container))); - // A masked 2-slot viewport ("slot machine reel"). The reel holds three cells: - // [ dictation ][ voice ][ listen ] + // A masked 2-slot viewport ("slot machine reel"). The reel holds four cells: + // [ dictation ][ voice ][ listen ][ mute ] // Disconnected → the reel shows slots 0..1 (dictation + voice-connect). - // Connected → the reel slides left one slot to show slots 1..2, so the voice - // cell takes the dictation cell's place (now animated + disconnect) - // and the listen toggle slides in from the right. + // Connected → the voice cell takes the dictation cell's place (now animated + // + disconnect) and either listen or mute occupies the second slot. const pill = dom.append(container, dom.$('.monaco-segmented-icon-toggle.chat-voice-input-mode')); this._reel = dom.append(pill, dom.$('.monaco-segmented-icon-toggle-reel.chat-voice-input-mode-reel')); @@ -509,6 +512,26 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { })); this._registerActivationKeys(this._listenCell, () => this._onClickListen()); + // --- Mute cell: microphone transport toggle for hands-free voice mode. --- + this._muteCell = dom.append(this._reel, dom.$('button.monaco-segmented-icon-toggle-cell.chat-voice-input-mode-cell.mute')); + this._muteCell.setAttribute('type', 'button'); + this._muteCell.setAttribute('role', 'button'); + this._muteIcon = dom.append(this._muteCell, dom.$('span.chat-voice-input-mode-icon')); + this._register(addMicButtonContextMenuListener( + this._muteCell, + () => getVoiceModeContextMenuActions(this.commandService, this.configurationService, this.keybindingService, VOICE_START_COMMAND_ID), + this.contextMenuService, + )); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this._muteCell, + () => this.voiceSessionController.isMuted.get() + ? localize('voiceInputMode.unmuteMicrophone', "Unmute Microphone") + : localize('voiceInputMode.muteMicrophone', "Mute Microphone"))); + this._register(dom.addDisposableListener(this._muteCell, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + this._onClickMute(); + })); + this._registerActivationKeys(this._muteCell, () => this._onClickMute()); + // Dictation activity: scoped to chat so editor and terminal dictation do not // animate this control. const dictationActive = observableFromEvent(this, @@ -560,30 +583,37 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { listening = connected && voiceState === 'listening'; speaking = connected && voiceState === 'speaking'; } - const voiceLive = listening || speaking; + // While muted the mic isn't heard, so the audio-reactive listening state + // would misleadingly react to the user's voice; render the calm idle-on + // wave instead until unmuted. Speaking (the assistant) is unaffected. + // Only read the mute observable while connected, mirroring how the state + // observables above are only read when voice is active. + const muted = sim === undefined && connected && this.voiceSessionController.isMuted.read(reader); + const micListening = listening && !muted; + const voiceLive = micListening || speaking; const voiceOn = connected || connecting; this._voiceLive = voiceLive; // First-use model download/load (real state only; simulations never prepare). const dictationBusy = sim === undefined && isDictationActive && dictationPreparing.read(reader); - // The dedicated listen (start/stop speaking) toggle shows in manual - // (non-hands-free) connected voice mode. In hands-free mode the auto-listen - // loop drives listening, so there is no listen cell. It keys off `connected` - // rather than `voiceOn` so a connect/reconnect renders as a single-cell - // spinner instead of a spinner beside an inert listen button. + // Connected Voice Mode always has one session control: manual mode shows + // start/stop listening, while hands-free mode shows mute/unmute. const showListen = connected && !handsFree; + const showMute = connected && handsFree; // Presence of each cell. The housing is a constant size; the absent cell // collapses its width to 0 (mask recenters) so icons slide into place. // - dictation: shown when NOT in voice mode (home menu / dictating) // - voice: shown unless dictation is actively recording // - listen: shown only in manual-connected voice mode + // - mute: shown only in hands-free connected voice mode const dictationPresent = dictationAvailable && !voiceOn; const voicePresent = voiceAvailable && !isDictating && !dictationBusy; const listenPresent = showListen; + const mutePresent = showMute; // Exactly one icon → single-icon view (the lone button fills the whole pill). - const presentCount = (dictationPresent ? 1 : 0) + (voicePresent ? 1 : 0) + (listenPresent ? 1 : 0); + const presentCount = (dictationPresent ? 1 : 0) + (voicePresent ? 1 : 0) + (listenPresent ? 1 : 0) + (mutePresent ? 1 : 0); container.classList.toggle('connected', voiceOn); container.classList.toggle('single', presentCount === 1); @@ -628,7 +658,7 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._voiceCell!.classList.toggle('on', voiceOn); this._voiceCell!.classList.toggle('connecting', connecting && !connected); this._voiceCell!.classList.toggle('idle-on', voiceOn && !voiceLive); - this._voiceCell!.classList.toggle('listening', listening); + this._voiceCell!.classList.toggle('listening', micListening); this._voiceCell!.classList.toggle('speaking', speaking); this._voiceCell!.setAttribute('aria-pressed', String(voiceOn)); // Simulated hover (walkthrough only) mirrors the real :hover disconnect preview. @@ -640,6 +670,12 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { this._listenCell!.classList.toggle('muted', !listening); this._listenCell!.setAttribute('aria-pressed', String(listening)); this._listenIcon!.className = `chat-voice-input-mode-icon ${ThemeIcon.asClassName(listening ? Codicon.personVoiceFilledCompact : Codicon.personVoiceCompact)}`; + + // Mute / unmute toggle: the glyph describes the action available. + this._muteCell!.classList.toggle('collapsed', !mutePresent); + this._muteCell!.classList.toggle('active', muted); + this._muteCell!.setAttribute('aria-pressed', String(muted)); + this._muteIcon!.className = `chat-voice-input-mode-icon ${ThemeIcon.asClassName(muted ? Codicon.mic : Codicon.mute)}`; this._updateAriaLabels(); // Audio-reactive bars only while live (and not hovering the disconnect preview). @@ -821,6 +857,13 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { } } + private _onClickMute(): void { + const controller = this.voiceSessionController; + if (controller.isConnected.get()) { + controller.setMuted(!controller.isMuted.get()); + } + } + /** Threshold (ms) separating a quick tap (toggle) from a press-and-hold (talk). */ private static readonly HOLD_THRESHOLD_MS = 180; diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts index 942c6f9a5d49c1..c287d97fad803c 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeContextKeys.ts @@ -11,8 +11,6 @@ const VoiceModeButtonShown = ContextKeyExpr.notEquals('config.agents.voice.showB /** Mirrors `ChatSpeechToTextConfigured` (built-in on-device dictation available). */ const DictationConfigured = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(ChatContextKeys.speechToTextConfigured.key))!; const DictationButtonShown = ContextKeyExpr.notEquals('config.dictation.showButton', false); -/** Voice Mode runs manual push-to-talk rather than hands-free auto-listen. */ -const HandsFreeDisabled = ContextKeyExpr.equals('config.agents.voice.handsFree', false); const VisibleVoiceMode = ContextKeyExpr.and(AGENTS_VOICE_ENABLED, VoiceModeButtonShown)!; const VisibleDictation = ContextKeyExpr.and(DictationConfigured, DictationButtonShown)!; @@ -21,8 +19,7 @@ const VisibleDictation = ContextKeyExpr.and(DictationConfigured, DictationButton * place when it would host at least two cells; otherwise the single standalone * control for the lone available mode is clearer: * - both dictation and Voice Mode are enabled (dictation + voice-connect cells), or - * - only Voice Mode is enabled in manual (non-hands-free) mode AND a session is - * active, so the voice-connection + listen cells both render. + * - Voice Mode is connected, so the voice-connection + listen/mute cells render. * In every other single-mode case the standalone controls (gated on the negation * below) take over. */ @@ -32,7 +29,7 @@ export const SegmentedVoiceInputModePillActive: ContextKeyExpression = ContextKe VisibleVoiceMode, ContextKeyExpr.or( VisibleDictation, - ContextKeyExpr.and(HandsFreeDisabled, AGENTS_VOICE_CONNECTED), + AGENTS_VOICE_CONNECTED, ), )!; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index 72dd05506aa108..a60b2eb843948e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -13,6 +13,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ILinkPresentationService } from '../../../../../../platform/dataChannel/common/dataChannel.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; @@ -172,7 +173,7 @@ export class ChatMarkdownDecorationsRenderer extends Disposable { this.renderFileWidget(content, href, a, store); } else if (href.startsWith('command:')) { this.injectKeybindingHint(a, href, this.keybindingService); - } else if (richLinksEnabled) { + } else if (richLinksEnabled || href.toLowerCase().startsWith(`${AGENT_HOST_SESSION_LINK_SCHEME}:`)) { this.richLinkDecorator.value.decorate(a, href, store); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts index 33668ac3fc5428..c8b845c5484ac4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatRichLink.ts @@ -169,7 +169,8 @@ const richLinkIcons: Readonly> = { commit: 'git-commit', file: 'file', folder: 'folder', - session: 'comment-discussion', + session: 'agent', + chat: 'comment-discussion', repository: 'repo', branch: 'git-branch', }; @@ -276,8 +277,6 @@ function hasLeadingLifecycleStatus(presentation: IChatLinkPresentation): boolean return statusKind === 'open' || statusKind === 'closed' || statusKind === 'notPlanned'; case 'pullRequest': return statusKind === 'open' || statusKind === 'closed' || statusKind === 'merged' || statusKind === 'draft'; - case 'session': - return statusKind !== undefined; default: return false; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css index f5a8a39b516564..da77f9dd6e92f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatRichLink.css @@ -314,81 +314,135 @@ line-height: inherit; } -.chat-rich-link[data-chat-rich-link-kind='session'] { +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) { align-items: center; gap: var(--vscode-spacing-size40); - padding: 1px var(--vscode-spacing-size60); - border-color: var(--vscode-chat-requestBorder, var(--vscode-input-border)); + max-width: 100%; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size100); + border-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border, transparent)); background: var(--vscode-button-secondaryBackground); - color: var(--vscode-descriptionForeground); + color: var(--vscode-button-secondaryForeground); + font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-regular); + line-height: 16px; } -.chat-rich-link[data-chat-rich-link-kind='session']:hover { - border-color: var(--vscode-chat-requestBorder, var(--vscode-input-border)); - background: var(--vscode-toolbar-hoverBackground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +):visited { + color: var(--vscode-button-secondaryForeground); } -.chat-rich-link[data-chat-rich-link-kind='session'] :is(.chat-rich-link-label, .chat-rich-link-title) { - color: inherit; - font-weight: var(--vscode-fontWeight-regular); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +):is(:hover, :active) { + border-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border, transparent)); + background: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); } -.chat-rich-link[data-chat-rich-link-kind='session'] .chat-rich-link-primary-status { - align-self: center; +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-icon { + display: inline-flex; + flex: 0 0 var(--vscode-codiconFontSize); align-items: center; - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); - margin: 0; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - font-size: 1em; - line-height: inherit; + justify-content: center; + width: var(--vscode-codiconFontSize); + height: var(--vscode-codiconFontSize); + color: inherit; + font-size: var(--vscode-codiconFontSize); + line-height: var(--vscode-codiconFontSize); } -.chat-rich-link[data-chat-rich-link-kind='session'] .chat-rich-link-primary-status .chat-rich-link-status-label { - display: none; +.interactive-item-container .value .rendered-markdown .chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-icon { + position: static; + top: auto; } -.chat-rich-link[data-chat-rich-link-kind='session'] .chat-rich-link-status-icon:not(.monaco-pixel-spinner, [hidden]) { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); - font-size: var(--vscode-codiconFontSize-compact); - line-height: var(--vscode-spacing-size120); +.interactive-item-container .value .rendered-markdown a.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) { + color: var(--vscode-button-secondaryForeground); } -.chat-rich-link .chat-rich-link-status-icon[hidden] { - display: none; +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) :is(.chat-rich-link-label, .chat-rich-link-title) { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; + color: inherit; + font-weight: inherit; + text-overflow: ellipsis; + white-space: nowrap; } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='neutral'] .chat-rich-link-primary-status, -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='success'] .chat-rich-link-primary-status, -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='pending'] .chat-rich-link-primary-status { - color: var(--vscode-descriptionForeground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) :is( + .chat-rich-link-detail, + .chat-rich-link-reference, + .chat-rich-link-changes, + .chat-rich-link-secondary-status +) { + display: none; } -.chat-rich-link[data-chat-rich-link-kind='session'] .monaco-pixel-spinner { - width: var(--vscode-spacing-size120); - height: var(--vscode-spacing-size120); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-primary-status { + display: none; + align-items: center; + height: var(--vscode-codiconFontSize-compact); + margin-left: 0; + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='warning'] .chat-rich-link-primary-status, -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='warning'] { - color: var(--vscode-list-warningForeground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +):is( + [data-chat-rich-link-status='pending'], + [data-chat-rich-link-status='warning'], + [data-chat-rich-link-status='error'] +) .chat-rich-link-primary-status { + display: inline-flex; } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='warning'] { - border-color: var(--vscode-list-warningForeground); - background-color: color-mix(in srgb, var(--vscode-list-warningForeground) 12%, transparent); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-primary-status .chat-rich-link-status-label { + display: none; } -.chat-rich-link[data-chat-rich-link-kind='session'][data-chat-rich-link-status='error'] .chat-rich-link-primary-status { - color: var(--vscode-errorForeground); +.chat-rich-link:is( + [data-chat-rich-link-kind='session'], + [data-chat-rich-link-kind='chat'] +) .chat-rich-link-primary-status :is( + .chat-rich-link-status-icon, + .monaco-pixel-spinner +) { + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + font-size: var(--vscode-codiconFontSize-compact); + line-height: var(--vscode-codiconFontSize-compact); } .hc-black .chat-rich-link, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css index 0b1839410f4583..703060386aeee6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/media/modelPicker.css @@ -139,6 +139,7 @@ } .chat-model-hover-warning-text, +.chat-model-hover-info-text, .chat-model-hover-promo-text { display: flex; gap: 6px; @@ -154,12 +155,14 @@ color: var(--vscode-notificationsWarningIcon-foreground); } +.chat-model-hover-info-text > .codicon, .chat-model-hover-promo-text > .codicon { flex-shrink: 0; color: var(--vscode-notificationsInfoIcon-foreground); } .chat-model-hover-warning-text p, +.chat-model-hover-info-text p, .chat-model-hover-promo-text p, .chat-model-hover-description p { margin: 0; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts index 96225a82357e5b..21a1543265f5a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerHover.ts @@ -13,6 +13,7 @@ import { Codicon } from '../../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; @@ -64,28 +65,20 @@ export function getModelHoverContent( if (!isAuto && model.metadata.warningText) { for (const message of Object.values(model.metadata.warningText)) { - const warningContainer = dom.$('.chat-model-hover-warning-text'); - warningContainer.appendChild(renderIcon(Codicon.warning)); - const warningMd = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); - const rendered = disposables.add(renderMarkdown(warningMd, { - actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, - })); - warningContainer.appendChild(rendered.element); - container.appendChild(warningContainer); + container.appendChild(createMessageBanner(message, 'chat-model-hover-warning-text', Codicon.warning, disposables, openerService)); + } + } + + if (!isAuto && model.metadata.infoText) { + for (const message of Object.values(model.metadata.infoText)) { + container.appendChild(createMessageBanner(message, 'chat-model-hover-info-text', Codicon.info, disposables, openerService)); } } if (promo) { - const promoContainer = dom.$('.chat-model-hover-promo-text'); - promoContainer.appendChild(renderIcon(Codicon.info)); const endsAtLabel = ILanguageModelChatMetadata.getPromoEndsAtLabel(promo.endsAt); const promoMessage = endsAtLabel ? promo.message + ' ' + endsAtLabel : promo.message; - const promoMd = new MarkdownString(promoMessage, { isTrusted: false, supportThemeIcons: true }); - const rendered = disposables.add(renderMarkdown(promoMd, { - actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, - })); - promoContainer.appendChild(rendered.element); - container.appendChild(promoContainer); + container.appendChild(createMessageBanner(promoMessage, 'chat-model-hover-promo-text', Codicon.info, disposables, openerService)); } let costInfoRendered = false; @@ -203,6 +196,21 @@ export function getModelHoverContent( return container.children.length > 0 ? { element: container, disposable: disposables } : undefined; } +/** + * Builds one bordered message banner (an icon plus a rendered markdown message) + * for the warning, info and promo notices shown at the top of the hover. + */ +function createMessageBanner(message: string, className: string, icon: ThemeIcon, disposables: DisposableStore, openerService: IOpenerService): HTMLElement { + const banner = dom.$(`.${className}`); + banner.appendChild(renderIcon(icon)); + const markdown = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); + const rendered = disposables.add(renderMarkdown(markdown, { + actionHandler: link => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, + })); + banner.appendChild(rendered.element); + return banner; +} + function appendCostSection(container: HTMLElement, pricing: string): void { const costSection = dom.$('.chat-model-hover-cost'); costSection.appendChild(dom.$('span', undefined, localize('models.cost', "Cost: {0}", pricing))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts index 88f704d1ecda47..ab53effbe91368 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts @@ -3,21 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; import { IAction, toAction } from '../../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { stripIcons } from '../../../../../../../base/common/iconLabels.js'; import * as semver from '../../../../../../../base/common/semver/semver.js'; +import Severity from '../../../../../../../base/common/severity.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { withSeverityPrefix } from '../../../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; import { getLanguageModelProviderDisplayName, IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { languageModelSourcePresentationRegistry } from '../../../../common/languageModelSourcePresentation.js'; import { getModelHoverContent } from './modelPickerHover.js'; -import { getPriceCategoryLabel, isMultiplierPricing } from './modelPickerPresentation.js'; +import { getPriceCategoryLabel, isAutoModel, isMultiplierPricing } from './modelPickerPresentation.js'; export function isVersionAtLeast(current: string, required: string): boolean { const currentSemver = semver.coerce(current); @@ -158,12 +162,33 @@ export function createModelAction( section, run: () => onSelect(model), }; - const ariaDescription = priceCategoryLabel + const baseDescription = priceCategoryLabel ? (textDescription ? textDescription + ' · ' + priceCategoryLabel : priceCategoryLabel) : undefined; + const notices = getNoticeAriaLabels(model); + const ariaDescription = notices.length > 0 + ? [baseDescription ?? textDescription, ...notices].filter((part): part is string => !!part).join(', ') + : baseDescription; return { action, ariaDescription }; } +/** + * Screen reader users never reach the rich hover, so its warning and info banners + * are folded into the row's accessible description, stripped of markdown and + * prefixed with their severity. + */ +function getNoticeAriaLabels(model: ILanguageModelChatMetadataAndIdentifier): string[] { + if (isAutoModel(model)) { + return []; + } + const toLabel = (message: string, severity: Severity): string => + withSeverityPrefix(stripIcons(renderAsPlaintext(new MarkdownString(message))), severity); + return [ + ...Object.values(model.metadata.warningText ?? {}).map(message => toLabel(message, Severity.Warning)), + ...Object.values(model.metadata.infoText ?? {}).map(message => toLabel(message, Severity.Info)), + ]; +} + export function getUnavailableReason( entry: IModelControlEntry, chatEntitlementService: IChatEntitlementService, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index a9af3ac24bece7..d97be275daa357 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -7,6 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IAction } from '../../../../../../base/common/actions.js'; +import { autorun } from '../../../../../../base/common/observable.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -197,6 +198,16 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } })); + // The managed sandbox floor is delivered by managed settings, not configuration, so it needs + // its own subscription to keep the visible harness list in sync. + this._register(autorun(reader => { + this.agentHostEnablementService.managedSandboxEnforced.read(reader); + this._updateAgentSessionItems(); + if (this.element) { + this.renderLabel(this.element); + } + })); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateAgentSessionItems())); this._updateAgentSessionItems(); @@ -302,11 +313,11 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { * {@link AgentSessionProviders.Local}. */ protected _getDefaultSessionType(): AgentSessionTarget { - return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()) as AgentSessionTarget; + return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()) as AgentSessionTarget; } protected _isVisible(type: AgentSessionTarget): boolean { - return isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService, this.workspaceContextService.getWorkspace()); + return isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.managedSandboxEnforced.get(), this.agentHostEnablementService.enabled.get()); } protected _isSessionTypeEnabled(type: AgentSessionTarget): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index b285bf15a04bdd..21f8c4c748b2c7 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -251,7 +251,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler } if (this.shouldReplaceEmptyLocalSession(this._sessionResource)) { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()); + const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (getChatSessionType(defaultResource) !== localChatSessionType) { let modelRef: IChatModelReference | undefined; try { @@ -276,7 +276,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler if (this.options.explicitSessionType === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveExplicitLocal' }); } else { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()); + const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (getChatSessionType(defaultResource) === localChatSessionType) { this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled' }); } else { @@ -321,7 +321,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler && this.options.explicitSessionType !== localChatSessionType && !!this.model && !this.model.hasRequests - && getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get()) !== localChatSessionType; + && getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, this.workspaceContextService.getWorkspace(), this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()) !== localChatSessionType; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 59d57de0c9f08d..5a77a716baccd3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -585,9 +585,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (sim === 'off' || sim === 'connecting' || sim === 'dictating') { return { connected: false, voiceState: 'idle', simulating: true }; } + const voiceState = this.voiceSessionController.voiceState.get() as VoiceGlowState; return { connected: this.voiceSessionController.isConnected.get(), - voiceState: this.voiceSessionController.voiceState.get() as VoiceGlowState, + // While muted the mic isn't heard; treat muted-listening as idle (no + // glow). Only check mute in the listening state so other states don't + // depend on it. + voiceState: voiceState === 'listening' && this.voiceSessionController.isMuted.get() ? 'idle' : voiceState, simulating: false, }; }; @@ -645,9 +649,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // glow. Idle renders none, so keeping the loop alive then would burn a // requestAnimationFrame callback every frame for nothing. React to // simulated states too, so the walkthrough commands light up the glow. + // A muted mic isn't heard, so the listening rim would misleadingly react + // to the user's voice; treat muted-listening as idle (no glow). The mute + // observable is only read in the listening state. const sim = this.voiceInputModeService.simulatedVoiceState.read(reader); const simGlow = sim === 'listening' || sim === 'speaking'; - if (!omniInputOpen && (simGlow || (connected && isGlowingVoiceState(voiceState)))) { + const liveGlow = connected && isGlowingVoiceState(voiceState) && !(voiceState === 'listening' && this.voiceSessionController.isMuted.read(reader)); + if (!omniInputOpen && (simGlow || liveGlow)) { startGlowAnimation(); } else { stopGlowAnimation(); @@ -782,7 +790,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); const listening = $('span.listening'); - listening.textContent = localize('voiceMode.listening', "Listening..."); + listening.textContent = this.voiceSessionController.isMuted.read(reader) + ? localize('voiceMode.mutedUnmuteToSpeak', "Unmute to speak...") + : localize('voiceMode.listening', "Listening..."); transcriptOverlay.append(listening); transcriptScrollable.scanDomNode(); } else if (!showTranscript && voiceState === 'speaking') { @@ -1297,11 +1307,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { */ private async acquireDefaultNewSession(token: CancellationToken): Promise { const workspace = this.workspaceContextService.getWorkspace(); - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get()); + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); if (defaultType === localChatSessionType) { return undefined; } - const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get()); + const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); try { return await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatViewPane#acquireDefaultNewSession'); } catch (error) { @@ -1337,7 +1347,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private shouldSkipRestoredLocalSession(sessionResource: URI, model: IChatModel): boolean { const workspace = this.workspaceContextService.getWorkspace(); - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get()); + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService, workspace, this.agentHostEnablementService.enabled.get(), undefined, this.agentHostEnablementService.managedSandboxEnforced.get()); return defaultType !== localChatSessionType && getChatSessionType(sessionResource) === localChatSessionType && !model.hasRequests; diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index 705c5300c9f767..797a594e46900f 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -6,9 +6,13 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +export const CHAT_DEBUG_HAS_ACTIVE_SESSION = new RawContextKey('chatDebug.hasActiveSession', false); +export const CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST = new RawContextKey('chatDebug.activeSessionIsAgentHost', false); + /** * The severity level of a chat debug log event. */ diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index b99e65f0827df4..dd5309060b4d70 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -11,10 +11,11 @@ import { Disposable, IDisposable, toDisposable } from '../../../../base/common/l import { ResourceMap } from '../../../../base/common/map.js'; import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; +import { CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST, CHAT_DEBUG_HAS_ACTIVE_SESSION, ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; import { isAgentHostTarget, localChatSessionType } from './chatSessionsService.js'; import { getChatSessionType } from './model/chatUri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { AgentHostAgentDebugLogMaxEventsSettingId } from './promptSyntax/promptTypes.js'; /** @@ -140,12 +141,31 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic /** Human-readable titles for imported sessions. */ private readonly _importedSessionTitles = new ResourceMap(); - activeSessionResource: URI | undefined; + private readonly _hasActiveSessionContextKey: IContextKey; + private readonly _activeSessionIsAgentHostContextKey: IContextKey; + private _activeSessionResource: URI | undefined; + + get activeSessionResource(): URI | undefined { + return this._activeSessionResource; + } + + set activeSessionResource(value: URI | undefined) { + this._activeSessionResource = value; + this._hasActiveSessionContextKey.set(value !== undefined); + this._activeSessionIsAgentHostContextKey.set(value ? isAgentHostTarget(getChatSessionType(value)) : false); + } constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + this._hasActiveSessionContextKey = CHAT_DEBUG_HAS_ACTIVE_SESSION.bindTo(contextKeyService); + this._activeSessionIsAgentHostContextKey = CHAT_DEBUG_ACTIVE_SESSION_IS_AGENT_HOST.bindTo(contextKeyService); + this._register(toDisposable(() => { + this._hasActiveSessionContextKey.reset(); + this._activeSessionIsAgentHostContextKey.reset(); + })); } /** Priority for deduplicating events with the same ID: lower = richer. */ diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index e23300dd17b4ab..ada948dabfce68 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -302,25 +302,26 @@ export function isSupportedChatFileScheme(accessor: ServicesAccessor, scheme: st * editor window. * * Virtual workspaces always default to {@link localChatSessionType}. Otherwise, - * when the agent host is enabled and `chat.defaultToCopilotHarness` is opted in, - * Agent Host Copilot CLI is the default. It falls back to the local harness - * when enabled, or to the first visible non-local provider. + * when the agent host is enabled and either `chat.defaultToCopilotHarness` is opted in or the + * agent sandbox is enforced by policy, Agent Host Copilot CLI is the default. It falls back to + * the local harness when enabled, or to the first visible non-local provider. */ export function getComputedDefaultSessionType( configurationService: IConfigurationService, chatSessionsService: Pick, workspace: IWorkspace, - agentHostEnabled: boolean + agentHostEnabled: boolean, + managedSandboxEnforced = false ): string { if (isVirtualWorkspace(workspace)) { return localChatSessionType; } - if (agentHostEnabled && configurationService.getValue(ChatConfiguration.DefaultToCopilotHarness)) { + if (agentHostEnabled && isCopilotHarnessDefault(configurationService, managedSandboxEnforced)) { return SessionType.AgentHostCopilot; } - if (isEditorLocalAgentEnabled(configurationService, workspace)) { + if (isEditorLocalAgentEnabled(configurationService, workspace, agentHostEnabled && managedSandboxEnforced)) { return localChatSessionType; } @@ -343,14 +344,15 @@ export function isNewChatSessionTypeUsable( chatSessionsService: Pick, workspace: IWorkspace, agentHostEnabled = true, + managedSandboxEnforced = false, ): boolean { if (sessionType === localChatSessionType) { - return isEditorLocalAgentEnabled(configurationService, workspace); + return isEditorLocalAgentEnabled(configurationService, workspace, agentHostEnabled && managedSandboxEnforced); } if (isAgentHostTarget(sessionType)) { return agentHostEnabled; } - return isVisibleEditorChatSessionType(sessionType, configurationService, chatSessionsService, workspace); + return isVisibleEditorChatSessionType(sessionType, configurationService, chatSessionsService, workspace, managedSandboxEnforced); } export interface IDefaultNewChatSessionTypeOptions { @@ -369,7 +371,8 @@ export function getDefaultNewChatSessionType( storageService: IStorageService, workspace: IWorkspace, agentHostEnabled: boolean, - options?: IDefaultNewChatSessionTypeOptions + options?: IDefaultNewChatSessionTypeOptions, + managedSandboxEnforced = false ): string { if (options?.explicitOverride) { return options.explicitOverride; @@ -379,16 +382,16 @@ export function getDefaultNewChatSessionType( return localChatSessionType; } - const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled); + const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); if (remembered) { return remembered; } - if (options?.currentSessionType && isNewChatSessionTypeUsable(options.currentSessionType, configurationService, chatSessionsService, workspace, agentHostEnabled)) { + if (options?.currentSessionType && isNewChatSessionTypeUsable(options.currentSessionType, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced)) { return options.currentSessionType; } - return getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, agentHostEnabled); + return getComputedDefaultSessionType(configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); } export function resolveDefaultNewChatSessionType( @@ -399,7 +402,9 @@ export function resolveDefaultNewChatSessionType( const chatSessionsService = accessor.get(IChatSessionsService); const storageService = accessor.get(IStorageService); const workspace = accessor.get(IWorkspaceContextService).getWorkspace(); - const agentHostEnabled = accessor.get(IAgentHostEnablementService).enabled.get(); + const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + const agentHostEnabled = agentHostEnablementService.enabled.get(); + const managedSandboxEnforced = agentHostEnablementService.managedSandboxEnforced.get(); if (options?.explicitOverride) { return { sessionType: options.explicitOverride }; @@ -409,18 +414,18 @@ export function resolveDefaultNewChatSessionType( return { sessionType: localChatSessionType }; } - const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled); + const remembered = getUsableRememberedSessionType(storageService, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced); if (remembered && remembered !== localChatSessionType) { return { sessionType: remembered }; } if (options?.currentSessionType === localChatSessionType && agentHostEnabled - && configurationService.getValue(ChatConfiguration.EditorPreferCopilotHarness)) { + && isCopilotHarnessPreferred(configurationService, managedSandboxEnforced)) { return { sessionType: SessionType.AgentHostCopilot }; } - return { sessionType: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options) }; + return { sessionType: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced) }; } function getUsableRememberedSessionType( @@ -429,9 +434,10 @@ function getUsableRememberedSessionType( chatSessionsService: Pick, workspace: IWorkspace, agentHostEnabled: boolean, + managedSandboxEnforced = false, ): string | undefined { const remembered = getRememberedSessionType(storageService); - return remembered && isNewChatSessionTypeUsable(remembered, configurationService, chatSessionsService, workspace, agentHostEnabled) ? remembered : undefined; + return remembered && isNewChatSessionTypeUsable(remembered, configurationService, chatSessionsService, workspace, agentHostEnabled, managedSandboxEnforced) ? remembered : undefined; } export function getDefaultNewChatSessionResource( @@ -440,9 +446,10 @@ export function getDefaultNewChatSessionResource( storageService: IStorageService, workspace: IWorkspace, agentHostEnabled: boolean, - options?: IDefaultNewChatSessionTypeOptions + options?: IDefaultNewChatSessionTypeOptions, + managedSandboxEnforced = false ): URI { - const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options); + const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, workspace, agentHostEnabled, options, managedSandboxEnforced); return getNewChatSessionResource(defaultType); } @@ -461,18 +468,52 @@ export function recordUserSelectedSessionType( } } -export function isEditorLocalAgentEnabled(configurationService: IConfigurationService, workspace: IWorkspace): boolean { - return isVirtualWorkspace(workspace) || (configurationService.getValue(ChatConfiguration.EditorLocalAgentEnabled) ?? true); +/** + * Whether new editor and panel chats should default to the Agent Host Copilot SDK. Enterprises + * whose managed settings mandate the SDK sandbox floor get this behavior without opting into + * `chat.defaultToCopilotHarness`. + */ +function isCopilotHarnessDefault(configurationService: IConfigurationService, managedSandboxEnforced = false): boolean { + return configurationService.getValue(ChatConfiguration.DefaultToCopilotHarness) === true + || managedSandboxEnforced; +} + +/** + * Whether the Agent Host Copilot SDK replaces the local harness whenever the local harness would + * otherwise be picked for a new chat. Implied by an enterprise-mandated sandbox floor. + */ +function isCopilotHarnessPreferred(configurationService: IConfigurationService, managedSandboxEnforced = false): boolean { + return configurationService.getValue(ChatConfiguration.EditorPreferCopilotHarness) === true + || managedSandboxEnforced; +} + +/** + * Whether the legacy local chat harness is offered. Virtual workspaces always keep it. Outside + * virtual workspaces, an enterprise-mandated sandbox floor retires it: the sandbox is implemented + * by the Agent Host, so the enterprise has declared these users governed. + */ +export function isEditorLocalAgentEnabled(configurationService: IConfigurationService, workspace: IWorkspace, managedSandboxEnforced = false): boolean { + if (isVirtualWorkspace(workspace)) { + return true; + } + + if (managedSandboxEnforced) { + return false; + } + + return configurationService.getValue(ChatConfiguration.EditorLocalAgentEnabled) ?? true; } export function isVisibleEditorChatSessionType( sessionType: string, configurationService: IConfigurationService, chatSessionsService: Pick, - workspace: IWorkspace + workspace: IWorkspace, + managedSandboxEnforced = false, + agentHostEnabled = true ): boolean { if (sessionType === localChatSessionType) { - return isEditorLocalAgentEnabled(configurationService, workspace) || getVisibleNonLocalEditorChatSessionTypes(configurationService, chatSessionsService, workspace).length === 0; + return isEditorLocalAgentEnabled(configurationService, workspace, agentHostEnabled && managedSandboxEnforced) || getVisibleNonLocalEditorChatSessionTypes(configurationService, chatSessionsService, workspace).length === 0; } if (sessionType === SessionType.CopilotCLI) { diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 6e96bb4bf8e398..7ce5c6cce710b5 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -325,6 +325,12 @@ export interface ILanguageModelChatMetadata { * The keys are warning categories (e.g. "data_retention") and the values are markdown strings. */ readonly warningText?: IStringDictionary; + /** + * Optional informational text to display in the model picker hover as an info banner. + * The keys are info categories (e.g. "model_relocated") and the values are markdown strings. + * Unlike {@link warningText}, these are neutral notices and never signal a problem with the model. + */ + readonly infoText?: IStringDictionary; /** * Optional promotional information for this model. A positive `discountPercent` * surfaces the full promotional UI; `0` is a message-only promo that features the diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index 8eb8eb8412c8bc..9eebf39766e7d8 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -600,6 +600,11 @@ export interface IVoiceFeedbackTranscriptTurn { readonly timestamp: string; } +export interface IVoicePttStartOptions { + readonly hasActiveSession: boolean; + readonly passive?: boolean; +} + export interface IVoiceClientService { readonly _serviceBrand: undefined; @@ -608,7 +613,7 @@ export interface IVoiceClientService { disconnect(): void; // --- Outbound messages --- - sendPttStart(turnId: string, passive?: boolean): void; + sendPttStart(turnId: string, options: IVoicePttStartOptions): void; sendPttAudioChunk(audio: string): void; sendPttEnd(): void; /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts index 04c082529e013d..99d6e74be38e66 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAllowSignedOutWhenUsableContribution.test.ts @@ -96,7 +96,7 @@ function setup(disposables: DisposableStore, settings: Record) const configurationService = new TestConfigurationService(settings); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); disposables.add(instantiationService.createInstance(AgentHostAllowSignedOutWhenUsableContribution)); return { agentHostService, configurationService }; } 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 ab5c59c9f53883..8060ae9072a788 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 @@ -775,7 +775,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv ...languageModelToolsServiceOverride, }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IProgressService, { withProgress: (_options: IProgressNotificationOptions, task: (progress: IProgress) => Promise) => task({ report: () => { } }) }); instantiationService.stub(IWorkspaceContextService, { getWorkbenchState: () => workspaceFolders.length > 1 ? WorkbenchState.WORKSPACE : workspaceFolders.length === 1 ? WorkbenchState.FOLDER : WorkbenchState.EMPTY, @@ -8778,7 +8778,7 @@ suite('AgentHostChatContribution', () => { test('setting gate prevents registration', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService } = createTestServices(disposables); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(false) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); // Contribution should exist but not have registered any agents @@ -8806,7 +8806,7 @@ suite('AgentHostChatContribution', () => { const { instantiationService, agentHostService, chatSessionContributions } = createTestServices( disposables, undefined, undefined, undefined, undefined, false, undefined, { [ChatAIDisabledSettingId]: false }); const enabled = observableValue('agentHostEnabled', true); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled, managedSandboxEnforced: constObservable(false) }); let progressStarts = 0; instantiationService.stub(IProgressService, { withProgress: (_options: IProgressNotificationOptions, task: (progress: IProgress) => Promise) => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index bbcdfa26540998..68d1bd7a8bd4fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -92,7 +92,7 @@ function setup(disposables: DisposableStore, settings: Record) const configurationService = new TestConfigurationService(settings); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }); disposables.add(instantiationService.createInstance(AgentHostCopilotCliSettingsContribution)); return { agentHostService }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index 1fc6b0fd472ce8..982d98c9d98be2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { OS, OperatingSystem } from '../../../../../../base/common/platform.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { observableValue, constObservable } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -216,7 +216,7 @@ function setup(disposables: DisposableStore, agentHostEnabled: boolean = true, r instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: observableValue('agentHostEnabled', agentHostEnabled) }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: observableValue('agentHostEnabled', agentHostEnabled), managedSandboxEnforced: constObservable(false) }); instantiationService.stub(IWorkbenchEnvironmentService, new class extends mock() { override readonly remoteAuthority = remoteAuthority; }()); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 2d9f98dd47adf0..db3724f8c51494 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -262,6 +262,8 @@ suite('stateToProgressAdapter', () => { rewriteAgentHostLinkTarget('C:relative', 'my-host'), rewriteAgentHostLinkTarget('git:foo', 'my-host'), rewriteAgentHostLinkTarget('urn:isbn:123', 'my-host'), + rewriteAgentHostLinkTarget('agent-host-session://copilotcli/session-1', 'my-host'), + rewriteAgentHostLinkTarget('agent-host-session://copilotcli/session-1?chat=chat-2', 'my-host'), ], [ 'vscode-browser://example.com', @@ -269,6 +271,8 @@ suite('stateToProgressAdapter', () => { 'C:relative', 'git:foo', 'urn:isbn:123', + 'agent-host-session://copilotcli/session-1', + 'agent-host-session://copilotcli/session-1?chat=chat-2', ], ); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 698e76d6bdf655..ccf26a7421e416 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -22,6 +22,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { SyncDescriptor } from '../../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { NullWorkbenchAssignmentService } from '../../../../../services/assignment/test/common/nullAssignmentService.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; @@ -92,7 +93,8 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); - collection.set(IChatDebugService, new ChatDebugServiceImpl(new TestConfigurationService())); + const contextKeyService = store.add(new MockContextKeyService()); + collection.set(IChatDebugService, store.add(new ChatDebugServiceImpl(new TestConfigurationService(), contextKeyService))); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index 0a982405397f37..fad846204b5267 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -41,6 +41,8 @@ function createEntitlementService(opts: { additionalUsageEnabled?: boolean; additionalUsageCount?: number; entitlement?: ChatEntitlement; + resetDate?: string; + resetDateHasTime?: boolean; }): IChatEntitlementService { return { _serviceBrand: undefined, @@ -58,6 +60,8 @@ function createEntitlementService(opts: { usageBasedBilling: opts.usageBasedBilling ?? opts.premiumChat?.usageBasedBilling, additionalUsageEnabled: opts.additionalUsageEnabled, additionalUsageCount: opts.additionalUsageCount, + resetDate: opts.resetDate, + resetDateHasTime: opts.resetDateHasTime, }, update: (_token: CancellationToken) => Promise.resolve(), onDidChangeSentiment: Event.None, @@ -93,6 +97,15 @@ function getQuotaLabels(element: HTMLElement): string[] { return Array.from(indicators).map(el => el.textContent ?? ''); } +function getQuotaResets(element: HTMLElement): [string, string][] { + const indicators = element.querySelectorAll('.quota-indicator:not(.included)'); + return Array.from(indicators).map(el => [ + el.querySelector('.quota-title > span:not(.quota-reset)')?.textContent ?? '', + // The time of day is locale and timezone dependent, so only its presence is asserted. + (el.querySelector('.quota-reset')?.textContent ?? '').replace(/ at .+$/, ' at