diff --git a/build/package-lock.json b/build/package-lock.json index d6d5a90b65955..627126191cfe2 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -5419,9 +5419,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index cfe982fb6b596..12a86ed2a60b1 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -15002,9 +15002,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 3c51ad5c67a81..a96326c5ef11a 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -43,6 +43,10 @@ export interface CopilotCLIModelInfo { readonly id: string; readonly name: string; readonly multiplier?: number; + readonly priceCategory?: string; + readonly inputCost?: number; + readonly outputCost?: number; + readonly cacheCost?: number; readonly maxInputTokens?: number; readonly maxOutputTokens?: number; readonly maxContextWindowTokens: number; @@ -152,18 +156,26 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { const [{ getAvailableModels }, authInfo] = await Promise.all([this.copilotCLISDK.getPackage(), this.copilotCLISDK.getAuthInfo()]); try { const models = await getAvailableModels(authInfo); - return models.map(model => ({ - id: model.id, - name: model.name, - multiplier: model.billing?.multiplier, - maxInputTokens: model.capabilities.limits.max_prompt_tokens, - maxOutputTokens: model.capabilities.limits.max_output_tokens, - maxContextWindowTokens: model.capabilities.limits.max_context_window_tokens, - supportsVision: model.capabilities.supports.vision, - supportsReasoningEffort: model.capabilities.supports.reasoningEffort, - defaultReasoningEffort: model.defaultReasoningEffort, - supportedReasoningEfforts: model.supportedReasoningEfforts, - } satisfies CopilotCLIModelInfo)); + return models.map(model => { + const tokenPrices = model.billing?.token_prices; + const normalizedPricing = normalizeTokenPricing(tokenPrices); + return { + id: model.id, + name: model.name, + multiplier: model.billing?.multiplier, + priceCategory: model.model_picker_price_category, + inputCost: normalizedPricing?.inputPrice, + outputCost: normalizedPricing?.outputPrice, + cacheCost: normalizedPricing?.cachePrice, + maxInputTokens: model.capabilities.limits.max_prompt_tokens, + maxOutputTokens: model.capabilities.limits.max_output_tokens, + maxContextWindowTokens: model.capabilities.limits.max_context_window_tokens, + supportsVision: model.capabilities.supports.vision, + supportsReasoningEffort: model.capabilities.supports.reasoningEffort, + defaultReasoningEffort: model.defaultReasoningEffort, + supportedReasoningEfforts: model.supportedReasoningEfforts, + } satisfies CopilotCLIModelInfo; + }); } catch (ex) { this.logService.error(`[CopilotCLISession] Failed to fetch models`, ex); // Clear cached promise so subsequent calls retry instead of @@ -208,6 +220,10 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { maxInputTokens: model.maxInputTokens ?? model.maxContextWindowTokens, maxOutputTokens: model.maxOutputTokens ?? 0, pricing: multiplier, + priceCategory: model.priceCategory, + inputCost: model.inputCost, + outputCost: model.outputCost, + cacheCost: model.cacheCost, multiplierNumeric: model.multiplier, isUserSelectable: true, configurationSchema: isReasoningEffortEnabled ? buildConfigurationSchema(model) : undefined, @@ -613,3 +629,24 @@ export function isEnabledForCopilotCLI(customization: { sessionTypes?: readonly return sessionTypes === undefined || sessionTypes.includes('copilotcli') || false; } +const AIC_DIVISOR = 1_000_000_000; +const TOKENS_PER_MILLION = 1_000_000; + +/** + * Converts raw billing token prices (nano-AICs with a batch_size) into + * normalized AICs per million tokens, matching the normalization in + * chatEndpoint.ts for non-CLI models. + */ +function normalizeTokenPricing(tokenPrices: { input_price?: number; output_price?: number; cache_price?: number; batch_size?: number } | undefined): { inputPrice: number; outputPrice: number; cachePrice: number | undefined } | undefined { + if (!tokenPrices || tokenPrices.input_price === undefined || tokenPrices.output_price === undefined) { + return undefined; + } + const batchSize = tokenPrices.batch_size ?? TOKENS_PER_MILLION; + const scale = TOKENS_PER_MILLION / batchSize; + return { + inputPrice: (tokenPrices.input_price / AIC_DIVISOR) * scale, + outputPrice: (tokenPrices.output_price / AIC_DIVISOR) * scale, + cachePrice: tokenPrices.cache_price !== undefined ? (tokenPrices.cache_price / AIC_DIVISOR) * scale : undefined, + }; +} + diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index bb3191614f063..9b48f60c1435c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -995,10 +995,11 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } if (models.status === 'fulfilled' && models.value.length > 0) { + const isUBB = !!this._authenticationService.copilotToken?.isUsageBasedBilling; const modelItems: vscode.ChatSessionProviderOptionItem[] = models.value.map(model => ({ id: model.id, name: model.name, - ...(model.billing?.multiplier !== undefined ? { description: `${model.billing.multiplier}x` } : {}), + ...(!isUBB && model.billing?.multiplier !== undefined ? { description: `${model.billing.multiplier}x` } : {}), })); if (!models.value.find(m => m.id === DEFAULT_MODEL_ID)) { modelItems.unshift({ id: DEFAULT_MODEL_ID, name: vscode.l10n.t('Auto'), description: vscode.l10n.t('Automatically select the best model') }); diff --git a/extensions/git/package-lock.json b/extensions/git/package-lock.json index fb117421b919a..6385d4c660533 100644 --- a/extensions/git/package-lock.json +++ b/extensions/git/package-lock.json @@ -167,9 +167,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@tokenizer/inflate": { diff --git a/extensions/github/package-lock.json b/extensions/github/package-lock.json index d6d6dcd964ec2..f6841582e21cc 100644 --- a/extensions/github/package-lock.json +++ b/extensions/github/package-lock.json @@ -141,9 +141,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@octokit/auth-token": { diff --git a/extensions/json-language-features/package-lock.json b/extensions/json-language-features/package-lock.json index 23e9c89d221a4..54774a0a7ad73 100644 --- a/extensions/json-language-features/package-lock.json +++ b/extensions/json-language-features/package-lock.json @@ -139,9 +139,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@types/node": { diff --git a/extensions/typescript-language-features/package-lock.json b/extensions/typescript-language-features/package-lock.json index 21a1849269409..96aa30c3a5e9b 100644 --- a/extensions/typescript-language-features/package-lock.json +++ b/extensions/typescript-language-features/package-lock.json @@ -146,9 +146,19 @@ } }, "node_modules/@nevware21/ts-utils": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.11.6.tgz", - "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.14.0.tgz", + "integrity": "sha512-WoeqTIXQ8WPhl+lD2NbMHoAQ4sJl0n7EoRoDmVJui//Usg512enl9q1fdbVobuZt3omnxnmVsDrNIvPBvFgddQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nevware21" + }, + { + "type": "other", + "url": "https://buymeacoffee.com/nevware21" + } + ], "license": "MIT" }, "node_modules/@types/node": { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index da9869f11f4c9..32d351043b088 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -1203,7 +1203,8 @@ export class InlineCompletionsModel extends Disposable { * Used for cross-file inline edits. */ public transplantCompletion(item: InlineSuggestionItem): void { - item.addRef(); + // No explicit addRef needed: `seedWithCompletion` creates a new `InlineCompletionsState` + // which calls `addRef` on every item it holds and pairs it with `removeRef` in dispose. transaction(tx => { this._source.seedWithCompletion(item, tx); this._isActive.set(true, tx); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 3ad6c3e0817da..66fe056c63ef5 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -5,32 +5,32 @@ import { assertNever } from '../../../../../base/common/assert.js'; import { AsyncIterableProducer } from '../../../../../base/common/async.js'; +import { CachedFunction } from '../../../../../base/common/cache.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { BugIndicatingError, onUnexpectedExternalError } from '../../../../../base/common/errors.js'; +import { groupByMap } from '../../../../../base/common/collections.js'; +import { BugIndicatingError, onUnexpectedError, onUnexpectedExternalError } from '../../../../../base/common/errors.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { isDefined } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; import { prefixedUuid } from '../../../../../base/common/uuid.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ISingleEditOperation } from '../../../../common/core/editOperation.js'; import { StringReplacement } from '../../../../common/core/edits/stringEdit.js'; -import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js'; +import { TextReplacement } from '../../../../common/core/edits/textEdit.js'; import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; -import { TextReplacement } from '../../../../common/core/edits/textEdit.js'; -import { InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider, PartialAcceptInfo, InlineCompletionsDisposeReason, LifetimeSummary, ProviderId, IInlineCompletionHint, InlineCompletionTriggerKind } from '../../../../common/languages.js'; +import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js'; +import { IInlineCompletionHint, InlineCompletion, InlineCompletionContext, InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletions, InlineCompletionsDisposeReason, InlineCompletionsProvider, InlineCompletionTriggerKind, LifetimeSummary, PartialAcceptInfo, ProviderId } from '../../../../common/languages.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { ITextModel } from '../../../../common/model.js'; import { fixBracketsInLine } from '../../../../common/model/bracketPairsTextModelPart/fixBrackets.js'; +import { EditDeltaInfo } from '../../../../common/textModelEditSource.js'; import { SnippetParser, Text } from '../../../snippet/browser/snippetParser.js'; import { ErrorResult, getReadonlyEmptyArray } from '../utils.js'; -import { groupByMap } from '../../../../../base/common/collections.js'; -import { DirectedGraph } from './graph.js'; -import { CachedFunction } from '../../../../../base/common/cache.js'; import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js'; -import { isDefined } from '../../../../../base/common/types.js'; -import { inlineCompletionIsVisible } from './inlineCompletionIsVisible.js'; -import { EditDeltaInfo } from '../../../../common/textModelEditSource.js'; -import { URI } from '../../../../../base/common/uri.js'; import { InlineSuggestionEditKind } from './editKind.js'; +import { DirectedGraph } from './graph.js'; +import { inlineCompletionIsVisible } from './inlineCompletionIsVisible.js'; import { InlineSuggestAlternativeAction } from './InlineSuggestAlternativeAction.js'; export type InlineCompletionContextWithoutUuid = Omit; @@ -659,6 +659,12 @@ export class InlineSuggestionList { item.reportEndOfLife(); } this.provider.disposeInlineCompletions(this.inlineSuggestions, reason); + } else if (this.refCount < 0) { + // Invariant: every addRef must be paired with exactly one removeRef. + // Going negative means a removeRef without a matching addRef somewhere. + onUnexpectedError(new BugIndicatingError( + `InlineSuggestionList (provider=${this.provider.providerId?.toString()}) refCount went negative (${this.refCount}) — more removeRef than addRef calls.` + )); } } } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 11b3407acd134..489df63e26c30 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -39,6 +39,7 @@ import { AgentHostTelemetryLevelConfigKey, AgentHostSessionSyncEnabledConfigKey, import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; +import { dirname } from '../../../base/common/resources.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -880,14 +881,15 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } catch { continue; } - const key = uri.toString(); + const grantUri = dirname(uri); + const key = grantUri.toString(); if (this._grantedCustomizationUris.has(key)) { continue; } this._grantedCustomizationUris.add(key); // Disposable is owned by the permission service; cleared on // connectionClosed. - this._permissionService.grantImplicitRead(this._address, uri); + this._permissionService.grantImplicitRead(this._address, grantUri); } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 43f25a7fc4e4f..5b85942cedbba 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -41,7 +41,7 @@ import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointSer import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { CopilotAgentSession, SessionWrapperFactory, type CopilotSdkMode, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toCustomizationAgentRefs, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { parsedPluginsEqual, toCustomizationAgentRefs, toSdkCustomAgents, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { ShellManager, createShellTools } from './copilotShellTools.js'; import { SessionCustomizationDiscovery } from './sessionCustomizationDiscovery.js'; @@ -1504,6 +1504,7 @@ export class CopilotAgent extends Disposable implements IAgent { mcpServers: toSdkMcpServers(plugins.flatMap(p => p.mcpServers)), customAgents, skillDirectories: toSdkSkillDirectories(plugins.flatMap(p => p.skills)), + instructionDirectories: toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)), systemMessage: COPILOT_AGENT_HOST_SYSTEM_MESSAGE, tools: [...shellTools, ...callbacks.clientTools], // Enable infinite sessions so the SDK provisions a workspace diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts index 551e4b45549b0..2df42810b5a2b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -126,11 +126,22 @@ export function toCustomizationAgentRefs(agents: readonly INamedPluginResource[] * The SDK expects directory paths; we extract the parent directory of each SKILL.md. */ export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] { + return toSdkResourceDirectories(skills); +} + +/** + * Converts parsed plugin instructions into the SDK's + * `instructionDirectories` config. + */ +export function toSdkInstructionDirectories(instructions: readonly INamedPluginResource[]): string[] { + return toSdkResourceDirectories(instructions); +} + +function toSdkResourceDirectories(resources: readonly INamedPluginResource[]): string[] { const seen = new Set(); const result: string[] = []; - for (const skill of skills) { - // SKILL.md parent directory is the skill directory - const dir = dirname(skill.uri.fsPath); + for (const resource of resources) { + const dir = dirname(resource.uri.fsPath); if (!seen.has(dir)) { seen.add(dir); result.push(dir); @@ -373,6 +384,7 @@ export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IPar mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })), skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })), agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })), + instructions: p.instructions.map(i => ({ uri: i.uri.toString(), name: i.name })), }))); }; return serialize(a) === serialize(b); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 39b11ed23bba1..b55117c9a9484 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -397,14 +397,7 @@ export class ProtocolServerHandler extends Disposable { this._clients.set(params.clientId, client); this._onDidChangeConnectionCount.fire(this._clients.size); - disposables.add(this._clientFileSystemProvider.registerAuthority(params.clientId, { - resourceList: (uri) => this._sendReverseRequest(params.clientId, 'resourceList', { uri: uri.toString() }), - resourceRead: (uri) => this._sendReverseRequest(params.clientId, 'resourceRead', { uri: uri.toString() }), - resourceWrite: (params_) => this._sendReverseRequest(params.clientId, 'resourceWrite', params_), - resourceDelete: (params_) => this._sendReverseRequest(params.clientId, 'resourceDelete', params_), - resourceMove: (params_) => this._sendReverseRequest(params.clientId, 'resourceMove', params_), - resourceRequest: (params_) => this._sendReverseRequest(params.clientId, 'resourceRequest', params_), - })); + this._registerClientFileSystemAuthority(params.clientId, disposables); const snapshots: IStateSnapshot[] = []; @@ -518,6 +511,13 @@ export class ProtocolServerHandler extends Disposable { this._clients.set(params.clientId, client); this._onDidChangeConnectionCount.fire(this._clients.size); + // Re-establish the reverse-RPC filesystem authority for this client. + // The prior transport's `onClose` disposed the previous registration, + // so without this step any subsequent `resourceRead` / `resourceWrite` + // / etc. from the agent host would fail with "no connection registered + // for authority" until the client disconnected and re-initialized. + this._registerClientFileSystemAuthority(params.clientId, disposables); + const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; const canReplay = params.lastSeenServerSeq >= oldestBuffered; @@ -525,6 +525,25 @@ export class ProtocolServerHandler extends Disposable { return { client, responsePromise }; } + /** + * Wires the reverse-RPC filesystem callbacks for `clientId` and binds + * the unregister to `disposables` (the transport's per-connection + * store). The callbacks dispatch through {@link _sendReverseRequest}, + * which looks up the *current* connected client by id — so re-binding + * after a reconnect picks up the new transport without rebuilding the + * closures. + */ + private _registerClientFileSystemAuthority(clientId: string, disposables: DisposableStore): void { + disposables.add(this._clientFileSystemProvider.registerAuthority(clientId, { + resourceList: (uri) => this._sendReverseRequest(clientId, 'resourceList', { uri: uri.toString() }), + resourceRead: (uri) => this._sendReverseRequest(clientId, 'resourceRead', { uri: uri.toString() }), + resourceWrite: (params_) => this._sendReverseRequest(clientId, 'resourceWrite', params_), + resourceDelete: (params_) => this._sendReverseRequest(clientId, 'resourceDelete', params_), + resourceMove: (params_) => this._sendReverseRequest(clientId, 'resourceMove', params_), + resourceRequest: (params_) => this._sendReverseRequest(clientId, 'resourceRequest', params_), + })); + } + /** * Re-establish each of the client's prior subscriptions on the server side. * Uses {@link IAgentService.subscribe} (rather than a bare `addSubscriber` 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 3fb95f2428372..81e984968aaf9 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -643,7 +643,7 @@ suite('RemoteAgentHostProtocolClient', () => { tools: [], customizations: [ { uri: 'file:///plugins/foo', displayName: 'Foo' }, - { uri: 'file:///plugins/bar', displayName: 'Bar' }, + { uri: 'file:///other/bar', displayName: 'Bar' }, ] }, }); @@ -651,12 +651,35 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual( calls.map(c => ({ address: c.address, uri: c.uri.toString() })), [ - { address: 'test.example:1234', uri: 'file:///plugins/foo' }, - { address: 'test.example:1234', uri: 'file:///plugins/bar' }, + { address: 'test.example:1234', uri: 'file:///plugins' }, + { address: 'test.example:1234', uri: 'file:///other' }, ], ); }); + test('multiple customizations in the same directory dedupe to one grant', () => { + const { service, calls } = createCapturingPermissionService(); + const { client } = createClient(undefined, service); + const sessionUri = URI.parse('ahp-session:/test'); + + client.dispatch(sessionUri.toString(), { + type: ActionType.SessionActiveClientChanged, + activeClient: { + clientId: 'c1', + tools: [], + customizations: [ + { uri: 'file:///plugins/foo', displayName: 'Foo' }, + { uri: 'file:///plugins/bar', displayName: 'Bar' }, + ] + }, + }); + + assert.deepStrictEqual( + calls.map(c => c.uri.toString()), + ['file:///plugins'], + ); + }); + test('repeat dispatch dedupes per URI', () => { const { service, calls } = createCapturingPermissionService(); const { client } = createClient(undefined, service); @@ -715,7 +738,7 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual( calls.map(c => c.uri.toString()), - ['file:///plugins/foo'], + ['file:///plugins'], ); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts index 3ef7bb522e35d..8f2eb15960d75 100644 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts @@ -15,7 +15,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; -import { toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js'; +import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js'; import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; suite('copilotPluginConverters', () => { @@ -242,6 +242,35 @@ suite('copilotPluginConverters', () => { // ---- toSdkHooks ------------------------------------------------------- + suite('toSdkInstructionDirectories', () => { + + test('extracts parent directories of instruction files', () => { + const instructions: INamedPluginResource[] = [ + { uri: URI.file('/plugins/rules/project.mdc'), name: 'project' }, + { uri: URI.file('/plugins/rules/review.instructions.md'), name: 'review' }, + ]; + const result = toSdkInstructionDirectories(instructions); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].replaceAll('\\', '/'), '/plugins/rules'); + }); + + test('deduplicates directories', () => { + const instructions: INamedPluginResource[] = [ + { uri: URI.file('/plugins/rules/a.mdc'), name: 'a' }, + { uri: URI.file('/plugins/rules/b.mdc'), name: 'b' }, + ]; + const result = toSdkInstructionDirectories(instructions); + assert.strictEqual(result.length, 1); + }); + + test('handles empty input', () => { + const result = toSdkInstructionDirectories([]); + assert.deepStrictEqual(result, []); + }); + }); + + // ---- toSdkHooks ------------------------------------------------------- + suite('toSdkHooks', () => { function makeHookGroup(type: string, command: string): IParsedHookGroup { @@ -355,6 +384,7 @@ suite('copilotPluginConverters', () => { mcpServers: [], skills: [], agents: [], + instructions: [], ...overrides, }; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 6a0b932656293..1dc341eb3dc3a 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -10,17 +10,18 @@ import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; +import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { CompletionsParams, CompletionsResult, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; -import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js'; +import { AgentHostFileSystemProvider, agentHostUri } from '../../common/agentHostFileSystemProvider.js'; import { iterateOtlpLogRecords, OtlpLogEmitter } from '../../common/otlp/otlpLogEmitter.js'; // ---- Mock helpers ----------------------------------------------------------- @@ -187,6 +188,7 @@ suite('ProtocolServerHandler', () => { let server: MockProtocolServer; let agentService: MockAgentService; let handler: ProtocolServerHandler; + let fileSystemProvider: AgentHostFileSystemProvider; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); @@ -225,7 +227,7 @@ suite('ProtocolServerHandler', () => { stateManager, server, { defaultDirectory: URI.file('/home/testuser').toString() }, - disposables.add(new AgentHostFileSystemProvider()), + disposables.add(fileSystemProvider = new AgentHostFileSystemProvider()), new NullLogService(), )); }); @@ -741,6 +743,43 @@ suite('ProtocolServerHandler', () => { assert.ok(stateManager.getSnapshot(sessionUri), 'state should have been re-hydrated by reconnect'); }); + test('reconnect re-registers the reverse-RPC filesystem authority', async () => { + // The server-side filesystem provider talks back to the client via + // reverse-RPC (e.g. `resourceList`). If the authority is not + // re-registered on reconnect, the agent host would fail with + // "No connection for authority: " until the client + // reinitialized. Verify a reverse-RPC routes through the new + // transport after reconnect. + const transport1 = connectClient('client-fs'); + transport1.simulateClose(); + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectRespPromise = waitForResponse(transport2, 1); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-fs', + lastSeenServerSeq: 0, + subscriptions: [], + })); + await reconnectRespPromise; + transport2.sent.length = 0; + + // Wire the test's response *before* we trigger the reverse-RPC so + // the response is observed on the next microtask. + disposables.add(transport2.onDidSend(msg => { + if (isJsonRpcRequest(msg) && msg.method === 'resourceList') { + transport2.simulateMessage({ + jsonrpc: '2.0', + id: msg.id, + result: { entries: [{ name: 'after-reconnect.txt', type: 'file' as const }] }, + }); + } + })); + + const result = await fileSystemProvider.readdir(agentHostUri('client-fs', '/workspace')); + assert.deepStrictEqual(result, [['after-reconnect.txt', FileType.File]]); + }); + test('client disconnect cleans up', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index 12c5942e8f9e6..b084826fd0114 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -74,6 +74,7 @@ export interface IParsedPlugin { readonly mcpServers: readonly IMcpServerDefinition[]; readonly skills: readonly INamedPluginResource[]; readonly agents: readonly INamedPluginResource[]; + readonly instructions: readonly INamedPluginResource[]; } // --------------------------------------------------------------------------- @@ -651,6 +652,8 @@ export async function pathExists(resource: URI, fileService: IFileService): Prom // --------------------------------------------------------------------------- const COMMAND_FILE_SUFFIX = '.md'; +const RULE_FILE_SUFFIX = '.mdc'; +const INSTRUCTION_FILE_SUFFIX = '.instructions.md'; export async function readSkills(pluginRoot: URI, dirs: readonly URI[], fileService: IFileService): Promise { const seen = new Set(); @@ -740,6 +743,71 @@ export async function readMarkdownComponents(dirs: readonly URI[], fileService: return items; } +function getInstructionFileName(resource: URI): string | undefined { + const fileName = basename(resource); + const lowerName = fileName.toLowerCase(); + if (lowerName.endsWith(RULE_FILE_SUFFIX)) { + return fileName.slice(0, -RULE_FILE_SUFFIX.length); + } + if (lowerName.endsWith(INSTRUCTION_FILE_SUFFIX)) { + return fileName.slice(0, -INSTRUCTION_FILE_SUFFIX.length); + } + return undefined; +} + +/** + * Reads rule/instruction files from plugin `rules` component directories. + * + * Open Plugins rules are conventionally `.mdc` files. We also accept + * `.instructions.md` for compatibility with VS Code-discovered instructions + * bundled as synthetic plugins. + */ +export async function readInstructionComponents(dirs: readonly URI[], fileService: IFileService): Promise { + const seen = new Set(); + const items: INamedPluginResource[] = []; + + const addItem = (name: string, uri: URI) => { + if (!seen.has(name)) { + seen.add(name); + items.push({ uri, name }); + } + }; + + for (const dir of dirs) { + let stat; + try { + stat = await fileService.resolve(dir); + } catch { + continue; + } + + if (stat.isFile) { + const instructionName = getInstructionFileName(dir); + if (instructionName) { + addItem(instructionName, dir); + } + continue; + } + + if (!stat.isDirectory || !stat.children) { + continue; + } + + for (const child of stat.children) { + if (!child.isFile) { + continue; + } + const instructionName = getInstructionFileName(child.resource); + if (instructionName) { + addItem(instructionName, child.resource); + } + } + } + + items.sort((a, b) => a.name.localeCompare(b.name)); + return items; +} + /** * Reads `.md` files in agent directories and enriches each entry with * the optional `name` / `description` from YAML frontmatter. Falls back @@ -850,7 +918,8 @@ export function parseMcpServerDefinitionMap( // --------------------------------------------------------------------------- /** - * Parses a plugin directory to extract hooks, MCP servers, skills, and agents. + * Parses a plugin directory to extract hooks, MCP servers, skills, agents, + * and instructions. * This is the main entry point for the agent host to discover plugin contents. */ export async function parsePlugin( @@ -871,6 +940,7 @@ export async function parsePlugin( const mcpDirs = resolveComponentDirs(pluginUri, '.mcp.json', parseComponentPathConfig(manifest?.['mcpServers']), boundaryUri); const skillDirs = resolveComponentDirs(pluginUri, 'skills', parseComponentPathConfig(manifest?.['skills']), boundaryUri); const agentDirs = resolveComponentDirs(pluginUri, 'agents', parseComponentPathConfig(manifest?.['agents']), boundaryUri); + const instructionDirs = resolveComponentDirs(pluginUri, 'rules', parseComponentPathConfig(manifest?.['rules']), boundaryUri); // Handle embedded MCP servers in manifest let embeddedMcp: IMcpServerDefinition[] = []; @@ -892,7 +962,7 @@ export async function parsePlugin( embeddedHooks = formatConfig.parseHooks(manifestUri, { hooks: hooksSection }, pluginUri, workspaceRoot, userHome); } - const [hooks, mcpServers, skills, agents] = await Promise.all([ + const [hooks, mcpServers, skills, agents, instructions] = await Promise.all([ embeddedHooks.length > 0 ? Promise.resolve(embeddedHooks) : readHooks(pluginUri, hookDirs, formatConfig, fileService, workspaceRoot, userHome), @@ -901,8 +971,9 @@ export async function parsePlugin( : readMcpServers(mcpDirs, pluginUri.fsPath, formatConfig, fileService), readSkills(pluginUri, skillDirs, fileService), readAgentComponents(agentDirs, fileService), + readInstructionComponents(instructionDirs, fileService), ]); - return { hooks, mcpServers, skills, agents }; + return { hooks, mcpServers, skills, agents, instructions }; } diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 59aad2d7dab8e..7efff855095b2 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -29,6 +29,8 @@ import { Menus } from '../menus.js'; import { LocalSelectionTransfer } from '../../../platform/dnd/browser/dnd.js'; import { DraggedSessionIdentifier, SessionsDataTransfers } from '../dnd.js'; import { applyDragImage } from '../../../base/browser/ui/dnd/dnd.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; interface IChatTab { readonly chat: IChat; @@ -76,6 +78,7 @@ export class ChatCompositeBar extends Disposable { @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IQuickInputService private readonly _quickInputService: IQuickInputService, + @IHoverService private readonly _hoverService: IHoverService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); @@ -226,6 +229,13 @@ export class ChatCompositeBar extends Disposable { })); tab.appendChild(labelEl); + // Delayed hover showing the full chat title (useful when the title is truncated) + this._tabDisposables.add(this._hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + tab, + () => chat.title.get(), + )); + // Track untitled state for styling (dirty dot + close button) this._tabDisposables.add(autorun(reader => { const status = chat.status.read(reader); diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 192c1461f634b..8dd5b8d7d9e15 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -11,6 +11,7 @@ height: 35px; flex-shrink: 0; overflow: hidden; + container-type: inline-size; } /* The ScrollableElement wrapper holding the tabs is the shrinkable flex item */ @@ -49,7 +50,6 @@ position: relative; padding: 0 8px; cursor: pointer; - white-space: nowrap; color: var(--chat-tab-inactive-foreground); font-weight: 500; font-size: 12px; @@ -58,6 +58,15 @@ border-radius: 4px; user-select: none; flex-shrink: 0; + min-width: 44px; + max-width: min(200px, 40cqi); +} + +.chat-composite-bar-tab-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .chat-composite-bar-tab:hover { diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index 04926331fc9b4..95788aba68321 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -112,6 +112,58 @@ export function logChangesViewReviewCommentAdded(telemetryService: ITelemetrySer telemetryService.publicLog2('vscodeAgents.changesView/reviewCommentAdded', data); } +// --- Tunnel agent host discovery --- + +export type TunnelDiscoveryTrigger = + | 'startup' + | 'rediscover' + | 'sessionChange'; + +type TunnelDiscoveryResultEvent = { + trigger: string; + totalFound: number; + withActiveHost: number; + cachedBefore: number; + autoConnectEnabled: boolean; + hostsEnabled: boolean; + success: boolean; +}; + +type TunnelDiscoveryResultClassification = { + owner: 'osortega'; + comment: 'Tracks the outcome of agent-host tunnel discovery so we can diagnose stuck-after-discovery scenarios where tunnels are found but no providers ever appear.'; + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What initiated the discovery (startup, rediscover, sessionChange).' }; + totalFound: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of tunnels returned by the embedder after the protocol-version filter.' }; + withActiveHost: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of discovered tunnels that have a host process currently connected (hostConnectionCount > 0).' }; + cachedBefore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of tunnels in the local recent-tunnels cache before this discovery run.' }; + autoConnectEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat.remoteAgentHostsAutoConnect is enabled.' }; + hostsEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether chat.remoteAgentHostsEnabled is enabled.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the discovery call itself completed (false when listTunnels threw).' }; +}; + +export function logTunnelDiscoveryResult( + telemetryService: ITelemetryService, + data: { + trigger: TunnelDiscoveryTrigger; + totalFound: number; + withActiveHost: number; + cachedBefore: number; + autoConnectEnabled: boolean; + hostsEnabled: boolean; + success: boolean; + }, +): void { + telemetryService.publicLog2('vscodeAgents.tunnelDiscovery/result', { + trigger: data.trigger, + totalFound: data.totalFound, + withActiveHost: data.withActiveHost, + cachedBefore: data.cachedBefore, + autoConnectEnabled: data.autoConnectEnabled, + hostsEnabled: data.hostsEnabled, + success: data.success, + }); +} + // --- Tunnel agent host connect --- export type TunnelConnectErrorCategory = diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 8e2320b65bf1d..82d209099614e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../ import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../../../../platform/agentHost/common/changesetUri.js'; import { KNOWN_AUTO_APPROVE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentSelection, CustomizationAgentRef, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentSelection, CustomizationAgentRef, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type ChangesetSummary } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChangesetState, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -43,6 +43,7 @@ import { IGitHubService } from '../../../github/browser/githubService.js'; import { changesetFilesToChanges, mapProtocolStatus } from './agentHostDiffs.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { createChangesets } from '../../copilotChatSessions/browser/copilotChatSessionsChangesets.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; // ============================================================================ // AgentHostSessionAdapter — shared adapter for local and remote sessions @@ -517,6 +518,8 @@ interface INewSessionConstructionContext { * takes over ownership of the same `sessionId` key. */ readonly onSessionState?: (sessionId: string, state: SessionState | undefined) => void; + /** Initial active-client snapshot for the eager `createSession`. Drift is reconciled by the handler before the first message. */ + readonly activeClient?: SessionActiveClient; } /** @@ -595,6 +598,8 @@ class NewSession extends Disposable { private readonly _stateListener = this._register(new MutableDisposable()); private readonly _onSessionState: ((sessionId: string, state: SessionState | undefined) => void) | undefined; + private readonly _initialActiveClient: SessionActiveClient | undefined; + private readonly _logService: ILogService; private readonly _providerId: string; @@ -609,6 +614,7 @@ class NewSession extends Disposable { this._providerId = ctx.providerId; this._logService = ctx.logService; this._onSessionState = ctx.onSessionState; + this._initialActiveClient = ctx.activeClient; const resource = URI.from({ scheme: ctx.resourceScheme, path: `/${generateUuid()}` }); this._status = observableValue(this, SessionStatus.Untitled); @@ -816,6 +822,7 @@ class NewSession extends Disposable { workingDirectory: this.workspaceUri, config: this._config?.values, ...(this._selectedAgent ? { agent: { uri: this._selectedAgent.uri } } : {}), + ...(this._initialActiveClient ? { activeClient: this._initialActiveClient } : {}), }); } catch (err) { this._logService.warn(`[${this._providerId}] Eager createSession failed for ${backendUri.toString()}: ${err}`); @@ -1030,6 +1037,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @IGitHubService protected readonly _gitHubService: IGitHubService, @IInstantiationService protected readonly _instantiationService: IInstantiationService, @ISessionsManagementService protected readonly _sessionsManagementService: ISessionsManagementService, + @IAgentHostActiveClientService protected readonly _activeClientService: IAgentHostActiveClientService, ) { super(); @@ -1286,6 +1294,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // against the eagerly created agent-host record so it's freed // immediately rather than waiting for the server-side // empty-session GC. + const connection = this.connection; const newSession = new NewSession({ workspace, sessionType, @@ -1299,6 +1308,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement onSessionState: (id, state) => state === undefined ? this._handleNewSessionStateGone(id) : this._handleNewSessionStateUpdate(id, state), + activeClient: connection + ? this._activeClientService.getActiveClient(this.resourceSchemeForProvider(sessionType.id), connection.clientId) + : undefined, }); this._newSession = newSession; this._onDidChangeSessionConfig.fire(newSession.sessionId); @@ -1306,7 +1318,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Kick off the initial config resolve and the eager backend session // in parallel. Both are non-blocking; failures are surfaced through // the session's loading observable. - const connection = this.connection; if (connection) { void this._refreshNewSessionConfig(newSession); newSession.eagerCreate(connection); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index c3ffacc6dfdc4..d977487e66e2c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -28,6 +28,7 @@ import { ISessionsManagementService } from '../../../../services/sessions/common import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; @@ -47,6 +48,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; + private readonly _localLabel = localize('localAgentHostSessionTypeLocation', "Local"); private readonly _localDescription = new MarkdownString(this._localLabel); @@ -62,8 +64,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide @IGitHubService gitHubService: IGitHubService, @IInstantiationService instantiationService: IInstantiationService, @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IAgentHostActiveClientService activeClientService: IAgentHostActiveClientService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsManagementService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsManagementService, activeClientService); this.label = localize('localAgentHostLabel', "Local Agent Host"); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 770ea72cb1054..f6501ca37ac1b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -29,6 +29,7 @@ import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; import { SessionStatus } from '../../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; +import { IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -264,6 +265,9 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen instantiationService.stub(ISessionsManagementService, new class extends mock() { override readonly activeSession: IObservable = activeSessionObs; }()); + instantiationService.stub(IAgentHostActiveClientService, new class extends mock() { + override getActiveClient = (_sessionType: string, clientId: string) => ({ clientId, tools: [], customizations: [] }); + }()); return disposables.add(instantiationService.createInstance(LocalAgentHostSessionsProvider)); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index bb48f42457333..2bbe8cd738479 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -6,8 +6,6 @@ import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { disposableTimeout, IntervalTimer } from '../../../../../base/common/async.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; -import { Event } from '../../../../../base/common/event.js'; -import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import * as nls from '../../../../../nls.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; @@ -18,7 +16,7 @@ import { TunnelAgentHostsSettingId } from '../../../../../platform/agentHost/com import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/agentHost/common/agentHostPermissionService.js'; import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { type AgentInfo, type CustomizationRef, type RootState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { type AgentInfo, type RootState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -28,22 +26,18 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { OpenSessionEventsFileAction } from '../../agentHost/browser/openSessionEventsFileActions.js'; -import { AgentCustomizationSyncProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { LoggingAgentConnection } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.js'; import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; -import { IPromptsService } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { resolveCustomizationRefs } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.js'; import { IAgentHostFileSystemService } from '../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -53,7 +47,6 @@ import { createRemoteAgentCustomizationItemProvider, createRemoteAgentHarnessDes import { RemoteAgentHostLogForwarder } from './remoteAgentHostLogForwarder.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; -import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { ISSHRemoteAgentHostService, SSHAuthMethod } from '../../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -230,11 +223,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc @ISSHRemoteAgentHostService private readonly _sshService: ISSHRemoteAgentHostService, @IAICustomizationWorkspaceService private readonly _customizationWorkspaceService: IAICustomizationWorkspaceService, @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, - @IStorageService private readonly _storageService: IStorageService, - @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, @ITelemetryService private readonly _telemetryService: ITelemetryService, - @IPromptsService private readonly _promptsService: IPromptsService, + @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, ) { super(); @@ -299,16 +290,18 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Update connection status on all providers (including those // that are reconnecting and don't have an active connection). for (const [address, provider] of this._providerInstances) { - // Preserve incompatible state — set by the SSH catch and the - // generic WebSocket connect failure path. Otherwise this loop - // would overwrite it back to `disconnected` on the next event. - if (RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { - continue; - } const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); if (connectionInfo) { + // Service has an entry for this address — its status is + // authoritative (including the `incompatible` set by the + // WebSocket connect failure path, and the `connecting` + // status of a fresh reconnect attempt after an upgrade). provider.setConnectionStatus(connectionInfo.status); - } else { + } else if (!RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { + // No service entry. Preserve incompatible state set by + // the SSH reconnect catch (where the failure happens + // before the service ever sees an entry); otherwise fall + // back to disconnected. provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); } } @@ -794,27 +787,12 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._customizationWorkspaceService, )); const itemProvider = agentStore.add(createRemoteAgentCustomizationItemProvider(agent, loggedConnection, sanitized, pluginController, this._fileService, this._logService)); - const syncProvider = agentStore.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); - const harnessDescriptor = createRemoteAgentHarnessDescriptor(sessionType, displayName, pluginController, itemProvider, syncProvider); - agentStore.add(this._customizationHarnessService.registerExternalHarness(harnessDescriptor)); - // Bundler for packaging individual files into a virtual Open Plugin - const bundler = agentStore.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); + const agentRegistration = agentStore.add(this._activeClientService.registerForAgent(sessionType)); + const syncProvider = agentRegistration.syncProvider; - // Agent-level customizations observable - const customizations = observableValue('agentCustomizations', []); - const updateCustomizations = async () => { - const refs = await resolveCustomizationRefs(this._promptsService, syncProvider, this._agentPluginService, bundler, sessionType); - customizations.set(refs, undefined); - }; - agentStore.add(syncProvider.onDidChange(() => updateCustomizations())); - agentStore.add(Event.any( - this._promptsService.onDidChangeCustomAgents, - this._promptsService.onDidChangeSlashCommands, - this._promptsService.onDidChangeSkills, - this._promptsService.onDidChangeInstructions, - )(() => updateCustomizations())); - updateCustomizations(); // resolve initial state + const harnessDescriptor = createRemoteAgentHarnessDescriptor(sessionType, displayName, pluginController, itemProvider, syncProvider); + agentStore.add(this._customizationHarnessService.registerExternalHarness(harnessDescriptor)); // Session handler (unified) const sessionHandler = agentStore.add(this._instantiationService.createInstance( @@ -831,7 +809,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc resolveWorkingDirectory, isNewSession, resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(address, loggedConnection, resources), - customizations, })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 594d87c92012c..385adb2083ed5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -21,9 +21,8 @@ import { ROOT_STATE_URI, type AgentInfo, type CustomizationRef } from '../../../ import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { ICustomizationSyncProvider, type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { AgentCustomizationSyncProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; function customizationKey(customization: CustomizationRef): string { @@ -174,7 +173,7 @@ export function createRemoteAgentHarnessDescriptor( displayName: string, controller: RemoteAgentPluginController, itemProvider: AgentCustomizationItemProvider, - syncProvider: AgentCustomizationSyncProvider, + syncProvider: ICustomizationSyncProvider, ): IHarnessDescriptor { const allSources = [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.plugin, AICustomizationSources.extension, AICustomizationSources.builtin]; const filter: IStorageSourceFilter = { sources: allSources }; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index fcb481b46b6b0..6ef725d186bbd 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -36,6 +36,7 @@ import { IGitHubService } from '../../../github/browser/githubService.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js'; /** Storage key prefix for cached session summaries, per remote address. */ @@ -192,6 +193,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid */ private _unpublished = false; + constructor( config: IRemoteAgentHostSessionsProviderConfig, @IFileDialogService private readonly _fileDialogService: IFileDialogService, @@ -208,8 +210,9 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid @IGitHubService gitHubService: IGitHubService, @IInstantiationService instantiationService: IInstantiationService, @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IAgentHostActiveClientService activeClientService: IAgentHostActiveClientService, ) { - super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsManagementService); + super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsManagementService, activeClientService); this._connectionAuthority = agentHostAuthority(config.address); this._connectOnDemand = config.connectOnDemand; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index b649680001d6f..121f1b0569ac3 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -17,7 +17,7 @@ import { INotificationService, Severity } from '../../../../../platform/notifica import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; -import { logTunnelConnectAttempt, logTunnelConnectResolved, TunnelConnectErrorCategory, TunnelConnectFailureReason } from '../../../../common/sessionsTelemetry.js'; +import { logTunnelConnectAttempt, logTunnelConnectResolved, logTunnelDiscoveryResult, TunnelConnectErrorCategory, TunnelConnectFailureReason, TunnelDiscoveryTrigger } from '../../../../common/sessionsTelemetry.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; @@ -230,16 +230,23 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private _updateConnectionStatuses(): void { for (const [address, provider] of this._providerInstances) { - // Preserve incompatible state until the user retries — otherwise - // the catch in `_connectTunnel` would set it and the `finally` - // block immediately overwrite it back to `disconnected`. - if (RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { - continue; - } const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); if (connectionInfo) { + // Service has an entry — its status is authoritative + // (including incompatible from the WebSocket connect + // failure path, and connecting/connected from a fresh + // reconnect after an upgrade). provider.setConnectionStatus(connectionInfo.status); - } else if (this._pendingConnects.has(address)) { + continue; + } + // Preserve incompatible state set by `_connectTunnel`'s catch + // (where the failure happens before the service ever has an + // entry) until the user retries — otherwise the `finally` + // block would immediately overwrite it back to `disconnected`. + if (RemoteAgentHostConnectionStatus.isIncompatible(provider.connectionStatus.get())) { + continue; + } + if (this._pendingConnects.has(address)) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connecting); } else if (!this._initialStatusChecked) { // Keep the initial "Connecting" state so the picker doesn't @@ -604,7 +611,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc if (added) { this._logService.info(`[TunnelAgentHost] ${e.providerId} session added; resuming reconnects and rediscovering.`); this._resumeReconnects('sessionAdded'); - this._silentStatusCheck(); + this._silentStatusCheck('sessionChange'); } } @@ -766,15 +773,27 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // -- Silent status check -- - private async _silentStatusCheck(): Promise { - const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); - if (!enabled) { + private async _silentStatusCheck(trigger?: TunnelDiscoveryTrigger): Promise { + const resolvedTrigger: TunnelDiscoveryTrigger = trigger ?? (this._initialStatusChecked ? 'rediscover' : 'startup'); + const hostsEnabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); + const autoConnectEnabled = this._configurationService.getValue(RemoteAgentHostAutoConnectSettingId); + if (!hostsEnabled) { this._initialStatusChecked = true; this._updateConnectionStatuses(); + logTunnelDiscoveryResult(this._telemetryService, { + trigger: resolvedTrigger, + totalFound: 0, + withActiveHost: 0, + cachedBefore: this._tunnelService.getCachedTunnels().length, + autoConnectEnabled, + hostsEnabled, + success: true, + }); return; } this._lastStatusCheck = Date.now(); + const cachedBefore = this._tunnelService.getCachedTunnels().length; // Fetch tunnel list silently to check online status let onlineTunnels: ITunnelInfo[] | undefined; @@ -784,6 +803,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // No cached token or network error — leave statuses as-is this._initialStatusChecked = true; this._updateConnectionStatuses(); + logTunnelDiscoveryResult(this._telemetryService, { + trigger: resolvedTrigger, + totalFound: 0, + withActiveHost: 0, + cachedBefore, + autoConnectEnabled, + hostsEnabled, + success: false, + }); return; } @@ -797,13 +825,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } } - // Auto-cache online tunnels that aren't cached yet so they - // appear in the UI on first discovery (e.g. fresh web session). - // Pass 'github' as authProvider so _handleSessionsChange can - // match these tunnels for teardown on session removal. + // Auto-cache every discovered tunnel that isn't cached yet so + // it appears in the picker on first discovery (e.g. fresh web + // session), including tunnels whose host process is currently + // offline — those render grayed-out via the status-update loop + // below. Pass 'github' as authProvider so _handleSessionsChange + // can match these tunnels for teardown on session removal. const cachedIds = new Set(cached.map(t => t.tunnelId)); for (const tunnel of onlineTunnels) { - if (!cachedIds.has(tunnel.tunnelId) && tunnel.hostConnectionCount > 0) { + if (!cachedIds.has(tunnel.tunnelId)) { this._tunnelService.cacheTunnel(tunnel, 'github'); } } @@ -873,6 +903,21 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._initialStatusChecked = true; this._updateConnectionStatuses(); + + const totalFound = onlineTunnels?.length ?? 0; + const withActiveHost = onlineTunnels?.filter(t => t.hostConnectionCount > 0).length ?? 0; + this._logService.info( + `[TunnelAgentHost] Silent status check (${resolvedTrigger}): totalFound=${totalFound}, withActiveHost=${withActiveHost}, cachedBefore=${cachedBefore}, autoConnect=${autoConnectEnabled}` + ); + logTunnelDiscoveryResult(this._telemetryService, { + trigger: resolvedTrigger, + totalFound, + withActiveHost, + cachedBefore, + autoConnectEnabled, + hostsEnabled, + success: true, + }); } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts index 1229daabfe2c5..d92f5a824f22c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -84,15 +84,29 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen // The embedder acquires tokens internally via its own auth flow const discovered = await this._discoveryProvider.listTunnels(); const results: ITunnelInfo[] = []; + let droppedByProtocolVersion = 0; + let withoutIds = 0; for (const tunnel of discovered) { const info = this._toTunnelInfo(tunnel); - if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) { - results.push(info); + if (!info) { + withoutIds++; + continue; } + if (info.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) { + droppedByProtocolVersion++; + this._logService.debug( + `${LOG_PREFIX} Dropping tunnel ${info.tunnelId} (protocolVersion=${info.protocolVersion} < ${TUNNEL_MIN_PROTOCOL_VERSION})` + ); + continue; + } + results.push(info); } - this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`); + const withActiveHost = results.filter(t => t.hostConnectionCount > 0).length; + this._logService.info( + `${LOG_PREFIX} Discovery complete: total=${discovered.length}, accepted=${results.length}, withActiveHost=${withActiveHost}, droppedByProtocolVersion=${droppedByProtocolVersion}, droppedMissingIds=${withoutIds}` + ); return results; } catch (err) { this._logService.error(`${LOG_PREFIX} Failed to list tunnels`, err); @@ -211,7 +225,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen authProvider, }); this.clearAutoConnectSuppression(tunnel.tunnelId); - this._storeCachedTunnels(filtered.slice(0, 20)); + this._storeCachedTunnels(filtered); this._onDidChangeTunnels.fire(); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index d5f43191a643d..f62aa112a977e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -272,7 +272,7 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo authProvider, }); this.clearAutoConnectSuppression(tunnel.tunnelId); - this._storeCachedTunnels(filtered.slice(0, 20)); + this._storeCachedTunnels(filtered); this._onDidChangeTunnels.fire(); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index c63a268b00beb..b64274efdab32 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -33,6 +33,7 @@ import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderC import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IGitHubService } from '../../../../github/browser/githubService.js'; +import { IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { IObservable, constObservable } from '../../../../../../base/common/observable.js'; import { IActiveSession, ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; @@ -220,6 +221,9 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(ISessionsManagementService, new class extends mock() { override readonly activeSession: IObservable = constObservable(undefined); }()); + instantiationService.stub(IAgentHostActiveClientService, new class extends mock() { + override getActiveClient = (_sessionType: string, clientId: string) => ({ clientId, tools: [], customizations: [] }); + }()); const config: IRemoteAgentHostSessionsProviderConfig = { address: overrides?.address ?? 'localhost:4321', diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 3e85197ab9c58..6c97d65628932 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -24,6 +24,7 @@ import { SessionsNavigation } from './sessionNavigation.js'; import { VisibleSessions } from './visibleSessions.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { SessionsTelemetryReporter } from './sessionsTelemetryReporter.js'; const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; @@ -72,6 +73,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _providerListeners = this._register(new DisposableMap()); private readonly _sessionStates: ResourceMap; private readonly _navigation: SessionsNavigation; + private readonly _telemetryReporter: SessionsTelemetryReporter; constructor( @IStorageService private readonly storageService: IStorageService, @@ -84,6 +86,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa ) { super(); + this._telemetryReporter = this._register(this.instantiationService.createInstance(SessionsTelemetryReporter)); + // Bind context key to active session state. // isNewSession is false when there are any established sessions in the model. this._isNewChatSessionContext = IsNewChatSessionContext.bindTo(contextKeyService); @@ -453,9 +457,20 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } + private _logRequestSent(session: ISession, chat: IChat, isNewSession: boolean, options: ISendRequestOptions): void { + const visibleSessionsCount = this._visibility.visibleSessions.get().filter(s => s !== undefined).length; + this._telemetryReporter.logRequestSent(session, chat, isNewSession, options, this.getSessions(), visibleSessionsCount); + } + async sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise { this._pendingNewSession = undefined; + // Kick off the workspace file-count fetch now so it has time to resolve + // while the provider creates the chat and sends the request. The reporter + // will pick the in-flight result up under the updated session id when + // _logRequestSent runs below. + this._telemetryReporter.prewarmWorkspaceFileCount(session.workspace.get()); + const setActiveChatToLast = () => { const activeSession = this._visibility.activeSession.get(); if (activeSession?.sessionId === session.sessionId && this.uriIdentityService.extUri.isEqual(activeSession.activeChat.get().resource, (session).activeChat?.get().resource)) { @@ -492,8 +507,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._visibility.updateSession(tmpSession, updatedSession); setActiveChatToLast(); } - } catch (error) { - this.logService.error(`[SessionsManagement] sendNewChatRequest: ${error}`); + + this._logRequestSent(updatedSession, session.mainChat.get(), true, options); } finally { chatsListener.dispose(); } @@ -502,6 +517,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise { this._pendingNewSession = undefined; + // Kick off the workspace file-count fetch now so it has time to resolve + // while the provider sends the request. + this._telemetryReporter.prewarmWorkspaceFileCount(session.workspace.get()); + // Keep the sent chat as the active chat this._visibility.setActiveChat(session, chat); @@ -515,6 +534,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); this._visibility.updateSession(session, updatedSession); } + + this._logRequestSent(updatedSession, chat, false, options); } openNewSessionView(): void { @@ -576,8 +597,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._visibility.toggleStickiness(session); } - insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right'): void { - this._visibility.insertAt(session, targetSessionId, side); + insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate: boolean = true): void { + this._visibility.insertAt(session, targetSessionId, side, activate); } closeSession(session: ISession): void { @@ -816,22 +837,27 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } async archiveSession(session: ISession): Promise { + this._telemetryReporter.logSessionArchived(session); await this._getProvider(session)?.archiveSession(session.sessionId); } async unarchiveSession(session: ISession): Promise { + this._telemetryReporter.logSessionUnarchived(session); await this._getProvider(session)?.unarchiveSession(session.sessionId); } async deleteSession(session: ISession): Promise { + this._telemetryReporter.logSessionDeleted(session); await this._getProvider(session)?.deleteSession(session.sessionId); } async deleteChat(session: ISession, chatUri: URI): Promise { + this._telemetryReporter.logChatDeleted(session); await this._getProvider(session)?.deleteChat(session.sessionId, chatUri); } async renameChat(session: ISession, chatUri: URI, title: string): Promise { + this._telemetryReporter.logChatRenamed(session); await this._getProvider(session)?.renameChat(session.sessionId, chatUri, title); } } diff --git a/src/vs/sessions/services/sessions/browser/sessionsTelemetryReporter.ts b/src/vs/sessions/services/sessions/browser/sessionsTelemetryReporter.ts new file mode 100644 index 0000000000000..6a49346f67093 --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionsTelemetryReporter.ts @@ -0,0 +1,475 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { hash } from '../../../../base/common/hash.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IChat, ISession, ISessionWorkspace, SessionStatus } from '../common/session.js'; +import { ISendRequestOptions } from '../common/sessionsProvider.js'; +import { isChatRequestFileEntry, isImageVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; + +const TOTAL_REQUESTS_KEY = 'agentSessions.telemetry.totalRequests'; +const WORKSPACE_REQUESTS_KEY = 'agentSessions.telemetry.workspaceRequests'; +const PROVIDER_REQUESTS_KEY = 'agentSessions.telemetry.providerRequests'; + +type SessionIsolationKind = 'worktree' | 'folder'; + +// --- Field group: session (derived from ISession) --- + +type SessionFields = { + sessionId: string; + providerId: string; + providerType: string; + chatCount: number; +}; + +type SessionFieldsClassification = { + sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Globally unique session id (providerId:resourceUri), used to correlate events for the same session.' }; + providerId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The sessions provider identifier (e.g., remote agent host or local).' }; + providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type identifier provided by the sessions provider.' }; + chatCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of chats currently in the session.' }; +}; + +// --- Field group: chat (derived from IChat) --- + +type ChatFields = { + chatModeKind: string; +}; + +type ChatFieldsClassification = { + chatModeKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Built-in chat mode kind (e.g., ask, agent, edit); empty when no mode is selected.' }; +}; + +// --- Field group: workspace (derived from ISessionWorkspace) --- + +type WorkspaceFields = { + isolationKind: SessionIsolationKind; + workspaceHash: string; + hasGitRepository: boolean; + isVirtualWorkspace: boolean; + workspaceFileCount: number; +}; + +type WorkspaceFieldsClassification = { + isolationKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode used by the session (worktree or folder).' }; + workspaceHash: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Non-reversible hash of the workspace URI, used to correlate events across the same workspace without disclosing the path.' }; + hasGitRepository: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether any of the workspace folders has a git repository.' }; + isVirtualWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the workspace URI uses a non-file scheme (virtual/remote).' }; + workspaceFileCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of files in the workspace (honoring user excludes); -1 if the workspace could not be scanned.' }; +}; + +// --- Field group: request (derived from ISendRequestOptions) --- + +type RequestFields = { + queryLength: number; + totalAttachementCount: number; + fileAttachmentCount: number; + imageAttachmentCount: number; + attachmentKinds: string; +}; + +type RequestFieldsClassification = { + queryLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of characters in the user query. Length only, no content.' }; + totalAttachementCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of attached context entries included with the request.' }; + fileAttachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of file attachments included with the request.' }; + imageAttachmentCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of image attachments included with the request.' }; + attachmentKinds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Stringified JSON object mapping each attachment kind (e.g. file, image, symbol) to its count for this request.' }; +}; + +// --- Field group: all sessions in the window (derived from anchor + ISession[]) --- + +type AllSessionsFields = { + currentWorkspaceFolderInProgress: number; + currentWorkspaceFolderUnread: number; + currentWorkspaceFolderWaitingForInput: number; + currentWorkspaceFolderNotDone: number; + + currentWorkspaceInProgress: number; + currentWorkspaceUnread: number; + currentWorkspaceWaitingForInput: number; + currentWorkspaceNotDone: number; + + allWorkspacesInProgress: number; + allWorkspacesUnread: number; + allWorkspacesWaitingForInput: number; + allWorkspacesNotDone: number; +}; + +type AllSessionsFieldsClassification = { + currentWorkspaceFolderInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace using folder isolation.' }; + currentWorkspaceFolderUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace using folder isolation.' }; + currentWorkspaceFolderWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace using folder isolation.' }; + currentWorkspaceFolderNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace using folder isolation.' }; + + currentWorkspaceInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions in the current workspace across all isolation modes.' }; + currentWorkspaceUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions in the current workspace across all isolation modes.' }; + currentWorkspaceWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input in the current workspace across all isolation modes.' }; + currentWorkspaceNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done in the current workspace across all isolation modes.' }; + + allWorkspacesInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'In-progress sessions across all workspaces.' }; + allWorkspacesUnread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Unread sessions across all workspaces.' }; + allWorkspacesWaitingForInput: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions waiting for user input across all workspaces.' }; + allWorkspacesNotDone: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Sessions not marked as done across all workspaces.' }; +}; + +// --- Field group: cumulative user-request counters (read + increment storage) --- + +type UserRequestCountersFields = { + userRequestsTotal: number; + userRequestsInWorkspace: number; + userRequestsForProvider: number; +}; + +type UserRequestCountersFieldsClassification = { + userRequestsTotal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of requests the user has sent from the Agents window across all workspaces and providers (including this one).' }; + userRequestsInWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of requests the user has sent in the current workspace (including this one).' }; + userRequestsForProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cumulative number of requests the user has sent for this sessions provider across all workspaces (including this one).' }; +}; + +// --- Composed event: vscodeAgents.sessions/requestSent --- + +type SessionRequestSentEvent = + & SessionFields + & ChatFields + & WorkspaceFields + & RequestFields + & AllSessionsFields + & UserRequestCountersFields + & { + isNewSession: boolean; + visibleSessionsCount: number; + }; + +type SessionRequestSentClassification = + & SessionFieldsClassification + & ChatFieldsClassification + & WorkspaceFieldsClassification + & RequestFieldsClassification + & AllSessionsFieldsClassification + & UserRequestCountersFieldsClassification + & { + owner: 'benibenj'; + comment: 'Reports when the user sends a request from a session in the Agents window, including the user state at the time of send.'; + isNewSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'True when the request starts a brand-new session, false when it is a new or continued chat in an existing session.' }; + visibleSessionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How many sessions are currently visible in the sessions grid.' }; + }; + +// --- Composed events: session-level lifecycle actions (archive / unarchive / +// delete / chat delete / chat rename). Each event shares the same payload -- +// session fields + workspace fields -- only the event name distinguishes them. --- + +type SessionActionEvent = SessionFields & WorkspaceFields; + +type SessionActionClassification = + & SessionFieldsClassification + & WorkspaceFieldsClassification + & { + owner: 'benibenj'; + comment: 'Reports when the user performs a lifecycle action on a session or chat (archive, unarchive, delete, rename) in the Agents window.'; + }; + +/** + * Owns telemetry emission for the {@link SessionsManagementService}. Each + * `getXxxFields` method returns a typed group of properties that can be + * spread into any telemetry event — other events that have, e.g., a session + * or a workspace can compose the same field shapes by intersecting the + * corresponding `*FieldsClassification` types and calling the getter. + */ +export class SessionsTelemetryReporter extends Disposable { + + /** Final workspace file counts, keyed by session id (so subsequent log calls for the same session are instant). */ + private readonly _workspaceFileCountCache = new Map(); + /** Pending workspace file-count fetches, keyed by workspace URI so a prewarm started before a session-id assignment can be picked up after. */ + private readonly _workspaceFileCountInFlight = new Map>(); + + constructor( + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IStorageService private readonly _storageService: IStorageService, + @ISearchService private readonly _searchService: ISearchService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + } + + // -- public emitters (fire-and-forget; callers do not await) --------------- + + logRequestSent(session: ISession, chat: IChat, isNewSession: boolean, options: ISendRequestOptions, allSessions: readonly ISession[], visibleSessionsCount: number): void { + // Snapshot all synchronous fields now so the event reflects the state at + // the time of the send, not when the async file-count fetch resolves. + const workspace = session.workspace.get(); + const sync = { + isNewSession, + visibleSessionsCount, + ...this._getRequestFields(options), + ...this._getSessionFields(session), + ...this._getChatFields(chat), + ...this._getAllSessionsFields(session, allSessions), + ...this._incrementAndGetUserRequestCounters(session.providerId, workspace?.uri.toString()), + }; + void this._getOrFetchWorkspaceFileCount(session.sessionId, workspace).then(workspaceFileCount => { + this._telemetryService.publicLog2('vscodeAgents.sessions/requestSent', { + ...sync, + ...this._getWorkspaceFields(workspace, workspaceFileCount), + }); + }); + } + + logSessionArchived(session: ISession): void { + this._logSessionAction('vscodeAgents.sessions/sessionArchived', session); + } + + logSessionUnarchived(session: ISession): void { + this._logSessionAction('vscodeAgents.sessions/sessionUnarchived', session); + } + + logSessionDeleted(session: ISession): void { + this._logSessionAction('vscodeAgents.sessions/sessionDeleted', session); + } + + logChatDeleted(session: ISession): void { + this._logSessionAction('vscodeAgents.sessions/chatDeleted', session); + } + + logChatRenamed(session: ISession): void { + this._logSessionAction('vscodeAgents.sessions/chatRenamed', session); + } + + private _logSessionAction(eventName: string, session: ISession): void { + const workspace = session.workspace.get(); + const sessionFields = this._getSessionFields(session); + void this._getOrFetchWorkspaceFileCount(session.sessionId, workspace).then(workspaceFileCount => { + this._telemetryService.publicLog2(eventName, { + ...sessionFields, + ...this._getWorkspaceFields(workspace, workspaceFileCount), + }); + }); + } + + // -- field-group getters (reusable by other telemetry events) -------------- + + private _getSessionFields(session: ISession): SessionFields { + return { + sessionId: session.sessionId, + providerId: session.providerId, + providerType: session.sessionType, + chatCount: session.chats.get().length, + }; + } + + private _getChatFields(chat: IChat): ChatFields { + return { + chatModeKind: chat.mode.get()?.kind ?? '', + }; + } + + private _getWorkspaceFields(workspace: ISessionWorkspace | undefined, workspaceFileCount: number): WorkspaceFields { + if (!workspace) { + return { + isolationKind: 'folder', + workspaceHash: '', + hasGitRepository: false, + isVirtualWorkspace: false, + workspaceFileCount, + }; + } + const hasWorktree = workspace.folders.some(folder => folder.gitRepository?.workTreeUri !== undefined); + return { + isolationKind: hasWorktree ? 'worktree' : 'folder', + workspaceHash: hash(workspace.uri.toString()).toString(16), + hasGitRepository: workspace.folders.some(folder => folder.gitRepository !== undefined), + isVirtualWorkspace: workspace.uri.scheme !== Schemas.file, + workspaceFileCount, + }; + } + + private _getOrFetchWorkspaceFileCount(sessionId: string, workspace: ISessionWorkspace | undefined): Promise { + const cached = this._workspaceFileCountCache.get(sessionId); + if (cached !== undefined) { + return Promise.resolve(cached); + } + const pending = this._startWorkspaceFileCountFetch(workspace); + if (!pending) { + return Promise.resolve(-1); + } + return pending.then(count => { + this._workspaceFileCountCache.set(sessionId, count); + return count; + }); + } + + /** + * Kick off the workspace file-count fetch (non-blocking) so its result is + * already available — or close to it — by the time a subsequent + * {@link logRequestSent} call awaits it. Safe to call multiple times for + * the same workspace. + */ + prewarmWorkspaceFileCount(workspace: ISessionWorkspace | undefined): void { + this._startWorkspaceFileCountFetch(workspace); + } + + private _startWorkspaceFileCountFetch(workspace: ISessionWorkspace | undefined): Promise | undefined { + if (!workspace || workspace.folders.length === 0) { + return undefined; + } + const workspaceKey = workspace.uri.toString(); + let pending = this._workspaceFileCountInFlight.get(workspaceKey); + if (!pending) { + pending = this._computeWorkspaceFileCount(workspace).then(count => { + this._workspaceFileCountInFlight.delete(workspaceKey); + return count; + }, () => { + this._workspaceFileCountInFlight.delete(workspaceKey); + return -1; + }); + this._workspaceFileCountInFlight.set(workspaceKey, pending); + } + return pending; + } + + private async _computeWorkspaceFileCount(workspace: ISessionWorkspace): Promise { + const excludePattern = getExcludes(this._configurationService.getValue({ resource: workspace.uri })); + const result = await this._searchService.fileSearch({ + folderQueries: workspace.folders.map(folder => ({ folder: folder.root, disregardIgnoreFiles: false })), + type: QueryType.File, + filePattern: '', + excludePattern, + }); + return result.results.length; + } + + private _getRequestFields(options: ISendRequestOptions): RequestFields { + const attachments = options.attachedContext ?? []; + return { + queryLength: options.query?.length ?? 0, + totalAttachementCount: attachments.length, + fileAttachmentCount: attachments.filter(isChatRequestFileEntry).length, + imageAttachmentCount: attachments.filter(isImageVariableEntry).length, + attachmentKinds: JSON.stringify(countAttachmentsByKind(attachments)), + }; + } + + private _getAllSessionsFields(anchorSession: ISession, allSessions: readonly ISession[]): AllSessionsFields { + const anchorWorkspaceUri = anchorSession.workspace.get()?.uri; + const isSameWorkspace = (other: ISession): boolean => { + if (!anchorWorkspaceUri) { + return false; + } + const otherWorkspaceUri = other.workspace.get()?.uri; + return otherWorkspaceUri !== undefined && this._uriIdentityService.extUri.isEqual(anchorWorkspaceUri, otherWorkspaceUri); + }; + + const inCurrentWorkspaceFolderOnly: ISession[] = []; + const inCurrentWorkspace: ISession[] = []; + const inAll: ISession[] = []; + + for (const session of allSessions) { + if (session.isArchived.get()) { + continue; + } + inAll.push(session); + if (isSameWorkspace(session)) { + inCurrentWorkspace.push(session); + const hasWorktree = session.workspace.get()?.folders.some(folder => folder.gitRepository?.workTreeUri !== undefined) ?? false; + if (!hasWorktree) { + inCurrentWorkspaceFolderOnly.push(session); + } + } + } + + const folderOnly = countByStatus(inCurrentWorkspaceFolderOnly); + const currentWorkspace = countByStatus(inCurrentWorkspace); + const all = countByStatus(inAll); + return { + currentWorkspaceFolderInProgress: folderOnly.inProgress, + currentWorkspaceFolderUnread: folderOnly.unread, + currentWorkspaceFolderWaitingForInput: folderOnly.waitingForInput, + currentWorkspaceFolderNotDone: folderOnly.notDone, + + currentWorkspaceInProgress: currentWorkspace.inProgress, + currentWorkspaceUnread: currentWorkspace.unread, + currentWorkspaceWaitingForInput: currentWorkspace.waitingForInput, + currentWorkspaceNotDone: currentWorkspace.notDone, + + allWorkspacesInProgress: all.inProgress, + allWorkspacesUnread: all.unread, + allWorkspacesWaitingForInput: all.waitingForInput, + allWorkspacesNotDone: all.notDone, + }; + } + + private _incrementAndGetUserRequestCounters(providerId: string, workspaceUri: string | undefined): UserRequestCountersFields { + const userRequestsTotal = this._storageService.getNumber(TOTAL_REQUESTS_KEY, StorageScope.APPLICATION, 0) + 1; + this._storageService.store(TOTAL_REQUESTS_KEY, userRequestsTotal, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const providerCounts = this._readCounterMap(PROVIDER_REQUESTS_KEY); + const userRequestsForProvider = (providerCounts[providerId] ?? 0) + 1; + providerCounts[providerId] = userRequestsForProvider; + this._storageService.store(PROVIDER_REQUESTS_KEY, JSON.stringify(providerCounts), StorageScope.APPLICATION, StorageTarget.MACHINE); + + let userRequestsInWorkspace = 0; + if (workspaceUri) { + const workspaceCounts = this._readCounterMap(WORKSPACE_REQUESTS_KEY); + userRequestsInWorkspace = (workspaceCounts[workspaceUri] ?? 0) + 1; + workspaceCounts[workspaceUri] = userRequestsInWorkspace; + this._storageService.store(WORKSPACE_REQUESTS_KEY, JSON.stringify(workspaceCounts), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + return { userRequestsTotal, userRequestsInWorkspace, userRequestsForProvider }; + } + + private _readCounterMap(key: string): Record { + const raw = this._storageService.get(key, StorageScope.APPLICATION); + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw); + return (parsed && typeof parsed === 'object') ? parsed as Record : {}; + } catch { + return {}; + } + } +} + +interface ISessionStatusCounts { + readonly inProgress: number; + readonly unread: number; + readonly waitingForInput: number; + readonly notDone: number; +} + +function countByStatus(sessions: readonly ISession[]): ISessionStatusCounts { + let inProgress = 0; + let unread = 0; + let waitingForInput = 0; + for (const session of sessions) { + const status = session.status.get(); + if (status === SessionStatus.InProgress) { + inProgress++; + } + if (status === SessionStatus.NeedsInput) { + waitingForInput++; + } + if (!session.isRead.get()) { + unread++; + } + } + // Archived sessions were filtered upstream, so every session here is "not done". + return { inProgress, unread, waitingForInput, notDone: sessions.length }; +} + +function countAttachmentsByKind(attachments: readonly { readonly kind: string }[]): Record { + const counts: Record = {}; + for (const attachment of attachments) { + counts[attachment.kind] = (counts[attachment.kind] ?? 0) + 1; + } + return counts; +} diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 19f0499fc3e4a..c61b6cc5f69db 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -242,9 +242,13 @@ export class VisibleSessions extends Disposable { * - If the slot is already visible, it is moved to the computed * position; its sticky / non-sticky state is preserved. * + * When `activate` is `true` (default), the inserted slot also becomes + * the active session. When `false`, the active session is left + * unchanged. + * * No-op if `targetSessionId` is not currently visible. */ - insertAt(session: ISession | undefined, targetSessionId: string, side: 'left' | 'right'): void { + insertAt(session: ISession | undefined, targetSessionId: string, side: 'left' | 'right', activate: boolean = true): void { const id: string | undefined = session?.sessionId; const targetIdx = this._visibleList.indexOf(targetSessionId); if (targetIdx < 0) { @@ -282,6 +286,11 @@ export class VisibleSessions extends Disposable { this._mostRecentNonStickySlot = id; } + if (activate) { + const wrapper = id !== undefined ? this._wrappers.get(id) : undefined; + this._activeSession.set(wrapper, undefined); + } + this._refresh(); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 5c60947569059..46f9130039ebd 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -146,8 +146,11 @@ export interface ISessionsManagementService { * at the computed position. * - If the session is already visible, it is moved to the computed * position; its sticky / non-sticky state is preserved. + * + * When `activate` is `true` (default), the inserted session also becomes + * the active session. Pass `false` to leave the active session unchanged. */ - insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right'): void; + insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate?: boolean): void; /** * Close a session: remove it from the visibility model so it is no longer diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index d1e04b500c2ad..8ed9c23956d1b 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -168,7 +168,7 @@ class MockSessionStore implements ISessionsManagementService { openPreviousSession(): Promise { throw new Error('not implemented'); } openNextSession(): Promise { throw new Error('not implemented'); } toggleSessionStickiness(_session: ISession): void { throw new Error('not implemented'); } - insertAt(_session: ISession, _targetSessionId: string, _side: 'left' | 'right'): void { throw new Error('not implemented'); } + insertAt(_session: ISession, _targetSessionId: string, _side: 'left' | 'right', _activate?: boolean): void { throw new Error('not implemented'); } closeSession(_session: ISession): void { throw new Error('not implemented'); } setActive(_session: IActiveSession): void { throw new Error('not implemented'); } archiveSession(_session: ISession): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index 4fea15488560c..93f193d833646 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -364,7 +364,7 @@ suite('VisibleSessions', () => { suite('insertAt', () => { - test('inserts a not-yet-visible session to the left of a target as non-sticky', () => { + test('inserts a not-yet-visible session to the left of a target as non-sticky and activates it', () => { const model = createModel(); const A = stubSession('A'); const B = stubSession('B'); @@ -378,12 +378,12 @@ suite('VisibleSessions', () => { assert.deepStrictEqual(snapshot(model), { visible: ['A', 'C', 'B'], - active: 'B', + active: 'C', sticky: ['A', 'B'], }); }); - test('inserts a not-yet-visible session to the right of a target as non-sticky', () => { + test('inserts a not-yet-visible session to the right of a target as non-sticky and activates it', () => { const model = createModel(); const A = stubSession('A'); const B = stubSession('B'); @@ -397,7 +397,7 @@ suite('VisibleSessions', () => { assert.deepStrictEqual(snapshot(model), { visible: ['A', 'C', 'B'], - active: 'B', + active: 'C', sticky: ['A', 'B'], }); }); @@ -416,7 +416,7 @@ suite('VisibleSessions', () => { assert.deepStrictEqual(snapshot(model), { visible: ['A', 'B', 'C'], - active: 'B', + active: 'C', sticky: ['A'], }); }); @@ -437,12 +437,12 @@ suite('VisibleSessions', () => { assert.deepStrictEqual(snapshot(model), { visible: ['B', 'C', 'A'], - active: 'C', + active: 'A', sticky: ['B', 'C', 'A'], }); }); - test('dropping a session to the right of its left neighbour is a no-op', () => { + test('dropping a session to the right of its left neighbour is a no-op for layout but still activates it', () => { const model = createModel(); const A = stubSession('A'); const B = stubSession('B'); @@ -460,7 +460,7 @@ suite('VisibleSessions', () => { }); }); - test('dropping a session to the left of its right neighbour is a no-op', () => { + test('dropping a session to the left of its right neighbour is a no-op for layout but still activates it', () => { const model = createModel(); const A = stubSession('A'); const B = stubSession('B'); @@ -473,6 +473,25 @@ suite('VisibleSessions', () => { assert.deepStrictEqual(snapshot(model), { visible: ['A', 'B'], + active: 'A', + sticky: ['A', 'B'], + }); + }); + + test('does not change the active session when activate is false', () => { + const model = createModel(); + const A = stubSession('A'); + const B = stubSession('B'); + const C = stubSession('C'); + + model.setActive(A); + model.toggleStickiness(A); + model.setActive(B); + model.toggleStickiness(B); // [A, B] active:B + model.insertAt(C, 'A', 'right', false); + + assert.deepStrictEqual(snapshot(model), { + visible: ['A', 'C', 'B'], active: 'B', sticky: ['A', 'B'], }); @@ -517,7 +536,7 @@ suite('VisibleSessions', () => { }); }); - test('insertAt(undefined, ...) adds an empty slot at the requested position', () => { + test('insertAt(undefined, ...) adds an empty slot at the requested position and activates it', () => { const model = createModel(); const A = stubSession('A'); const B = stubSession('B'); @@ -530,7 +549,7 @@ suite('VisibleSessions', () => { assert.deepStrictEqual(snapshot(model), { visible: ['A', undefined, 'B'], - active: 'B', + active: undefined, sticky: ['A', 'B'], }); }); @@ -544,7 +563,8 @@ suite('VisibleSessions', () => { model.toggleStickiness(A); model.setActive(B); model.toggleStickiness(B); // [A, B] sticky:[A, B] - model.insertAt(undefined, 'A', 'right'); // [A, undefined, B] + model.insertAt(undefined, 'A', 'right'); // [A, undefined, B] active becomes empty slot + model.setActive(B); // re-activate B model.insertAt(undefined, 'B', 'right'); // no-op — empty slot already exists assert.deepStrictEqual(snapshot(model), { diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 7b3d493ba521e..ea2da133d8609 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -2961,11 +2961,12 @@ class LayoutStateModel extends Disposable { return true; } - // New users: Show auxiliary bar even in empty workspaces - // but not if the user explicitly hides it + // New users: Show auxiliary bar even in empty workspaces, + // but not if the user explicitly hides it or AI features are disabled. if ( this.isNew[StorageScope.APPLICATION] && - configuration.value !== 'hidden' + configuration.value !== 'hidden' && + !this.configurationService.getValue('chat.disableAIFeatures') ) { return false; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts index c22809d56ee07..6c83a2a3ecdba 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts @@ -52,7 +52,7 @@ class ToggleDevToolsAction extends Action2 { id: ToggleDevToolsAction.ID, title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), category: BrowserActionCategory, - icon: Codicon.terminal, + icon: Codicon.developerTools, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 16df050161791..34cc6b13afccb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -137,7 +137,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi const progressTitle = options?.progressTitle ?? localize('preparingMarketplace', "Preparing plugin marketplace '{0}'...", marketplace.displayLabel); const failureLabel = options?.failureLabel ?? marketplace.displayLabel; - await this._cloneRepository(repoDir, marketplace.cloneUrl, progressTitle, failureLabel); + await this._cloneRepository(repoDir, marketplace.cloneUrl, progressTitle, failureLabel, marketplace.ref); this._updateMarketplaceIndex(marketplace, repoDir, options?.marketplaceType); return repoDir; }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts index 377738cce8603..cca1c557031f1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationContentExpander.ts @@ -122,7 +122,8 @@ export class AgentCustomizationContentExpander { /** * Emits one item per markdown file for agent/rules/command folders. * Agents and instructions read frontmatter name/description, and - * agents additionally surface userInvocable. + * agents additionally surface userInvocable. Instruction (rules) + * folders additionally accept `.mdc` files per the Open Plugins spec. */ private async collectFromRegularDir(entries: readonly { name: string; resource: URI; isDirectory: boolean }[], pluginUri: URI, source: AICustomizationSource, promptType: PromptsType, groupKey: string, isBundleItem: boolean, token: CancellationToken): Promise { type Entry = { name: string; resource: URI; isDirectory: boolean }; @@ -131,7 +132,11 @@ export class AgentCustomizationContentExpander { if (child.name.startsWith('.')) { continue; } - if (child.isDirectory || extname(child.name) !== '.md') { + if (child.isDirectory) { + continue; + } + const ext = extname(child.name); + if (ext !== '.md' && !(promptType === PromptsType.instructions && ext === '.mdc')) { continue; } eligible.push(child); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts new file mode 100644 index 0000000000000..d8a8ec3a4b0e3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { onUnexpectedError } from '../../../../../../base/common/errors.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { equals } from '../../../../../../base/common/objects.js'; +import { derived, IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; +import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import type { CustomizationRef, SessionActiveClient, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { ICustomizationSyncProvider } from '../../../common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; +import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js'; +import { toolDataToDefinition } from './agentHostToolUtils.js'; +import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; + +export const IAgentHostActiveClientService = createDecorator('agentHostActiveClientService'); + +/** The exposed `syncProvider` is the same instance the service uses to resolve customizations; the contribution wires it into its harness so opt-out toggles propagate. */ +export interface IAgentRegistration extends IDisposable { + readonly syncProvider: ICustomizationSyncProvider; +} + +export interface IAgentHostActiveClientService { + readonly _serviceBrand: undefined; + + /** + * Constructs the per-sessionType {@link AgentCustomizationSyncProvider} + * and {@link SyncedCustomizationBundler}, builds the `customizations` + * observable from them, wires it to {@link IPromptsService} change events, + * and resolves the initial value. Disposing the returned handle tears all + * of that down. The created `syncProvider` is exposed on the returned + * object so the contribution can pass the same instance to its + * customization harness. + */ + registerForAgent(sessionType: string): IAgentRegistration; + + /** Returns a {@link SessionActiveClient} for `sessionType` using the caller-supplied `clientId`. Customizations are empty when `sessionType` has not been registered. */ + getActiveClient(sessionType: string, clientId: string): SessionActiveClient; + + getCustomizations(sessionType: string): IObservable; + + readonly clientTools: IObservable; +} + +export class AgentHostActiveClientService extends Disposable implements IAgentHostActiveClientService { + declare readonly _serviceBrand: undefined; + + private readonly _customizationsByType: ISettableObservable>>; + readonly clientTools: IObservable; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IConfigurationService configurationService: IConfigurationService, + @IPromptsService private readonly _promptsService: IPromptsService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + @IStorageService private readonly _storageService: IStorageService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._customizationsByType = observableValue('agentHostCustomizationsByType', new Map()); + + // Pass `undefined` for the model: agent-host sessions use server-side model selection. + const allToolsObs = toolsService.observeTools(undefined); + const allowlistObs = observableConfigValue(ChatConfiguration.AgentHostClientTools, [], configurationService); + this.clientTools = derived(reader => { + const allowlist = new Set(allowlistObs.read(reader)); + return allToolsObs.read(reader) + .filter(t => t.toolReferenceName !== undefined && allowlist.has(t.toolReferenceName)) + .map(toolDataToDefinition); + }); + } + + registerForAgent(sessionType: string): IAgentRegistration { + const store = new DisposableStore(); + const syncProvider = store.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); + const bundler = store.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); + const customizations = observableValue('agentCustomizations', []); + let updateSeq = 0; + const updateCustomizations = async () => { + const seq = ++updateSeq; + try { + const refs = await resolveCustomizationRefs(this._promptsService, syncProvider, this._agentPluginService, bundler, sessionType); + if (seq !== updateSeq) { + return; + } + if (equals(customizations.get(), refs)) { + return; + } + customizations.set(refs, undefined); + } catch (err) { + onUnexpectedError(err); + } + }; + store.add(syncProvider.onDidChange(() => updateCustomizations())); + store.add(Event.any( + this._promptsService.onDidChangeCustomAgents, + this._promptsService.onDidChangeSlashCommands, + this._promptsService.onDidChangeSkills, + this._promptsService.onDidChangeInstructions, + )(() => updateCustomizations())); + updateCustomizations(); + store.add(this._setCustomizations(sessionType, customizations)); + return { + syncProvider, + dispose: () => store.dispose(), + }; + } + + private _setCustomizations(sessionType: string, customizations: IObservable): IDisposable { + const next = new Map(this._customizationsByType.get()); + next.set(sessionType, customizations); + this._customizationsByType.set(next, undefined); + return toDisposable(() => { + const current = this._customizationsByType.get(); + if (current.get(sessionType) !== customizations) { + return; + } + const removed = new Map(current); + removed.delete(sessionType); + this._customizationsByType.set(removed, undefined); + }); + } + + getActiveClient(sessionType: string, clientId: string): SessionActiveClient { + return { + clientId, + tools: [...this.clientTools.get()], + customizations: [...(this._customizationsByType.get().get(sessionType)?.get() ?? [])], + }; + } + + getCustomizations(sessionType: string): IObservable { + return derived(reader => this._customizationsByType.read(reader).get(sessionType)?.read(reader) ?? EMPTY_CUSTOMIZATIONS); + } +} + +const EMPTY_CUSTOMIZATIONS: readonly CustomizationRef[] = Object.freeze([]); + +registerSingleton(IAgentHostActiveClientService, AgentHostActiveClientService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 679e5bd17d69b..69183c9d9b3f2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -5,20 +5,16 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { Event } from '../../../../../../base/common/event.js'; -import { equals } from '../../../../../../base/common/objects.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { AgentHostEnabledSettingId, IAgentHostService, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -26,17 +22,13 @@ import { IWorkbenchEnvironmentService } from '../../../../../services/environmen import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; -import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js'; -import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; -import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js'; import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; +import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; import { AgentHostSessionListController } from './agentHostSessionListController.js'; import { LoggingAgentConnection } from './loggingAgentConnection.js'; -import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; export { AgentHostSessionHandler } from './agentHostSessionHandler.js'; @@ -77,11 +69,9 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @IAgentHostFileSystemService _agentHostFileSystemService: IAgentHostFileSystemService, @IConfigurationService configurationService: IConfigurationService, @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, - @IStorageService private readonly _storageService: IStorageService, - @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, - @IPromptsService private readonly _promptsService: IPromptsService, @IFileService private readonly _fileService: IFileService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, ) { super(); @@ -189,17 +179,11 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add({ dispose: () => this._listControllers.delete(agent.provider) }); store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); - // Customization disable provider + item provider + bundler + observable - const syncProvider = store.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); + const agentRegistration = store.add(this._activeClientService.registerForAgent(sessionType)); + const syncProvider = agentRegistration.syncProvider; + const itemProvider = store.add(new AgentCustomizationItemProvider(agent, this._loggedConnection!, 'local', this._fileService, this._logService)); - const bundler = store.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); - // Distinguish from the extension-host Copilot CLI harness, which - // registers under the same `Copilot CLI` displayName via the chat - // session customization provider API. Without the `[Local]` suffix - // both harnesses render identically in the customizations view. - // Matches the workspace-label convention from - // `buildAgentHostSessionWorkspace` and the provider-name in - // `getAgentSessionProviderName(AgentHostCopilot)`. + // `[Local]` suffix disambiguates from the extension-host Copilot CLI harness, which uses the same displayName. store.add(this._customizationHarnessService.registerExternalHarness({ id: sessionType, label: localize('agentHostHarnessLabel.local', "{0} [Local]", agent.displayName), @@ -211,23 +195,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr itemProvider, })); - const customizations = observableValue('agentCustomizations', []); - const updateCustomizations = async () => { - const refs = await resolveCustomizationRefs(this._promptsService, syncProvider, this._agentPluginService, bundler, sessionType); - if (equals(customizations.get(), refs)) { - return; - } - customizations.set(refs, undefined); - }; - store.add(syncProvider.onDidChange(() => updateCustomizations())); - store.add(Event.any( - this._promptsService.onDidChangeCustomAgents, - this._promptsService.onDidChangeSlashCommands, - this._promptsService.onDidChangeSkills, - this._promptsService.onDidChangeInstructions, - )(() => updateCustomizations())); - updateCustomizations(); // resolve initial state - // Session handler const sessionHandler = store.add(this._instantiationService.createInstance(AgentHostSessionHandler, { provider: agent.provider, @@ -239,7 +206,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr connectionAuthority: 'local', isNewSession: sessionResource => listController.isNewSession(sessionResource), resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(resources), - customizations, })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 37e79da2f7b01..5eb615ab1c1be 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -36,6 +36,7 @@ export const SYNCABLE_PROMPT_TYPES: readonly PromptsType[] = [ export const SYNCABLE_STORAGE_SOURCES: readonly PromptsStorage[] = [ PromptsStorage.plugin, PromptsStorage.extension, + PromptsStorage.user, ]; export interface ILocalCustomizationFile { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 2f70913106c62..7175989a89bc9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -15,48 +15,49 @@ import { autorun, autorunPerKeyedItem, derived, IObservable, observableValue, tr import { extUriBiasedIgnorePathCase, isEqual } from '../../../../../../base/common/resources.js'; import { hasKey, Mutable } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { isLocation, type Location } from '../../../../../../editor/common/languages.js'; import { IPosition } from '../../../../../../editor/common/core/position.js'; +import { isLocation, type Location } from '../../../../../../editor/common/languages.js'; import { localize } from '../../../../../../nls.js'; -import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/agentFeedbackAttachments.js'; +import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, CustomizationRef, TerminalClaimKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, SessionTurnStartedAction, type ClientSessionAction, type SessionAction, type SessionInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, MessageAttachmentKind, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type MarkdownResponsePart, type MessageAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type SessionInputAnswer, type SessionInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; -import { IChatWidgetService } from '../../chat.js'; -import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult } from '../../../common/chatSessionsService.js'; import { isAgentFeedbackVariableEntry, isImageVariableEntry, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry, type IImageVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; -import { getChatSessionType } from '../../../common/model/chatUri.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; +import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult } from '../../../common/chatSessionsService.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { type IChatRequestVariableData } from '../../../common/model/chatModel.js'; import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; -import { type IChatRequestVariableData } from '../../../common/model/chatModel.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ILanguageModelsService } from '../../../common/languageModels.js'; -import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolInvocation, IToolResult, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { IChatWidgetService } from '../../chat.js'; import { getAgentHostIcon } from '../agentSessions.js'; -import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; +import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; +import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; +import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, userMessageToVariableData, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +export { toolDataToDefinition }; // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host @@ -348,12 +349,6 @@ export interface IAgentHostSessionHandlerConfig { * state that require authentication. */ readonly resolveAuthentication?: (protectedResources: ProtectedResourceMetadata[]) => Promise; - - /** - * Observable set of agent-level customizations to include in the active - * client set. When the value changes, active sessions are updated. - */ - readonly customizations?: IObservable; } /** @@ -391,9 +386,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Active session subscriptions, keyed by backend session URI string. */ private readonly _sessionSubscriptions = new Map>>(); - /** Observable of client-provided tools filtered by the allowlist and `when` clauses. */ - private readonly _clientToolsObs: IObservable; - constructor( config: IAgentHostSessionHandlerConfig, @IChatAgentService private readonly _chatAgentService: IChatAgentService, @@ -408,40 +400,23 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalService: IAgentHostUntitledProvisionalSessionService, @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IOpenerService private readonly _openerService: IOpenerService, + @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, ) { super(); this._config = config; - // Build an observable of client tools: all tools matching the - // allowlist setting, filtered by `when` clauses via observeTools. - // We pass `undefined` for the model since agent host sessions use - // server-side model selection and client tools should be available - // regardless of which model is active. - const allToolsObs = this._toolsService.observeTools(undefined); - const allowlistObs = observableConfigValue(ChatConfiguration.AgentHostClientTools, [], this._configurationService); - this._clientToolsObs = derived(reader => { - const allowlist = new Set(allowlistObs.read(reader)); - const allTools = allToolsObs.read(reader); - return allTools.filter(t => t.toolReferenceName !== undefined && allowlist.has(t.toolReferenceName)); - }); - - // When the client tools set changes, dispatch - // activeClientToolsChanged for all active sessions owned by this - // client so the server sees the updated tool list. this._register(autorun(reader => { - const tools = this._clientToolsObs.read(reader); - const defs = tools.map(toolDataToDefinition); + const defs = this._activeClientService.clientTools.read(reader); for (const [sessionResource] of this._activeSessions) { const backendSession = this._resolveSessionUri(sessionResource); const state = this._getSessionState(backendSession.toString()); if (state?.activeClient?.clientId === this._config.connection.clientId) { this._dispatchAction(backendSession, { type: ActionType.SessionActiveClientToolsChanged, - tools: defs, + tools: [...defs], }); } } @@ -479,20 +454,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }, )); - // When this client's exposed customization list changes, update sessions - // where this client is already active without reclaiming observed sessions. - if (config.customizations) { - this._register(autorun(reader => { - const refs = config.customizations!.read(reader); - for (const [sessionResource] of this._activeSessions) { - const backendSession = this._resolveSessionUri(sessionResource); - const state = this._getSessionState(backendSession.toString()); - if (state?.activeClient?.clientId === this._config.connection.clientId && !equals(state.activeClient.customizations ?? [], refs)) { - this._dispatchActiveClient(backendSession, refs); - } + // Push customization changes to sessions where this client is already active without reclaiming. + const customizationsObs = this._activeClientService.getCustomizations(config.sessionType); + this._register(autorun(reader => { + const refs = customizationsObs.read(reader); + for (const [sessionResource] of this._activeSessions) { + const backendSession = this._resolveSessionUri(sessionResource); + const state = this._getSessionState(backendSession.toString()); + if (state?.activeClient?.clientId === this._config.connection.clientId && !equals(state.activeClient.customizations ?? [], refs)) { + this._dispatchActiveClient(backendSession, [...refs]); } - })); - } + } + })); this._registerAgent(); } @@ -937,12 +910,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.dispatch(channel.toString(), action); } - private _getCurrentActiveClient(customizations: CustomizationRef[] = this._config.customizations?.get() ?? []): SessionActiveClient { - return { - clientId: this._config.connection.clientId, - tools: this._clientToolsObs.get().map(toolDataToDefinition), - customizations, - }; + private _getCurrentActiveClient(): SessionActiveClient { + return this._activeClientService.getActiveClient(this._config.sessionType, this._config.connection.clientId); } private _ensureActiveClientForMessage(backendSession: URI): void { @@ -951,7 +920,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (equals(state?.activeClient, activeClient)) { return; } - this._dispatchActiveClient(backendSession, activeClient.customizations ?? []); + this._dispatchAction(backendSession, { + type: ActionType.SessionActiveClientChanged, + activeClient, + }); } /** @@ -960,9 +932,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * client-provided tools. */ private _dispatchActiveClient(backendSession: URI, customizations: CustomizationRef[]): void { + const current = this._getCurrentActiveClient(); this._dispatchAction(backendSession, { type: ActionType.SessionActiveClientChanged, - activeClient: this._getCurrentActiveClient(customizations), + activeClient: { ...current, customizations }, }); } @@ -2499,11 +2472,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } - const activeClient = { - clientId: this._config.connection.clientId, - tools: this._clientToolsObs.get().map(toolDataToDefinition), - customizations: this._config.customizations?.get() ?? [], - }; + const activeClient = this._getCurrentActiveClient(); let session: URI; try { @@ -2974,20 +2943,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Client-provided tool helpers // ============================================================================= -/** - * Converts an internal {@link IToolData} to a protocol {@link ToolDefinition}. - */ -export function toolDataToDefinition(tool: IToolData): ToolDefinition { - return { - name: tool.toolReferenceName ?? tool.id, - title: tool.displayName, - description: tool.modelDescription, - inputSchema: tool.inputSchema?.type === 'object' - ? tool.inputSchema as ToolDefinition['inputSchema'] - : undefined, - }; -} - /** * Converts an internal {@link IToolResult} to a protocol * {@link import('../../../../../../platform/agentHost/common/state/protocol/state.js').ToolCallResult}. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolUtils.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolUtils.ts new file mode 100644 index 0000000000000..b350a50c7ad0c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolUtils.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { IToolData } from '../../../common/tools/languageModelToolsService.js'; + +/** + * Converts an internal {@link IToolData} to a protocol {@link ToolDefinition}. + */ +export function toolDataToDefinition(tool: IToolData): ToolDefinition { + return { + name: tool.toolReferenceName ?? tool.id, + title: tool.displayName, + description: tool.modelDescription, + inputSchema: tool.inputSchema?.type === 'object' + ? tool.inputSchema as ToolDefinition['inputSchema'] + : undefined, + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 01816a6e2b7ed..9fed5910529b9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -418,6 +418,12 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati export class PureItemProviderItemSource extends Disposable implements IAICustomizationItemSource { readonly onDidAICustomizationItemsChange: Event; + // Caches the raw, unfiltered items returned by the provider so each + // `fetchAICustomizationItems` call can apply its own `promptType` filter. + // Previously the cache stored items already filtered/normalized for the + // first requested `promptType`, which caused every subsequent section + // (Instructions, Skills, …) to see an empty list whenever the Agents tab + // was loaded first. private cachedPromise: Promise | undefined; constructor( 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 aa8f2e47501ca..944e41bfd9cab 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -878,8 +878,8 @@ configurationRegistry.registerConfiguration({ items: { type: 'string', }, - markdownDescription: nls.localize('chat.plugins.marketplaces', "Plugin marketplaces to query. Entries may be GitHub shorthand (`owner/repo`), direct Git repository URIs (`https://...git`, `ssh://...git`, or `git@host:path.git`), or local repository URIs (`file:///...`). Equivalent GitHub shorthand and URI entries are deduplicated."), - default: ['github/copilot-plugins', 'github/awesome-copilot'], + markdownDescription: nls.localize('chat.plugins.marketplaces', "Plugin marketplaces to query. Entries may be GitHub shorthand (`owner/repo` or `owner/repo#ref`), direct Git repository URIs (`https://...git`, `ssh://...git`, or `git@host:path.git`, each optionally suffixed with `#ref`), or local repository URIs (`file:///...`). Equivalent GitHub shorthand and URI entries are deduplicated."), + default: ['github/copilot-plugins', 'github/awesome-copilot#marketplace'], scope: ConfigurationScope.APPLICATION, tags: ['experimental'], }, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index b36704dda59a1..7f7c9eb8adbb9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -253,11 +253,19 @@ function resolveConfigProperty( */ function getPriceCategoryLabel(priceCategory: string | undefined): string | undefined { switch (priceCategory) { - case 'low': return localize('chat.priceCategory.low', "Low cost"); - case 'medium': return localize('chat.priceCategory.medium', "Medium cost"); - case 'high': return localize('chat.priceCategory.high', "High cost"); - case 'very_high': return localize('chat.priceCategory.veryHigh', "Very high cost"); - default: return undefined; + case undefined: + case '': + return undefined; + case 'low': + return localize('chat.priceCategory.low', "Low cost"); + case 'medium': + return localize('chat.priceCategory.medium', "Medium cost"); + case 'high': + return localize('chat.priceCategory.high', "High cost"); + case 'very_high': + return localize('chat.priceCategory.veryHigh', "Very high cost"); + default: + return localize('chat.priceCategory.unknown', "{0} cost", priceCategory.charAt(0).toUpperCase() + priceCategory.slice(1)); } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts index daab4c6523cf9..6a1995778246a 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/marketplaceReference.ts @@ -18,6 +18,7 @@ export interface IMarketplaceReference { readonly canonicalId: string; readonly cacheSegments: readonly string[]; readonly kind: MarketplaceReferenceKind; + readonly ref?: string; readonly githubRepo?: string; readonly localRepositoryUri?: URI; } @@ -76,17 +77,19 @@ export function parseMarketplaceReference(value: string): IMarketplaceReference return scpReference; } - const shorthandMatch = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(rawValue); + const shorthandMatch = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#(.+))?$/.exec(rawValue); if (shorthandMatch) { const owner = shorthandMatch[1]; const repo = shorthandMatch[2]; + const ref = shorthandMatch[3]; return { rawValue, - displayLabel: `${owner}/${repo}`, + displayLabel: rawValue, cloneUrl: `https://github.com/${owner}/${repo}.git`, - canonicalId: getGitHubCanonicalId(owner, repo), - cacheSegments: ['github.com', owner, repo], + canonicalId: getGitHubCanonicalId(owner, repo, ref), + cacheSegments: ['github.com', owner, repo, ...getRefCacheSegments(ref)], kind: MarketplaceReferenceKind.GitHubShorthand, + ref, githubRepo: `${owner}/${repo}`, }; } @@ -104,6 +107,9 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | const scheme = uri.scheme.toLowerCase(); if (scheme === 'file' && /^file:\/\//i.test(rawValue)) { + if (uri.fragment) { + return undefined; + } const localRepositoryUri = URI.file(uri.fsPath); return { rawValue, @@ -128,6 +134,8 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | if (!normalizedPath) { return undefined; } + const ref = uri.fragment || undefined; + const cloneUri = uri.fragment ? uri.with({ fragment: '' }) : uri; const gitSuffix = '.git'; const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); @@ -143,16 +151,17 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | return { rawValue, displayLabel: rawValue, - cloneUrl: rawValue, - canonicalId: `git:${uri.authority.toLowerCase()}/${canonicalPath}`, - cacheSegments: [sanitizedAuthority, ...pathSegments], + cloneUrl: cloneUri.toString(), + canonicalId: appendRefSuffix(`git:${uri.authority.toLowerCase()}/${canonicalPath}`, ref), + cacheSegments: [sanitizedAuthority, ...pathSegments, ...getRefCacheSegments(ref)], kind: MarketplaceReferenceKind.GitUri, + ref, githubRepo, }; } function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | undefined { - const match = /^([^@\s]+)@([^:\s]+):(.+\.git)$/i.exec(rawValue); + const match = /^([^@\s]+)@([^:\s]+):(.+?\.git)(?:#(.+))?$/i.exec(rawValue); if (!match) { return undefined; } @@ -160,6 +169,7 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | const gitSuffix = '.git'; const authority = match[2]; const pathWithGit = match[3].replace(/^\/+/, ''); + const ref = match[4]; if (!pathWithGit.toLowerCase().endsWith(gitSuffix)) { return undefined; } @@ -171,10 +181,11 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | return { rawValue, displayLabel: rawValue, - cloneUrl: rawValue, - canonicalId: `git:${authority.toLowerCase()}/${pathWithGit.toLowerCase()}`, - cacheSegments: [sanitizePathSegment(authority.toLowerCase()), ...pathSegments], + cloneUrl: `${match[1]}@${authority}:${pathWithGit}`, + canonicalId: appendRefSuffix(`git:${authority.toLowerCase()}/${pathWithGit.toLowerCase()}`, ref), + cacheSegments: [sanitizePathSegment(authority.toLowerCase()), ...pathSegments, ...getRefCacheSegments(ref)], kind: MarketplaceReferenceKind.GitUri, + ref, githubRepo, }; } @@ -212,8 +223,16 @@ function extractGitHubRepo(authority: string, pathWithoutGit: string): string | return undefined; } -function getGitHubCanonicalId(owner: string, repo: string): string { - return `github:${owner.toLowerCase()}/${repo.toLowerCase()}`; +function getGitHubCanonicalId(owner: string, repo: string, ref?: string): string { + return appendRefSuffix(`github:${owner.toLowerCase()}/${repo.toLowerCase()}`, ref); +} + +function appendRefSuffix(canonicalId: string, ref: string | undefined): string { + return ref ? `${canonicalId}#${encodeURIComponent(ref)}` : canonicalId; +} + +function getRefCacheSegments(ref: string | undefined): string[] { + return ref ? [`ref_${encodeURIComponent(ref)}`] : []; } function sanitizePathSegment(value: string): string { diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 5e1464b580b3e..22cb3bc650502 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -463,7 +463,8 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke if (token.isCancellationRequested) { return undefined; } - const url = `https://raw.githubusercontent.com/${repo}/main/${defPath}`; + const ref = encodeURIComponent(reference.ref ?? 'main'); + const url = `https://raw.githubusercontent.com/${repo}/${ref}/${defPath}`; try { const context = await this._requestService.request({ type: 'GET', url, callSite: 'pluginMarketplaceService.fetchPluginList' }, token); const statusCode = context.res.statusCode; diff --git a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts index 052b93699ae29..7db6ffa8c38c8 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/workspacePluginSettingsService.ts @@ -55,6 +55,7 @@ interface IMarketplaceSourceJson { readonly source?: string; readonly repo?: string; readonly url?: string; + readonly ref?: string; readonly path?: string; } @@ -62,6 +63,7 @@ interface IExtraMarketplaceJson { readonly source?: string | IMarketplaceSourceJson; readonly repo?: string; readonly url?: string; + readonly ref?: string; readonly path?: string; } @@ -79,29 +81,42 @@ function marketplaceEntryToReference(entry: IExtraMarketplaceJson): IMarketplace let sourceType: string | undefined; let repo: string | undefined; let url: string | undefined; + let ref: string | undefined; if (typeof entry.source === 'object' && entry.source !== null) { const nested = entry.source; sourceType = nested.source; repo = nested.repo; url = nested.url; + ref = nested.ref; } else { sourceType = entry.source as string | undefined; repo = entry.repo; url = entry.url; + ref = entry.ref; } if (sourceType === 'github' && typeof repo === 'string') { - return parseMarketplaceReference(repo); + return parseMarketplaceReference(appendMarketplaceRef(repo, ref)); } if (sourceType === 'git' && typeof url === 'string') { - return parseMarketplaceReference(url); + return parseMarketplaceReference(appendMarketplaceRef(url, ref)); } return undefined; } +function appendMarketplaceRef(value: string, ref: string | undefined): string { + if (!ref) { + return value; + } + + const fragmentIndex = value.indexOf('#'); + const baseValue = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); + return `${baseValue}#${ref}`; +} + /** * Parses `enabledPlugins` from a JSON object. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts index e2e0d5519e67d..956575bf92d46 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentCustomizationContentExpander.test.ts @@ -330,6 +330,24 @@ suite('AgentCustomizationContentExpander', () => { assert.strictEqual(ruleItems.length, 1); assert.strictEqual(ruleItems[0].userInvocable, undefined, 'rules must not expose userInvocable'); }); + + test('emits one item per .mdc file per the Open Plugins spec', async () => { + const pluginRoot = URI.file('/plugins/rules-mdc'); + await mockFiles(fileService, [ + { path: '/plugins/rules-mdc/rules/style.mdc', contents: ['Some rule content'] }, + { path: '/plugins/rules-mdc/rules/other.mdc', contents: ['Another rule'] }, + // `.txt` and similar must still be ignored + { path: '/plugins/rules-mdc/rules/readme.txt', contents: ['not a rule'] }, + ]); + + const expander = new AgentCustomizationContentExpander(fileService, new NullLogService()); + const items = await expand(expander, pluginRoot, REMOTE_HOST_GROUP, false, AICustomizationSources.plugin, CancellationToken.None); + const ruleItems = items.filter(i => i.type === PromptsType.instructions); + assert.deepStrictEqual( + ruleItems.map(i => i.name).sort(), + ['other', 'style'], + ); + }); }); // ----------------------------------------------------------------------- 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 d1f7f8b4a48f3..1abcf628f2aca 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 @@ -6,9 +6,9 @@ import assert from 'assert'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ISettableObservable, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; +import { constObservable, derived, ISettableObservable, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; @@ -20,7 +20,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, Ag import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/agentFeedbackAttachments.js'; import { ActionType, isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import type { CustomizationRef, ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, MessageAttachmentKind, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; @@ -54,6 +54,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { IChatWidgetService } from '../../../browser/chat.js'; @@ -497,9 +498,45 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv disposeSession: async () => { }, ...provisionalServiceOverride, } as Partial as IAgentHostUntitledProvisionalSessionService); + const customizationsByType = new Map>(); + const seedActiveClient = (sessionType: string, entry: { customizations: IObservable }): IDisposable => { + customizationsByType.set(sessionType, entry.customizations); + return toDisposable(() => { + if (customizationsByType.get(sessionType) === entry.customizations) { + customizationsByType.delete(sessionType); + } + }); + }; + const activeClientService: IAgentHostActiveClientService = { + _serviceBrand: undefined, + registerForAgent: (sessionType) => { + // Tests that exercise customization changes seed entries via + // `seedActiveClient` directly. This stub just records an empty + // entry so the contribution flow completes. + const inner = seedActiveClient(sessionType, { + customizations: constObservable([]), + }); + return { + syncProvider: { + onDidChange: Event.None, + isDisabled: () => false, + setDisabled: () => { }, + }, + dispose: () => inner.dispose(), + }; + }, + getActiveClient: (sessionType: string, clientId: string) => ({ + clientId, + tools: [], + customizations: [...(customizationsByType.get(sessionType)?.get() ?? [])], + }), + getCustomizations: (sessionType: string) => derived(reader => customizationsByType.get(sessionType)?.read(reader) ?? []), + clientTools: constObservable([]), + }; + instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IOpenerService, openerService as IOpenerService); - return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient }; } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial }) { @@ -4358,11 +4395,12 @@ suite('AgentHostChatContribution', () => { suite('customizations', () => { test('dispatches activeClientChanged when a new session is created', async () => { - const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); const customizations = observableValue('customizations', [ { uri: 'file:///plugin-a', displayName: 'Plugin A' }, ]); + disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, @@ -4372,7 +4410,6 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', - customizations, })); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); @@ -4390,9 +4427,10 @@ suite('AgentHostChatContribution', () => { }); test('re-dispatches activeClientChanged when customizations observable changes', async () => { - const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const { instantiationService, agentHostService, chatAgentService, seedActiveClient } = createTestServices(disposables); const customizations = observableValue('customizations', []); + disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, @@ -4402,7 +4440,6 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', - customizations, })); // Create a session first @@ -4505,10 +4542,11 @@ suite('AgentHostChatContribution', () => { }); test('dispatches activeClientChanged when restoring a session where current client customizations are stale', async () => { - const { instantiationService, agentHostService } = createTestServices(disposables); + const { instantiationService, agentHostService, seedActiveClient } = createTestServices(disposables); const customizations = observableValue('customizations', [ { uri: 'file:///plugin-new', displayName: 'Plugin New' }, ]); + disposables.add(seedActiveClient('agent-host-copilot', { customizations })); const sessionResource = AgentSession.uri('copilot', 'existing-session'); const summary: SessionSummary = { resource: sessionResource.toString(), @@ -4536,7 +4574,6 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', - customizations, })); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index c4bf83290a518..eb07eebf489f4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -30,6 +30,7 @@ import { IProductService } from '../../../../../../platform/product/common/produ import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { AgentHostSessionHandler, toolDataToDefinition, toolResultToProtocol } from '../../../browser/agentSessions/agentHost/agentHostSessionHandler.js'; +import { AgentHostActiveClientService, IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -47,6 +48,7 @@ import { IAgentPluginService } from '../../../common/plugins/agentPluginService. import { IOutputService } from '../../../../../services/output/common/output.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; // ============================================================================= // Unit tests for toolDataToDefinition and toolResultToProtocol @@ -438,6 +440,16 @@ suite('AgentHostClientTools', () => { instantiationService.stub(IAgentPluginService, { plugins: observableValue('plugins', []), }); + instantiationService.stub(IPromptsService, new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override readonly onDidChangeInstructions = Event.None; + + override async listPromptFilesForStorage() { + return []; + } + }()); instantiationService.stub(ITerminalChatService, { onDidContinueInBackground: Event.None, registerTerminalInstanceWithToolSession: () => { }, @@ -457,6 +469,11 @@ suite('AgentHostClientTools', () => { }); instantiationService.stub(ILanguageModelToolsService, toolsService); + // Use the real active-client service so the handler's tools autorun + // observes the mocked ILanguageModelToolsService + allowlist setting. + const activeClientService = disposables.add(instantiationService.createInstance(AgentHostActiveClientService)); + instantiationService.stub(IAgentHostActiveClientService, activeClientService); + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'agent-host-copilot', diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index b625537cd5cdf..2f3432e6834ed 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -756,4 +756,117 @@ suite('AICustomizationItemsModel', () => { assert.strictEqual(count.get(), 1, 'remote row is folded into the labelless local plugin via basename'); }); }); + + // Regression coverage for the agent-host harness path + // (`PureItemProviderItemSource`). The item-source caches the provider's + // items and applies each section's `promptType` filter at fetch time, + // so reading one section (e.g. Agents) must not poison the cached + // items for any other section (e.g. Instructions). + suite('agent host item source caches all types', () => { + + let disposables: DisposableStore; + let instaService: TestInstantiationService; + let providerItems: ICustomizationItem[]; + + setup(() => { + disposables = new DisposableStore(); + providerItems = []; + + const sessionType = 'agent-host-test'; + const provider: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: () => Promise.resolve(providerItems.slice()), + }; + const descriptor: IHarnessDescriptor = { + id: sessionType, + label: 'Agent Host Test', + icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [] }), + itemProvider: provider, + }; + const sessionResource = URI.parse(`${sessionType}:///active-session`); + const availableHarnesses = observableValue('availableHarnesses', [descriptor]); + + instaService = workbenchInstantiationService({}, disposables); + instaService.stub(IPromptsService, { + onDidChangeCustomAgents: Event.None, + onDidChangeSlashCommands: Event.None, + onDidChangeSkills: Event.None, + onDidChangeHooks: Event.None, + onDidChangeInstructions: Event.None, + listPromptFiles: async () => [], + getCustomAgents: async () => [], + findAgentSkills: async () => [], + getHooks: async () => undefined, + getInstructionFiles: async () => [], + getDisabledPromptFiles: () => new ResourceSet(), + }); + instaService.stub(IAICustomizationWorkspaceService, { + activeProjectRoot: observableValue('test', undefined), + getActiveProjectRoot: () => undefined, + managementSections: [AICustomizationManagementSection.Agents], + isSessionsWindow: false, + welcomePageFeatures: { showGettingStartedBanner: false }, + getStorageSourceFilter: () => ({ sources: [] }), + getSkillUIIntegrations: () => new Map(), + hasOverrideProjectRoot: observableValue('test', false), + commitFiles: async () => { }, + deleteFiles: async () => { }, + generateCustomization: async () => { }, + setOverrideProjectRoot: () => { }, + clearOverrideProjectRoot: () => { }, + }); + const activeSessionResource = observableValue('activeSessionResource', sessionResource); + const activeHarness = derived(reader => getChatSessionType(activeSessionResource.read(reader))); + instaService.stub(ICustomizationHarnessService, { + activeSessionResource, + activeHarness, + availableHarnesses, + setActiveSession: (next: URI) => activeSessionResource.set(next, undefined), + getStorageSourceFilter: () => ({ sources: [] }), + getActiveDescriptor: () => availableHarnesses.get().find(d => d.id === activeHarness.get())!, + findHarnessById: (id: string) => availableHarnesses.get().find(d => d.id === id), + registerExternalHarness: () => ({ dispose() { } }), + }); + instaService.stub(IAgentPluginService, { + plugins: observableValue('plugins', []), + enablementModel: { + readEnabled: () => ContributionEnablementState.EnabledProfile, + setEnabled: () => { }, + remove: () => { }, + }, + }); + }); + + teardown(() => disposables.dispose()); + + test('observing one section does not hide items of other sections', async () => { + providerItems = [ + { uri: URI.parse('agent-host://t/agents/coder.agent.md'), type: PromptsType.agent, name: 'coder', source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: true }, + { uri: URI.parse('agent-host://t/rules/style.instructions.md'), type: PromptsType.instructions, name: 'style', source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: undefined }, + { uri: URI.parse('agent-host://t/skills/repo/SKILL.md'), type: PromptsType.skill, name: 'repo', source: AICustomizationSources.plugin, extensionId: undefined, pluginUri: undefined, userInvocable: true }, + ]; + + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + // Observe the Agents section first — this primes the underlying + // cache. Then observe Instructions on the same model; the bug + // caused this second observation to see an empty list because + // the cache had already been normalized for `PromptsType.agent`. + const agentItems = model.getItems(AICustomizationManagementSection.Agents); + await model.whenSectionLoaded(AICustomizationManagementSection.Agents); + const instructionItems = model.getItems(AICustomizationManagementSection.Instructions); + await model.whenSectionLoaded(AICustomizationManagementSection.Instructions); + + assert.deepStrictEqual( + { + agents: agentItems.get().map(i => i.name).sort(), + instructions: instructionItems.get().map(i => i.name).sort(), + }, + { + agents: ['coder'], + instructions: ['style'], + }, + ); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 3479662495e69..7a8429e9f5749 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -98,6 +98,14 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode'); }); + test('uses ref-specific cache path for GitHub shorthand plugin references', () => { + const service = createService(); + const plugin = createPlugin('microsoft/vscode#marketplace', 'plugins/myPlugin'); + const uri = service.getRepositoryUri(plugin.marketplaceReference, plugin.marketplaceType); + + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode/ref_marketplace'); + }); + test('uses marketplaces cache path for direct git URI plugin references', () => { const service = createService(); const plugin = createPlugin('https://example.com/org/repo.git', 'plugins/myPlugin'); @@ -131,6 +139,35 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode'); }); + test('passes marketplace refs through cloneRepository', async () => { + let clonedRef: string | undefined; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IUserDataProfileService, { currentProfile: { agentPluginsHome: URI.file('/cache/agentPlugins') } } as unknown as IUserDataProfileService); + instantiationService.stub(IFileService, { + exists: async () => false, + createFolder: async () => undefined, + } as unknown as IFileService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); + instantiationService.stub(IPluginGitService, stubPluginGit({ + cloneRepository: async (_cloneUrl, _targetDir, ref) => { + clonedRef = ref; + }, + })); + instantiationService.stub(IProgressService, { + withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise) => callback(), + } as unknown as IProgressService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + + const service = instantiationService.createInstance(AgentPluginRepositoryService); + const plugin = createPlugin('microsoft/vscode#marketplace', 'plugins/myPlugin'); + await service.ensureRepository(plugin.marketplaceReference, { marketplaceType: plugin.marketplaceType }); + + assert.strictEqual(clonedRef, 'marketplace'); + }); + test('concurrent ensureRepository calls for the same marketplace clone only once', async () => { let cloneCount = 0; const instantiationService = store.add(new TestInstantiationService()); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index fe3f9540822b9..6daf96b4306b0 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; -import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { bufferToStream, VSBuffer } from '../../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -41,6 +42,21 @@ suite('PluginMarketplaceService', () => { assert.strictEqual(parsed.githubRepo, 'microsoft/vscode'); }); + test('parses GitHub shorthand marketplace with ref suffix', () => { + const parsed = parseMarketplaceReference('microsoft/vscode#marketplace'); + assert.ok(parsed); + if (!parsed) { + return; + } + assert.strictEqual(parsed.kind, MarketplaceReferenceKind.GitHubShorthand); + assert.strictEqual(parsed.cloneUrl, 'https://github.com/microsoft/vscode.git'); + assert.strictEqual(parsed.canonicalId, 'github:microsoft/vscode#marketplace'); + assert.strictEqual(parsed.displayLabel, 'microsoft/vscode#marketplace'); + assert.deepStrictEqual(parsed.cacheSegments, ['github.com', 'microsoft', 'vscode', 'ref_marketplace']); + assert.strictEqual(parsed.ref, 'marketplace'); + assert.strictEqual(parsed.githubRepo, 'microsoft/vscode'); + }); + test('parses direct HTTPS and SSH marketplaces ending in .git', () => { const https = parseMarketplaceReference('https://example.com/org/repo.git'); assert.ok(https); @@ -73,6 +89,22 @@ suite('PluginMarketplaceService', () => { assert.strictEqual(parsed.githubRepo, undefined); }); + test('parses git URI marketplaces with ref suffix', () => { + const https = parseMarketplaceReference('https://example.com/org/repo.git#marketplace'); + assert.ok(https); + assert.strictEqual(https?.cloneUrl, 'https://example.com/org/repo.git'); + assert.strictEqual(https?.canonicalId, 'git:example.com/org/repo.git#marketplace'); + assert.deepStrictEqual(https?.cacheSegments, ['example.com', 'org', 'repo', 'ref_marketplace']); + assert.strictEqual(https?.ref, 'marketplace'); + + const scp = parseMarketplaceReference('git@example.com:org/repo.git#marketplace'); + assert.ok(scp); + assert.strictEqual(scp?.cloneUrl, 'git@example.com:org/repo.git'); + assert.strictEqual(scp?.canonicalId, 'git:example.com/org/repo.git#marketplace'); + assert.deepStrictEqual(scp?.cacheSegments, ['example.com', 'org', 'repo', 'ref_marketplace']); + assert.strictEqual(scp?.ref, 'marketplace'); + }); + test('populates githubRepo for GitHub HTTPS URLs', () => { const withGit = parseMarketplaceReference('https://github.com/owner/repo.git'); assert.ok(withGit); @@ -175,6 +207,64 @@ suite('PluginMarketplaceService', () => { assert.strictEqual(parsed.length, 1); assert.strictEqual(parsed[0].canonicalId, 'github:microsoft/vscode'); }); + + test('treats different marketplace refs as distinct references', () => { + const parsed = parseMarketplaceReferences([ + 'microsoft/vscode#main', + 'microsoft/vscode#marketplace', + 'https://github.com/microsoft/vscode.git#marketplace', + ]); + + assert.deepStrictEqual(parsed.map(r => r.canonicalId), [ + 'github:microsoft/vscode#main', + 'github:microsoft/vscode#marketplace', + 'git:github.com/microsoft/vscode.git#marketplace', + ]); + }); +}); + +suite('PluginMarketplaceService - GitHub marketplace refs', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('fetches GitHub marketplace definitions from the configured ref', async () => { + const requestUrls: string[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [ChatConfiguration.PluginMarketplaces]: ['microsoft/vscode#marketplace'], + [ChatConfiguration.PluginsEnabled]: true, + })); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); + instantiationService.stub(IFileService, {} as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, { + agentPluginsHome: URI.file('/agent-plugins'), + ensureRepository: async () => { + throw new Error('should not clone for 5xx responses'); + }, + } as Partial as IAgentPluginRepositoryService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRequestService, { + request: async (options: { url: string }) => { + requestUrls.push(options.url); + return { res: { headers: {}, statusCode: 500 }, stream: bufferToStream(VSBuffer.fromString('')) }; + }, + } as Partial as IRequestService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + instantiationService.stub(IWorkspacePluginSettingsService, { + extraMarketplaces: observableValue('test.extraMarketplaces', []), + enabledPlugins: observableValue('test.enabledPlugins', new Map()), + } as Partial as IWorkspacePluginSettingsService); + instantiationService.stub(IWorkspaceTrustManagementService, { + isWorkspaceTrusted: () => true, + onDidChangeTrust: Event.None, + } as Partial as IWorkspaceTrustManagementService); + + const service = store.add(instantiationService.createInstance(PluginMarketplaceService)); + await service.fetchMarketplacePlugins(CancellationToken.None); + + assert.ok(requestUrls.length > 0); + assert.ok(requestUrls.every(url => url.includes('/marketplace/'))); + assert.ok(requestUrls.every(url => !url.includes('/main/'))); + }); }); suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { @@ -454,7 +544,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { test('hydrates a github-sourced plugin from installed.json name and marketplace cache after restart', async () => { // Simulates: user installs the "azure" plugin from the - // "github/awesome-copilot" marketplace (fetched via HTTP, never + // "github/awesome-copilot#marketplace" marketplace (fetched via HTTP, never // cloned). After restart, installed.json contains only the durable // identity for that plugin; the full descriptor is recovered from // marketplace data cached from the prior fetch. @@ -462,7 +552,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { const storageService = store.add(new InMemoryStorageService()); const fileService = new TestFileService(); - const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot')!; + const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot#marketplace')!; const azurePlugin = makeAzurePlugin(awesomeCopilot); storeMarketplaceCache(storageService, awesomeCopilot, azurePlugin); const azurePluginUri = URI.joinPath(CACHE_ROOT, 'github.com', 'microsoft', 'azure-skills', '.github', 'plugins', 'azure-skills'); @@ -479,7 +569,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ - [ChatConfiguration.PluginMarketplaces]: ['github/awesome-copilot'], + [ChatConfiguration.PluginMarketplaces]: ['github/awesome-copilot#marketplace'], [ChatConfiguration.PluginsEnabled]: true, })); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); @@ -521,7 +611,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { const storageService = store.add(new InMemoryStorageService()); const fileService = new TestFileService(); - const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot')!; + const awesomeCopilot = parseMarketplaceReference('github/awesome-copilot#marketplace')!; const azurePluginUri = URI.joinPath(CACHE_ROOT, 'github.com', 'microsoft', 'azure-skills', '.github', 'plugins', 'azure-skills'); const azurePlugin = makeAzurePlugin(awesomeCopilot); storeMarketplaceCache(storageService, awesomeCopilot, azurePlugin); @@ -529,7 +619,7 @@ suite('PluginMarketplaceService - hydration after restart', () => { function makeService(): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ - [ChatConfiguration.PluginMarketplaces]: ['github/awesome-copilot'], + [ChatConfiguration.PluginMarketplaces]: ['github/awesome-copilot#marketplace'], [ChatConfiguration.PluginsEnabled]: true, })); instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as Partial as IEnvironmentService); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts index 0a1434d439db2..f72810ad59f69 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/workspacePluginSettingsService.test.ts @@ -147,6 +147,25 @@ suite('WorkspacePluginSettingsService', () => { assert.strictEqual(marketplaces[0].reference.githubRepo, 'owner/repo'); })); + test('parses marketplace refs from extraKnownMarketplaces', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await writeClaudeSettings(JSON.stringify({ + extraKnownMarketplaces: { + 'my-marketplace': { + source: 'github', + repo: 'owner/repo', + ref: 'marketplace', + } + } + })); + + const service = createService(); + await waitForState(service.extraMarketplaces, v => v.length > 0); + + const marketplaces = service.extraMarketplaces.get(); + assert.strictEqual(marketplaces[0].reference.ref, 'marketplace'); + assert.strictEqual(marketplaces[0].reference.canonicalId, 'github:owner/repo#marketplace'); + })); + test('parses nested source object from extraKnownMarketplaces', () => runWithFakedTimers({ useFakeTimers: true }, async () => { await writeClaudeSettings(JSON.stringify({ extraKnownMarketplaces: { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts index b1438fb3f8767..eb4c1ba87fa24 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../base/common/objects.js'; import { IAgentConnection, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; @@ -20,24 +20,29 @@ import { readAgentHostSandboxValues, SANDBOX_SETTING_KEYS } from '../common/sand * agent host (local + remote) via `RootConfigChanged` actions, so the * agent-host terminal sandbox engine can mirror the user's preferences. * - * Each push is schema-guarded against the receiving host's published root - * config schema, so older hosts that don't advertise the sandbox keys - * gracefully ignore them. Per-key value comparison against - * `rootState.config.values` suppresses no-op dispatches. + * The forwarder is deliberately one-directional: it pushes only when + * - a connection comes online (initial push, deferred until the host + * advertises the sandbox schema), or + * - a sandbox-related workbench setting changes. * - * The forwarder reacts to: - * - workbench `IConfigurationService.onDidChangeConfiguration` for any - * sandbox-related key (modern or deprecated) - * - `IAgentHostService.rootState.onDidChange` / per-remote rootState - * hydration (covers the initial push race where state arrives after - * construction) - * - `IRemoteAgentHostService.onDidChangeConnections` (new remotes get an - * initial push as soon as they connect) + * It does NOT react to agent-host root-state changes after the initial + * push, so concurrent edits coming from the host (or from another client + * attached to the same host) do not trigger a push-back loop. Each push + * is schema-guarded so older hosts that don't advertise the sandbox keys + * are skipped silently. */ export class AgentHostSandboxForwarder extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.agentHostSandboxForwarder'; - private readonly _remoteListeners = this._register(new MutableDisposable()); + /** + * Connections that have already had their initial push attempted + * (successfully or via a pending listener waiting for the sandbox + * schema). Used to avoid re-scheduling pushes for connections that + * are still present across `onDidChangeConnections` events. + */ + private readonly _scheduled = new Map(); + + private _desired: Record | undefined; constructor( @IAgentHostService private readonly _localAgentHostService: IAgentHostService, @@ -49,66 +54,107 @@ export class AgentHostSandboxForwarder extends Disposable implements IWorkbenchC this._register(this._configurationService.onDidChangeConfiguration(e => { if (SANDBOX_SETTING_KEYS.some(key => e.affectsConfiguration(key))) { + this._desired = undefined; this._pushToAllConnections(); } })); - this._register(this._localAgentHostService.rootState.onDidChange(() => { - this._pushToConnection(this._localAgentHostService); - })); - this._register(this._remoteAgentHostService.onDidChangeConnections(() => { - this._refreshRemoteListeners(); - this._pushToAllConnections(); + this._syncConnectionListeners(); })); - this._refreshRemoteListeners(); - - this._pushToAllConnections(); + this._syncConnectionListeners(); } - private _refreshRemoteListeners(): void { - const store = new DisposableStore(); + private _syncConnectionListeners(): void { + const live = new Set(); + const ensureScheduled = (connection: IAgentConnection) => { + live.add(connection); + if (!this._scheduled.has(connection)) { + this._scheduleInitialPush(connection); + } + }; + ensureScheduled(this._localAgentHostService); for (const info of this._remoteAgentHostService.connections) { const connection = this._remoteAgentHostService.getConnection(info.address); if (connection) { - store.add(connection.rootState.onDidChange(() => this._pushToConnection(connection))); + ensureScheduled(connection); + } + } + for (const [connection, listener] of this._scheduled) { + if (!live.has(connection)) { + listener.dispose(); + this._scheduled.delete(connection); } } - this._remoteListeners.value = store; + } + + /** + * Push immediately if the host is already advertising the sandbox + * schema; otherwise subscribe to `rootState.onDidChange` long enough + * to catch the schema and push exactly once, then unsubscribe. + */ + private _scheduleInitialPush(connection: IAgentConnection): void { + if (this._tryPush(connection)) { + this._scheduled.set(connection, Disposable.None); + return; + } + const listener = connection.rootState.onDidChange(() => { + if (this._tryPush(connection)) { + this._scheduled.get(connection)?.dispose(); + this._scheduled.set(connection, Disposable.None); + } + }); + this._scheduled.set(connection, listener); } private _pushToAllConnections(): void { - this._pushToConnection(this._localAgentHostService); + this._tryPush(this._localAgentHostService); for (const info of this._remoteAgentHostService.connections) { const connection = this._remoteAgentHostService.getConnection(info.address); if (connection) { - this._pushToConnection(connection); + this._tryPush(connection); } } } - private _pushToConnection(connection: IAgentConnection): void { + /** + * Attempt to dispatch the desired sandbox config to `connection`. + * Returns `true` once the host has advertised the sandbox schema + * (whether or not an actual dispatch was needed); `false` if the + * schema is not yet available and the caller should keep waiting. + */ + private _tryPush(connection: IAgentConnection): boolean { const rootState = connection.rootState.value; if (!rootState || rootState instanceof Error) { - return; + return false; } const schemaProperties = rootState.config?.schema.properties; if (!schemaProperties?.[AgentHostSandboxConfigKey.Sandbox]) { - // Older hosts that don't advertise the `sandbox` config key — - // skip silently. - return; - } - const desired = readAgentHostSandboxValues(this._configurationService, this._logService); - if (typeof desired.enabled === 'object') { - delete desired.enabled; // Work around nested enabled.windows setting. + return false; } + const desired = this._getDesired(); const current = (rootState.config?.values?.[AgentHostSandboxConfigKey.Sandbox] as Record | undefined) ?? {}; - if (equals(current, desired)) { - return; + if (!equals(current, desired)) { + connection.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostSandboxConfigKey.Sandbox]: desired }, + }); } - connection.dispatch(ROOT_STATE_URI, { - type: ActionType.RootConfigChanged, - config: { [AgentHostSandboxConfigKey.Sandbox]: desired }, - }); + return true; + } + + private _getDesired(): Record { + if (this._desired === undefined) { + this._desired = readAgentHostSandboxValues(this._configurationService, this._logService); + } + return this._desired; + } + + override dispose(): void { + for (const listener of this._scheduled.values()) { + listener.dispose(); + } + this._scheduled.clear(); + super.dispose(); } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts index c09a14c203bb2..a3158175fddea 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts @@ -305,4 +305,58 @@ suite('AgentHostSandboxForwarder', () => { assert.deepStrictEqual(local.dispatched, []); }); + + test('does not push back after initial push when the host updates rootState', () => { + const { local } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + + // Initial hydration triggers exactly one push. + local.setRootState(rootStateWithSandboxSchema()); + assert.strictEqual(local.dispatched.length, 1); + + // Subsequent rootState changes from the host side (different sandbox + // values, unrelated config keys, anything) must NOT trigger another + // push — that's the push-back loop the forwarder is designed to avoid. + local.setRootState(rootStateWithSandboxSchema({ [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.Off })); + local.setRootState(rootStateWithSandboxSchema({ [AgentHostSandboxKey.AllowUnsandboxedCommands]: true })); + local.setRootState(rootStateWithSandboxSchema()); + + assert.strictEqual(local.dispatched.length, 1); + }); + + test('does not re-push to existing connections when a new remote appears', () => { + const { local, remote } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + local.setRootState(rootStateWithSandboxSchema()); + assert.strictEqual(local.dispatched.length, 1); + + const firstRemote = remote.addConnection('remote-a.example:9000'); + firstRemote.setRootState(rootStateWithSandboxSchema()); + assert.strictEqual(firstRemote.dispatched.length, 1); + assert.strictEqual(local.dispatched.length, 1); + + // Adding a second remote must not cause a redundant push to the local + // host or to the already-pushed first remote. + const secondRemote = remote.addConnection('remote-b.example:9000'); + secondRemote.setRootState(rootStateWithSandboxSchema()); + + assert.strictEqual(local.dispatched.length, 1); + assert.strictEqual(firstRemote.dispatched.length, 1); + assert.strictEqual(secondRemote.dispatched.length, 1); + }); + + test('cleans up the pending listener when a remote disconnects before hydrating', () => { + const { remote } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + + const remoteConn = remote.addConnection('remote.example:9000'); + // Connection never hydrates → forwarder is still subscribed to its + // rootState.onDidChange waiting for the schema. + assert.deepStrictEqual(remoteConn.dispatched, []); + + remote.removeConnection('remote.example:9000'); + // If the listener wasn't disposed, the leak checker (see + // ensureNoDisposablesAreLeakedInTestSuite) would flag it at teardown. + // Firing here would also throw if the connection was still observed + // after removal — explicitly assert no late dispatch happens. + remoteConn.setRootState(rootStateWithSandboxSchema()); + assert.deepStrictEqual(remoteConn.dispatched, []); + }); }); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts index 6f785eaa4a051..777f3ad872c6d 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts @@ -34,6 +34,7 @@ import { getActiveElement } from '../../../../base/browser/dom.js'; import { isWeb } from '../../../../base/common/platform.js'; import { IOnboardingService } from '../../welcomeOnboarding/common/onboardingService.js'; import { ONBOARDING_STORAGE_KEY } from '../../welcomeOnboarding/common/onboardingTypes.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; export const restoreWalkthroughsConfigurationKey = 'workbench.welcomePage.restorableWalkthroughs'; export type RestoreWalkthroughsConfigurationValue = { folder: string; category?: string; step?: string }; @@ -95,6 +96,7 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe @INotificationService private readonly notificationService: INotificationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IOnboardingService private readonly onboardingService: IOnboardingService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); @@ -244,6 +246,10 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe return; // experimental onboarding is disabled } + if (this.chatEntitlementService.sentiment.hidden) { + return; // AI features are hidden, do not show AI-focused onboarding + } + if (!this.storageService.isNew(StorageScope.APPLICATION)) { return; // only show onboarding for new users who have never used the product before }