diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2b5d86aa203235..cbe044a133d8b1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,10 +11,10 @@ # VS Code API # Ensure the API team is aware of changes to the vscode-dts file # this is only about the final API, not about proposed API changes -src/vscode-dts/vscode.d.ts @mjbvz @alexr00 -src/vs/workbench/services/extensions/common/extensionPoints.json @mjbvz @alexr00 +src/vscode-dts/vscode.d.ts @TylerLeonhardt @alexr00 +src/vs/workbench/services/extensions/common/extensionPoints.json @TylerLeonhardt @alexr00 # Allowlist for the `local/code-no-new-javascript-files` lint rule. # Adding entries here lets a new .js/.cjs/.mjs file land in the repo; # review is required to make sure TypeScript is not a better choice. -.eslint-allowed-javascript-files @alexr00 @alexdima @sbatten +.eslint-allowed-javascript-files @alexr00 @alexdima @sbatten @TylerLeonhardt diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f7e3481c75b0c8..dfce6c7f199221 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,9 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/extensions/markdown-language-features" + schedule: + interval: "daily" + allow: + - dependency-name: "@vscode/markdown-editor" diff --git a/extensions/copilot/src/extension/intents/node/testIntent/testIntent.tsx b/extensions/copilot/src/extension/intents/node/testIntent/testIntent.tsx index 1804961a5da310..b126007b3f46d7 100644 --- a/extensions/copilot/src/extension/intents/node/testIntent/testIntent.tsx +++ b/extensions/copilot/src/extension/intents/node/testIntent/testIntent.tsx @@ -16,6 +16,7 @@ import { IEndpointProvider } from '../../../../platform/endpoint/common/endpoint import { IOctoKitService } from '../../../../platform/github/common/githubService'; import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService'; import { ILogService } from '../../../../platform/log/common/logService'; +import { IChatWebSocketManager } from '../../../../platform/networking/node/chatWebSocketManager'; import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger'; import { ISurveyService } from '../../../../platform/survey/common/surveyService'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; @@ -243,8 +244,9 @@ class RequestHandler extends DefaultIntentRequestHandler { @IChatHookService chatHookService: IChatHookService, @IOctoKitService octoKitService: IOctoKitService, @IConfigurationService configurationService: IConfigurationService, + @IChatWebSocketManager chatWebSocketManager: IChatWebSocketManager, ) { - super(intent, conversation, request, stream, token, documentContext, location, chatTelemetry, undefined, undefined, instantiationService, conversationOptions, telemetryService, logService, surveyService, requestLogger, editSurvivalTrackerService, authenticationService, chatHookService, octoKitService, configurationService); + super(intent, conversation, request, stream, token, documentContext, location, chatTelemetry, undefined, undefined, instantiationService, conversationOptions, telemetryService, logService, surveyService, requestLogger, editSurvivalTrackerService, authenticationService, chatHookService, octoKitService, configurationService, chatWebSocketManager); } /** diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 352b811f1986d7..170d23ef45c8f8 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -138,7 +138,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { * Note: the returned array of strings may be less than `n` (e.g., in case there were errors during streaming) */ public async fetchMany(opts: IFetchMLOptions, token: CancellationToken): Promise { - let { debugName, endpoint: chatEndpoint, finishedCb, location, messages, requestOptions, source, telemetryProperties, userInitiatedRequest, interactionTypeOverride, conversationId, turnId, topLevelTurnId, useWebSocket, ignoreStatefulMarker } = opts; + let { debugName, endpoint: chatEndpoint, finishedCb, location, messages, requestOptions, source, telemetryProperties, userInitiatedRequest, interactionTypeOverride, conversationId, webSocketConnectionId, turnId, topLevelTurnId, useWebSocket, ignoreStatefulMarker } = opts; const interactionType = interactionTypeOverride ?? locationToIntent(location); if (useWebSocket && this._consecutiveWebSocketRetryFallbacks >= ChatMLFetcherImpl._maxConsecutiveWebSocketFallbacks) { this._logService.debug(`[ChatWebSocketManager] Disabling WebSocket for request due to ${this._consecutiveWebSocketRetryFallbacks} consecutive WebSocket failures with successful HTTP fallback.`); @@ -243,6 +243,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { useWebSocket, turnId, conversationId, + webSocketConnectionId, telemetryProperties, opts.useFetcher, canRetryOnce, @@ -914,7 +915,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._logService.info(`[ChatWebSocketManager] WebSocket request failed with successful HTTP fallback (${this._consecutiveWebSocketRetryFallbacks} consecutive).`); if (opts.conversationId) { // Closing here because the retry is transparent. - this._webSocketManager.closeConnection(opts.conversationId); + this._webSocketManager.closeConnection(opts.conversationId, opts.webSocketConnectionId); } } return { retryResult, connectivityTestError, connectivityTestErrorGitHubRequestId }; @@ -936,6 +937,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { useWebSocket?: boolean, turnId?: string, conversationId?: string, + webSocketConnectionId?: string, telemetryProperties?: TelemetryProperties | undefined, useFetcher?: FetcherId, canRetryOnce?: boolean, @@ -976,6 +978,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { useWebSocket, turnId, conversationId, + webSocketConnectionId, telemetryProperties, useFetcher, canRetryOnce, @@ -1015,6 +1018,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { useWebSocket?: boolean, turnId?: string, conversationId?: string, + webSocketConnectionId?: string, telemetryProperties?: TelemetryProperties | undefined, useFetcher?: FetcherId, canRetryOnce?: boolean, @@ -1093,6 +1097,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { ourRequestId, turnId, conversationId, + webSocketConnectionId, cancellationToken, countTokens, userInitiatedRequest, @@ -1152,6 +1157,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { ourRequestId: string, turnId: string, conversationId: string, + webSocketConnectionId: string | undefined, cancellationToken: CancellationToken, countTokens: () => Promise, userInitiatedRequest: boolean | undefined, @@ -1175,7 +1181,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { if (request.messages?.some((m: CAPIChatMessage) => Array.isArray(m.content) ? m.content.some(c => 'image_url' in c) : false) && chatEndpointInfo.supportsVision) { additionalHeaders['Copilot-Vision-Request'] = 'true'; } - const connection = this._webSocketManager.getOrCreateConnection(conversationId, additionalHeaders, ourRequestId); + const connection = this._webSocketManager.getOrCreateConnection({ conversationId, modelId: chatEndpointInfo.model, connectionId: webSocketConnectionId }, additionalHeaders, ourRequestId); try { await connection.connect(); } catch (err) { diff --git a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts index 0136bf71c3844d..b3a2933a397e70 100644 --- a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts +++ b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts @@ -22,6 +22,7 @@ import { HAS_IGNORED_FILES_MESSAGE } from '../../../platform/ignore/common/ignor import { ILogService } from '../../../platform/log/common/logService'; import { isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic'; import { FilterReason } from '../../../platform/networking/common/openai'; +import { IChatWebSocketManager } from '../../../platform/networking/node/chatWebSocketManager'; import { IOTelService } from '../../../platform/otel/common/otelService'; import { CapturingToken } from '../../../platform/requestLogger/common/capturingToken'; import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger'; @@ -101,6 +102,7 @@ export class DefaultIntentRequestHandler { @IChatHookService private readonly _chatHookService: IChatHookService, @IOctoKitService private readonly _octoKitService: IOctoKitService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IChatWebSocketManager private readonly _chatWebSocketManager: IChatWebSocketManager, ) { // Initialize properties this.turn = conversation.getLatestTurn(); @@ -110,6 +112,9 @@ export class DefaultIntentRequestHandler { if (isToolCallLimitCancellation(this.request)) { // Just some friendly text instead of an empty message on cancellation: this.stream.markdown(l10n.t("Let me know if there's anything else I can help with!")); + if (this.request.subAgentInvocationId) { + this._chatWebSocketManager.closeConnection(this.conversation.sessionId, this.request.subAgentInvocationId); + } return {}; } @@ -189,6 +194,10 @@ export class DefaultIntentRequestHandler { const chatResult = { errorDetails: { message: errorMessage } }; this.turn.setResponse(TurnStatus.Error, { message: errorMessage, type: 'meta' }, undefined, chatResult); return chatResult; + } finally { + if (this.request.subAgentInvocationId) { + this._chatWebSocketManager.closeConnection(this.conversation.sessionId, this.request.subAgentInvocationId); + } } } @@ -703,6 +712,7 @@ class DefaultToolCallingLoop extends ToolCallingLoop { }, debugName, conversationId: this.options.conversation.sessionId, + webSocketConnectionId: this.options.request.subAgentInvocationId, turnId: opts.turnId, finishedCb: (text, index, delta) => { this.telemetry.markReceivedToken(); diff --git a/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts b/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts index 6a07197dbe603b..1d1131a3e391cc 100644 --- a/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts +++ b/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts @@ -13,6 +13,7 @@ import { StaticChatMLFetcher } from '../../../../platform/chat/test/common/stati import { MockEndpoint } from '../../../../platform/endpoint/test/node/mockEndpoint'; import { IResponseDelta } from '../../../../platform/networking/common/fetch'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { IChatWebSocketManager } from '../../../../platform/networking/node/chatWebSocketManager'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; import { SpyingTelemetryService } from '../../../../platform/telemetry/node/spyingTelemetryService'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; @@ -191,6 +192,24 @@ suite('defaultIntentRequestHandler', () => { expect(getDerandomizedTelemetry()).toMatchSnapshot(); }); + test('isolates and closes a subagent WebSocket connection', async () => { + const request = new TestChatRequest(); + Object.assign(request, { subAgentInvocationId: 'subagent-1', subAgentName: 'Reviewer' }); + const requestSpy = vi.spyOn(endpoint, 'makeChatRequest2'); + const closeConnectionSpy = vi.spyOn(accessor.get(IChatWebSocketManager), 'closeConnection'); + const handler = makeHandler({ request }); + chatResponse[0] = 'some response here :)'; + promptResult = { + ...nullRenderPromptResult(), + messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }], + }; + + await handler.getResult(); + + expect(requestSpy.mock.calls[0][0].webSocketConnectionId).toBe('subagent-1'); + expect(closeConnectionSpy).toHaveBeenCalledWith(sessionId, 'subagent-1'); + }); + test('propagates resolvedModel into result metadata from a successful response', async () => { fetcher.resolvedModel = 'gpt-4o-resolved'; const handler = makeHandler(); diff --git a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx index 536ebe40dc5c42..b882e3932019c5 100644 --- a/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileToolUtils.tsx @@ -711,6 +711,11 @@ export async function applyEdit( const ALWAYS_CHECKED_EDIT_PATTERNS: Readonly> = { '**/.vscode/*.json': false, + // Markdown files in these folders are loaded as custom agents; their + // frontmatter can declare a `hooks:` block that runs shell commands during + // the agent lifecycle, so writing them must always be confirmed. + '**/.github/agents/**': false, + '**/.claude/agents/**': false, }; const allPlatformPatterns: (glob.ParsedPattern | string)[] = [ diff --git a/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts b/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts index 0d75613474be2a..3510b5cc0eb75e 100644 --- a/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/editFileToolUtils.spec.ts @@ -1113,3 +1113,35 @@ describe('makeUriConfirmationChecker', async () => { }); }); }); + +describe('agent definition files require confirmation', () => { + let configService: InMemoryConfigurationService; + let workspaceService: TestWorkspaceService; + let customInstructionsService: MockCustomInstructionsService; + + beforeEach(() => { + configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); + workspaceService = new TestWorkspaceService([URI.file('/workspace')], []); + customInstructionsService = new MockCustomInstructionsService(); + }); + + test('requires confirmation for files in agent folders', async () => { + const checker = makeUriConfirmationChecker(configService, workspaceService.getWorkspaceFolder.bind(workspaceService), customInstructionsService); + const files = [ + '/workspace/.github/agents/dev-helper.md', + '/workspace/.github/agents/dev-helper.agent.md', + '/workspace/.claude/agents/reviewer.md', + '/workspace/.github/agents/nested/deep.md', + ]; + for (const file of files) { + expect(await checker(URI.file(file))).toBe(ConfirmationCheckResult.Sensitive); + } + }); + + test('does not require confirmation for similarly-named files outside agent folders', async () => { + const checker = makeUriConfirmationChecker(configService, workspaceService.getWorkspaceFolder.bind(workspaceService), customInstructionsService); + expect(await checker(URI.file('/workspace/src/agents/agent.md'))).toBe(ConfirmationCheckResult.NoConfirmation); + expect(await checker(URI.file('/workspace/.github/workflows/ci.md'))).toBe(ConfirmationCheckResult.NoConfirmation); + }); +}); + diff --git a/extensions/copilot/src/extension/tools/node/test/toolSearchTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/toolSearchTool.spec.ts new file mode 100644 index 00000000000000..84e8956f691d3e --- /dev/null +++ b/extensions/copilot/src/extension/tools/node/test/toolSearchTool.spec.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { describe, expect, it, vi } from 'vitest'; +import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import type { ILogService } from '../../../../platform/log/common/logService'; +import type { IToolDeferralService } from '../../../../platform/networking/common/toolDeferralService'; +import type { IToolEmbeddingsComputer } from '../../common/virtualTools/toolEmbeddingsComputer'; +import type { IToolsService } from '../../common/toolsService'; +import { ToolSearchTool } from '../toolSearchTool'; + +function text(result: vscode.LanguageModelToolResult): string { + return result.content.map(part => { + const value = part as { value?: unknown }; + return value.value === undefined ? '' : String(value.value); + }).join(''); +} + +describe('ToolSearchTool', () => { + it('uses an injected Agent Host corpus instead of the extension registry', async () => { + const searchToolsByQuery = vi.fn(async (_query: string, tools: readonly vscode.LanguageModelToolInformation[]) => tools.map(tool => tool.name)); + const embeddings = { _serviceBrand: undefined, searchToolsByQuery } as unknown as IToolEmbeddingsComputer; + const toolsService = { + _serviceBrand: undefined, + tools: [{ name: 'extension-only', description: 'Extension tool', inputSchema: undefined, tags: [], source: undefined }], + } as unknown as IToolsService; + const deferral = { _serviceBrand: undefined, isNonDeferredTool: () => false } as IToolDeferralService; + const log = { _serviceBrand: undefined, trace: vi.fn() } as unknown as ILogService; + const tool = new ToolSearchTool(embeddings, toolsService, deferral, log); + + const result = await tool.invoke({ + input: { + query: 'add numbers', + candidateTools: [{ name: 'everything-get-sum', description: 'Adds numbers' }], + }, + toolInvocationToken: undefined, + } as vscode.LanguageModelToolInvocationOptions, CancellationToken.None); + + expect(searchToolsByQuery).toHaveBeenCalledOnce(); + expect(searchToolsByQuery.mock.calls[0][1].map(tool => tool.name)).toEqual(['everything-get-sum']); + expect(text(result)).toBe('["everything-get-sum"]'); + }); + + it('preserves the extension registry path when no corpus is injected', async () => { + const searchToolsByQuery = vi.fn(async (_query: string, tools: readonly vscode.LanguageModelToolInformation[]) => tools.map(tool => tool.name)); + const embeddings = { _serviceBrand: undefined, searchToolsByQuery } as unknown as IToolEmbeddingsComputer; + const toolsService = { + _serviceBrand: undefined, + tools: [ + { name: 'deferred-extension-tool', description: 'Deferred', inputSchema: undefined, tags: [], source: undefined }, + { name: 'core-tool', description: 'Core', inputSchema: undefined, tags: [], source: undefined }, + ], + } as unknown as IToolsService; + const deferral = { _serviceBrand: undefined, isNonDeferredTool: (name: string) => name === 'core-tool' } as IToolDeferralService; + const log = { _serviceBrand: undefined, trace: vi.fn() } as unknown as ILogService; + const tool = new ToolSearchTool(embeddings, toolsService, deferral, log); + + await tool.invoke({ input: { query: 'deferred' }, toolInvocationToken: undefined } as vscode.LanguageModelToolInvocationOptions, CancellationToken.None); + + expect(searchToolsByQuery.mock.calls[0][1].map(tool => tool.name)).toEqual(['deferred-extension-tool']); + }); +}); diff --git a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts index 7d2c82de3c132c..e11cabd107130a 100644 --- a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts +++ b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts @@ -16,6 +16,13 @@ import { IToolEmbeddingsComputer } from '../common/virtualTools/toolEmbeddingsCo export interface IToolSearchParams { query: string; limit?: number; + /** Internal Agent Host corpus; not part of the model-facing schema. */ + candidateTools?: readonly IToolSearchCandidate[]; +} + +export interface IToolSearchCandidate { + name: string; + description: string; } const DEFAULT_SEARCH_LIMIT = 5; @@ -29,7 +36,7 @@ export class ToolSearchTool implements ICopilotModelSpecificTool, token: vscode.CancellationToken) { - const { query, limit } = options.input; + const { query, limit, candidateTools } = options.input; if (!query) { return new LanguageModelToolResult([ @@ -37,9 +44,17 @@ export class ToolSearchTool implements ICopilotModelSpecificTool !this._toolDeferralService.isNonDeferredTool(tool.name), - ); + const availableTools: readonly vscode.LanguageModelToolInformation[] = candidateTools !== undefined + ? candidateTools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: undefined, + tags: [], + source: undefined, + })) + : this._toolsService.tools.filter( + tool => !this._toolDeferralService.isNonDeferredTool(tool.name), + ); const matchedToolNames = await this._toolEmbeddingsComputer.searchToolsByQuery( query, availableTools, diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 7cb34e9f0707bb..64bf888172c7a3 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -499,7 +499,7 @@ export class ChatEndpoint implements IChatEndpoint { useWebSocket && options.conversationId && options.turnId - && this._chatWebSocketService.hasActiveConnection(options.conversationId) + && this._chatWebSocketService.hasActiveConnection({ conversationId: options.conversationId, modelId: this.model, connectionId: options.webSocketConnectionId }) ); const response = await this._makeChatRequest2({ ...options, diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 857abebdff2aca..274b3bfe5f0738 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -56,7 +56,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: const compactThreshold = getResponsesApiCompactionThreshold(configService, expService, endpoint); // compaction supported for all the models but works well for codex models and any future models after 5.3 - const webSocketStatefulMarker = resolveWebSocketStatefulMarker(accessor, options); + const webSocketStatefulMarker = resolveWebSocketStatefulMarker(accessor, options, model); // When WebSocket is in use, always defer to the WebSocket marker (which may be // undefined if the connection is new or the summary state changed). Never fall // back to the HTTP marker lookup in that case. @@ -285,19 +285,20 @@ interface ResponseStreamEventWithResponseOutput { }; } -function resolveWebSocketStatefulMarker(accessor: ServicesAccessor, options: ICreateEndpointBodyOptions): string | undefined { +function resolveWebSocketStatefulMarker(accessor: ServicesAccessor, options: ICreateEndpointBodyOptions, modelId: string): string | undefined { if (options.ignoreStatefulMarker || !options.useWebSocket || !options.conversationId) { return undefined; } const wsManager = accessor.get(IChatWebSocketManager); + const connectionKey = { conversationId: options.conversationId, modelId, connectionId: options.webSocketConnectionId }; // If client-side summarization state changed since the stateful marker // was stored (new summary, or rollback removing a summary), the server's // state no longer matches. Skip the marker so the full history is sent. - const connSummarizedAt = wsManager.getSummarizedAtRoundId(options.conversationId); + const connSummarizedAt = wsManager.getSummarizedAtRoundId(connectionKey); if (options.summarizedAtRoundId !== connSummarizedAt) { return undefined; } - return wsManager.getStatefulMarker(options.conversationId); + return wsManager.getStatefulMarker(connectionKey); } interface RawMessagesToResponseAPIOptions { diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 9d038eecd3d6fe..73e7c1d36bd461 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -206,6 +206,8 @@ export interface IMakeChatRequestOptions { source?: Source; /** Conversation identifier used for request-scoped state (for example WebSocket connection reuse). */ conversationId?: string; + /** Optional identifier for an independent WebSocket connection within a conversation. */ + webSocketConnectionId?: string; /** Identifier for a single tool-calling turn within a conversation. */ turnId?: string; /** Additional request options */ diff --git a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts index 06232d462a27b3..133f62e3c82017 100644 --- a/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts +++ b/extensions/copilot/src/platform/networking/node/chatWebSocketManager.ts @@ -22,40 +22,46 @@ import { ChatWebSocketRequestOutcome, ChatWebSocketTelemetrySender } from './cha export const IChatWebSocketManager = createServiceIdentifier('IChatWebSocketManager'); +export interface IChatWebSocketConnectionKey { + readonly conversationId: string; + readonly modelId: string; + readonly connectionId?: string; +} + export interface IChatWebSocketManager { readonly _serviceBrand: undefined; /** - * Gets or creates a WebSocket connection for the given conversation. + * Gets or creates a WebSocket connection for the given conversation lane and model. * The connection is shared across turns and tool call rounds within - * the same conversation, keeping server-side context alive. + * the same lane, keeping server-side context alive. */ - getOrCreateConnection(conversationId: string, headers: Record, initiatingRequestId: string): IChatWebSocketConnection; + getOrCreateConnection(key: IChatWebSocketConnectionKey, headers: Record, initiatingRequestId: string): IChatWebSocketConnection; /** - * Returns true if there is an open WebSocket connection for the given - * conversation. Used to decide whether the server already has context - * from earlier requests in this conversation. + * Returns true if there is an idle, open WebSocket connection for the given + * conversation lane and model. Used to decide whether the server already has + * context from earlier requests in this conversation. */ - hasActiveConnection(conversationId: string): boolean; + hasActiveConnection(key: IChatWebSocketConnectionKey): boolean; /** * Returns the stateful marker (last completed response ID) for the given * conversation's active WebSocket connection, or undefined if there is * no active connection or no marker yet. */ - getStatefulMarker(conversationId: string): string | undefined; + getStatefulMarker(key: IChatWebSocketConnectionKey): string | undefined; /** * Returns the round ID at which the last client-side summarization * occurred for this connection, or undefined if none. */ - getSummarizedAtRoundId(conversationId: string): string | undefined; + getSummarizedAtRoundId(key: IChatWebSocketConnectionKey): string | undefined; /** * Closes and removes the connection for a specific conversation. */ - closeConnection(conversationId: string): void; + closeConnection(conversationId: string, connectionId?: string): void; /** * Closes all active connections. @@ -68,13 +74,13 @@ export interface IChatWebSocketManager { */ export class NullChatWebSocketManager implements IChatWebSocketManager { declare readonly _serviceBrand: undefined; - getOrCreateConnection(_conversationId: string, _headers?: Record, _initiatingRequestId?: string): IChatWebSocketConnection { + getOrCreateConnection(_key: IChatWebSocketConnectionKey, _headers?: Record, _initiatingRequestId?: string): IChatWebSocketConnection { throw new Error('WebSocket not available'); } - hasActiveConnection(_conversationId: string): boolean { return false; } - getStatefulMarker(_conversationId: string): string | undefined { return undefined; } - getSummarizedAtRoundId(_conversationId: string): string | undefined { return undefined; } - closeConnection(_conversationId: string): void { } + hasActiveConnection(_key: IChatWebSocketConnectionKey): boolean { return false; } + getStatefulMarker(_key: IChatWebSocketConnectionKey): string | undefined { return undefined; } + getSummarizedAtRoundId(_key: IChatWebSocketConnectionKey): string | undefined { return undefined; } + closeConnection(_conversationId: string, _connectionId?: string): void { } closeAll(): void { } } @@ -189,52 +195,54 @@ export class ChatWebSocketManager extends Disposable implements IChatWebSocketMa super(); } - getOrCreateConnection(conversationId: string, headers: Record, initiatingRequestId: string): IChatWebSocketConnection { - const existing = this._connections.get(conversationId); + getOrCreateConnection(key: IChatWebSocketConnectionKey, headers: Record, initiatingRequestId: string): IChatWebSocketConnection { + const connectionKey = this._getConnectionKey(key.conversationId, key.connectionId); + const existing = this._connections.get(connectionKey); - // Reuse the connection if it's still open, even across turns. - if (existing?.isOpen) { + // Reuse the connection if it is idle, open, and bound to the requested model. + if (existing?.isOpen && !existing.hasActiveRequest && existing.modelId === key.modelId) { return existing; } if (existing) { - this._logService.debug(`[ChatWebSocketManager] Replacing closed connection for conversation ${conversationId}`); + this._logService.debug(`[ChatWebSocketManager] Replacing connection for conversation ${key.conversationId} (model: ${existing.modelId} -> ${key.modelId}, active: ${existing.hasActiveRequest})`); existing.dispose(); - this._connections.delete(conversationId); + this._connections.delete(connectionKey); } - const connection = new ChatWebSocketConnection(this._capiClientService, this._logService, this._telemetryService, this._configurationService, conversationId, headers, initiatingRequestId); - this._logService.debug(`[ChatWebSocketManager] Creating new connection for conversation ${conversationId}`); - this._connections.set(conversationId, connection); + const connection = new ChatWebSocketConnection(this._capiClientService, this._logService, this._telemetryService, this._configurationService, key.conversationId, key.modelId, headers, initiatingRequestId); + this._logService.debug(`[ChatWebSocketManager] Creating new connection for conversation ${key.conversationId}, model ${key.modelId}`); + this._connections.set(connectionKey, connection); // Remove from map when disposed externally connection.onDidDispose(() => { - const entry = this._connections.get(conversationId); + const entry = this._connections.get(connectionKey); if (entry === connection) { - this._connections.delete(conversationId); + this._connections.delete(connectionKey); } }); return connection; } - hasActiveConnection(conversationId: string): boolean { - const connection = this._connections.get(conversationId); - return !!connection?.isOpen; + hasActiveConnection(key: IChatWebSocketConnectionKey): boolean { + const connection = this._getConnection(key); + return !!connection?.isOpen && !connection.hasActiveRequest; } - getStatefulMarker(conversationId: string): string | undefined { - const connection = this._connections.get(conversationId); - return connection?.isOpen ? connection.statefulMarker : undefined; + getStatefulMarker(key: IChatWebSocketConnectionKey): string | undefined { + const connection = this._getConnection(key); + return connection?.isOpen && !connection.hasActiveRequest ? connection.statefulMarker : undefined; } - getSummarizedAtRoundId(conversationId: string): string | undefined { - const connection = this._connections.get(conversationId); - return connection?.isOpen ? connection.summarizedAtRoundId : undefined; + getSummarizedAtRoundId(key: IChatWebSocketConnectionKey): string | undefined { + const connection = this._getConnection(key); + return connection?.isOpen && !connection.hasActiveRequest ? connection.summarizedAtRoundId : undefined; } - closeConnection(conversationId: string): void { - const connection = this._connections.get(conversationId); + closeConnection(conversationId: string, connectionId?: string): void { + const connectionKey = this._getConnectionKey(conversationId, connectionId); + const connection = this._connections.get(connectionKey); if (connection) { if (connection.hasActiveRequest) { this._logService.warn(`[ChatWebSocketManager] Closing connection for conversation ${conversationId} while turn ${connection.turnId} still has an active request`); @@ -242,10 +250,19 @@ export class ChatWebSocketManager extends Disposable implements IChatWebSocketMa this._logService.debug(`[ChatWebSocketManager] Closing connection for conversation ${conversationId}`); } connection.dispose(); - this._connections.delete(conversationId); + this._connections.delete(connectionKey); } } + private _getConnection(key: IChatWebSocketConnectionKey): ChatWebSocketConnection | undefined { + const connection = this._connections.get(this._getConnectionKey(key.conversationId, key.connectionId)); + return connection?.modelId === key.modelId ? connection : undefined; + } + + private _getConnectionKey(conversationId: string, connectionId: string | undefined): string { + return JSON.stringify([conversationId, connectionId ?? null]); + } + closeAll(): void { for (const connection of this._connections.values()) { connection.dispose(); @@ -316,6 +333,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec private readonly _telemetryService: ITelemetryService, private readonly _configurationService: IConfigurationService, private readonly _conversationId: string, + readonly modelId: string, private readonly _headers: Record, private readonly _initiatingRequestId: string, ) { @@ -651,7 +669,7 @@ class ChatWebSocketConnection extends Disposable implements IChatWebSocketConnec const cancelDisposable = token.onCancellationRequested(() => { if (this._activeRequest === request) { request.handleCancellation(); - this._activeRequest = undefined; + this.dispose(); } }); request.done.finally(() => cancelDisposable.dispose()).catch(() => { }); diff --git a/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts b/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts index 133a1590261d7b..3699f83b9d28fb 100644 --- a/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts +++ b/extensions/copilot/src/platform/networking/node/test/chatWebSocketManager.spec.ts @@ -60,6 +60,7 @@ describe('ChatWebSocketManager', () => { let disposables: DisposableStore; let ws: FakeWebSocket; let manager: ChatWebSocketManager; + const connectionKey = { conversationId: 'conv-1', modelId: 'test-model' }; beforeEach(() => { disposables = new DisposableStore(); @@ -78,7 +79,7 @@ describe('ChatWebSocketManager', () => { { getConfig: () => undefined } as unknown as IConfigurationService, ); disposables.add(manager); - const connection = manager.getOrCreateConnection('conv-1', headers, 'req-conn'); + const connection = manager.getOrCreateConnection(connectionKey, headers, 'req-conn'); const connectPromise = connection.connect(); // Defer open event to allow connect() to attach listeners first await Promise.resolve(); @@ -94,9 +95,9 @@ describe('ChatWebSocketManager', () => { const connection = await getConnection(); // Request a connection for a new turn — should return the same object - const connection2 = manager.getOrCreateConnection('conv-1', {}, 'req-conn-2'); + const connection2 = manager.getOrCreateConnection(connectionKey, {}, 'req-conn-2'); expect(connection2).toBe(connection); - expect(manager.hasActiveConnection('conv-1')).toBe(true); + expect(manager.hasActiveConnection(connectionKey)).toBe(true); }); it('creates a new connection when the previous one is closed', async () => { @@ -104,19 +105,59 @@ describe('ChatWebSocketManager', () => { connection.dispose(); // Same manager, new getOrCreateConnection call should replace the disposed one - const connection2 = manager.getOrCreateConnection('conv-1', {}, 'req-conn-2'); + const connection2 = manager.getOrCreateConnection(connectionKey, {}, 'req-conn-2'); expect(connection2).not.toBe(connection); }); + it('creates a new connection when the model changes', async () => { + const connection = await getConnection(); + expect(manager.hasActiveConnection({ ...connectionKey, modelId: 'other-model' })).toBe(false); + const connection2 = manager.getOrCreateConnection({ ...connectionKey, modelId: 'other-model' }, {}, 'req-conn-2'); + + expect(connection2).not.toBe(connection); + expect(connection.isOpen).toBe(false); + }); + + it('uses independent connections for parallel scopes', async () => { + const connection = await getConnection(); + const connection2 = manager.getOrCreateConnection({ ...connectionKey, connectionId: 'subagent-1' }, {}, 'req-conn-2'); + + expect(connection2).not.toBe(connection); + expect(connection.isOpen).toBe(true); + }); + it('hasActiveConnection returns true regardless of current turnId', async () => { await getConnection(); // connected on turn-1 - expect(manager.hasActiveConnection('conv-1')).toBe(true); + expect(manager.hasActiveConnection(connectionKey)).toBe(true); }); it('hasActiveConnection returns false after connection is disposed', async () => { const connection = await getConnection(); connection.dispose(); - expect(manager.hasActiveConnection('conv-1')).toBe(false); + expect(manager.hasActiveConnection(connectionKey)).toBe(false); + }); + }); + + describe('cancellation', () => { + it('closes the connection when the active request is cancelled', async () => { + const connection = await getConnection(); + const cts = disposables.add(new CancellationTokenSource()); + const handle = connection.sendRequest( + { model: 'test-model', messages: [], stream: true }, + { userInitiated: true, turnId: 'turn-1', requestId: 'req-1', model: 'test-model', countTokens: () => Promise.resolve(0), tokenCountMax: 4096, modelMaxPromptTokens: 128000 }, + cts.token, + ); + handle.firstEvent.catch(() => { }); + handle.done.catch(() => { }); + expect(manager.hasActiveConnection(connectionKey)).toBe(false); + + cts.cancel(); + + await expect(handle.done).rejects.toThrow(); + expect(connection.isOpen).toBe(false); + expect(manager.hasActiveConnection(connectionKey)).toBe(false); + expect(ws.readyState).toBe(ws.CLOSED); + expect(manager.getOrCreateConnection(connectionKey, {}, 'req-conn-2')).not.toBe(connection); }); }); diff --git a/package-lock.json b/package-lock.json index 3bb1482ba8514f..c762bc39432cbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-23", + "@vscode/codicons": "^0.0.46-24", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -4171,9 +4171,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-23", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-23.tgz", - "integrity": "sha512-rk7p4Jk5Pydm8akIMBPIIXXkpvK5vHXu8j/gxx72Fy5+yfZYHDH4MhENOlDjCtDmMS20UFeTxDqBcUlvP0w8mg==", + "version": "0.0.46-24", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-24.tgz", + "integrity": "sha512-KIDWUYxG6eh+DHRg+3G4bS487T+g1ZkLfB4HylOBbCcHsvLdfLMRM/m92DS1Ljhi8nglLr9ucwwWhHTEAC+DpA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { diff --git a/package.json b/package.json index b590ad881c962c..1adea686ed3225 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-23", + "@vscode/codicons": "^0.0.46-24", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 18fc98fa361dd2..8e9d7bbbbd4ae0 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-23", + "@vscode/codicons": "^0.0.46-24", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-23", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-23.tgz", - "integrity": "sha512-rk7p4Jk5Pydm8akIMBPIIXXkpvK5vHXu8j/gxx72Fy5+yfZYHDH4MhENOlDjCtDmMS20UFeTxDqBcUlvP0w8mg==", + "version": "0.0.46-24", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-24.tgz", + "integrity": "sha512-KIDWUYxG6eh+DHRg+3G4bS487T+g1ZkLfB4HylOBbCcHsvLdfLMRM/m92DS1Ljhi8nglLr9ucwwWhHTEAC+DpA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 05af2c1da6ab7d..16aaec72e9f15d 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-23", + "@vscode/codicons": "^0.0.46-24", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/base/browser/ui/iconLabel/iconlabel.css b/src/vs/base/browser/ui/iconLabel/iconlabel.css index d3dfd9a586b385..e82cd75fcfcd6c 100644 --- a/src/vs/base/browser/ui/iconLabel/iconlabel.css +++ b/src/vs/base/browser/ui/iconLabel/iconlabel.css @@ -113,7 +113,7 @@ color: inherit !important; } -.monaco-list-row.focused.selected .label-description, -.monaco-list-row.selected .label-description { - opacity: .8; +.monaco-list-row.focused .monaco-icon-label > .monaco-icon-label-container > .monaco-icon-description-container > .label-description, +.monaco-list-row.selected .monaco-icon-label > .monaco-icon-label-container > .monaco-icon-description-container > .label-description { + opacity: 1; } diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index d84f6713838ec9..af0fff9c2b44f8 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -735,4 +735,24 @@ export const codiconsLibrary = { googleGemini: register('google-gemini', 0xecd1), kimi: register('kimi', 0xecd2), microsoft: register('microsoft', 0xecd3), + fish1Happy: register('fish1-happy', 0xecd4), + fish1Neutral: register('fish1-neutral', 0xecd5), + fish1Sad: register('fish1-sad', 0xecd6), + fish1VerySad: register('fish1-very-sad', 0xecd7), + fish2Happy: register('fish2-happy', 0xecd8), + fish2Neutral: register('fish2-neutral', 0xecd9), + fish2Sad: register('fish2-sad', 0xecda), + fish2VerySad: register('fish2-very-sad', 0xecdb), + fish3Happy: register('fish3-happy', 0xecdc), + fish3Neutral: register('fish3-neutral', 0xecdd), + fish3Sad: register('fish3-sad', 0xecde), + fish3VerySad: register('fish3-very-sad', 0xecdf), + fish4Happy: register('fish4-happy', 0xece0), + fish4Neutral: register('fish4-neutral', 0xece1), + fish4Sad: register('fish4-sad', 0xece2), + fish4VerySad: register('fish4-very-sad', 0xece3), + personVoice: register('person-voice', 0xece4), + personVoiceCompact: register('person-voice-compact', 0xece5), + personVoiceFilled: register('person-voice-filled', 0xece6), + personVoiceFilledCompact: register('person-voice-filled-compact', 0xece7), } as const; diff --git a/src/vs/platform/agentHost/common/agentModelPricing.ts b/src/vs/platform/agentHost/common/agentModelPricing.ts index 18cf2829024364..cbc6366792bdec 100644 --- a/src/vs/platform/agentHost/common/agentModelPricing.ts +++ b/src/vs/platform/agentHost/common/agentModelPricing.ts @@ -7,9 +7,9 @@ import type { SessionModelInfo } from './state/protocol/state.js'; import type { IAgentModelInfo } from './agentService.js'; /** - * Well-known pricing metadata carried under a model's open `_meta` bag (see {@link IAgentModelInfo._meta} / - * {@link SessionModelInfo._meta}). Agents that know a model's billing details populate these keys so the chat model - * picker can render its cost hover. + * Well-known model picker metadata carried under a model's open `_meta` bag (see {@link IAgentModelInfo._meta} / + * {@link SessionModelInfo._meta}). Agents populate these keys so the chat model picker can render pricing, + * capability categories, and promotions. * * All cost values are expressed as credits per 1M tokens — the same unit the model picker hover renders (see * `getModelHoverContent` in `modelPicker/modelPickerHover.ts`). Fields are optional; agents omit what they don't know. @@ -35,6 +35,8 @@ export interface IAgentModelPricingMeta { readonly longContextOutputCost?: number; /** Coarse price bucket (e.g. `low`, `medium`, `high`) for an at-a-glance tag. */ readonly priceCategory?: string; + /** Capability category (e.g. `lightweight`, `versatile`, `powerful`) shown in the model picker hover. */ + readonly category?: string; /** Whole-number percentage discount (0-100) for the synthetic `auto` model; shown as a "{n}% discount" detail. */ readonly discountPercent?: number; /** Promotional information when the model is experiencing a discount. */ @@ -79,6 +81,9 @@ export function readAgentModelPricingMeta(model: IAgentModelInfo | SessionModelI if (typeof meta.priceCategory === 'string') { result.priceCategory = meta.priceCategory; } + if (typeof meta.category === 'string') { + result.category = meta.category; + } const rawPromo = meta.promo; if (rawPromo && typeof rawPromo === 'object' && !Array.isArray(rawPromo)) { const p = rawPromo as Record; @@ -91,7 +96,7 @@ export function readAgentModelPricingMeta(model: IAgentModelInfo | SessionModelI /** * Builds a `_meta` payload from {@link IAgentModelPricingMeta}, dropping `undefined` entries. Returns `undefined` when - * no pricing fields are known so callers can avoid attaching an empty `_meta` object. + * no model picker fields are known so callers can avoid attaching an empty `_meta` object. */ export function createAgentModelPricingMeta(pricing: IAgentModelPricingMeta): Record | undefined { const entries = Object.entries(pricing).filter(([, value]) => value !== undefined); @@ -99,10 +104,9 @@ export function createAgentModelPricingMeta(pricing: IAgentModelPricingMeta): Re } /** - * Normalizes a raw CAPI billing payload (which uses snake_case field names like `token_prices`, - * `input_price`) into the camelCase {@link ICAPIModelBilling} shape that {@link createPricingMetaFromBilling} - * expects. Also handles the case where the billing object already uses camelCase (e.g. from the - * Copilot SDK's `ModelInfo`). Returns `undefined` when `raw` is nullish. + * Normalizes a raw CAPI or Copilot SDK billing payload into the camelCase + * {@link ICAPIModelBilling} shape that {@link createPricingMetaFromBilling} expects. + * Prices are converted from the payload's billing batch to credits per million tokens. */ export function normalizeCAPIBilling(raw: unknown): ICAPIModelBilling | undefined { if (!raw || typeof raw !== 'object') { @@ -125,22 +129,28 @@ export function normalizeCAPIBilling(raw: unknown): ICAPIModelBilling | undefine // the camelCase format flattens them at the top level of `tokenPrices`. const defaultTier = rawTokenPrices.default as Record | undefined; const hasDefault = defaultTier && typeof defaultTier === 'object'; + const batchSize = asNumber(rawTokenPrices.batchSize) ?? asNumber(rawTokenPrices.batch_size) ?? 1_000_000; + const scale = batchSize > 0 ? 1_000_000 / batchSize : 1; + const price = (...values: unknown[]): number | undefined => { + const value = values.map(asNumber).find(candidate => candidate !== undefined); + return value === undefined ? undefined : value * scale; + }; - const inputPrice = asNumber(rawTokenPrices.inputPrice) ?? asNumber(hasDefault ? defaultTier.input_price : undefined); - const cachePrice = asNumber(rawTokenPrices.cachePrice) ?? asNumber(hasDefault ? defaultTier.cache_price : undefined); - const cacheWritePrice = asNumber(rawTokenPrices.cacheWritePrice) ?? asNumber(hasDefault ? defaultTier.cache_write_price : undefined); - const outputPrice = asNumber(rawTokenPrices.outputPrice) ?? asNumber(hasDefault ? defaultTier.output_price : undefined); - const contextMax = asNumber(rawTokenPrices.contextMax) ?? asNumber(hasDefault ? defaultTier.context_max : undefined); + const inputPrice = price(rawTokenPrices.inputPrice, hasDefault ? defaultTier.input_price : undefined); + const cachePrice = price(rawTokenPrices.cacheReadPrice, rawTokenPrices.cachePrice, hasDefault ? defaultTier.cache_read_price : undefined, hasDefault ? defaultTier.cache_price : undefined); + const cacheWritePrice = price(rawTokenPrices.cacheWritePrice, hasDefault ? defaultTier.cache_write_price : undefined); + const outputPrice = price(rawTokenPrices.outputPrice, hasDefault ? defaultTier.output_price : undefined); + const contextMax = asNumber(rawTokenPrices.maxPromptTokens) ?? asNumber(rawTokenPrices.contextMax) ?? asNumber(hasDefault ? defaultTier.max_prompt_tokens : undefined) ?? asNumber(hasDefault ? defaultTier.context_max : undefined); const rawLong = (rawTokenPrices.longContext ?? rawTokenPrices.long_context) as Record | undefined; let longContext: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number } | undefined; if (rawLong && typeof rawLong === 'object') { longContext = { - inputPrice: asNumber(rawLong.inputPrice) ?? asNumber(rawLong.input_price), - cachePrice: asNumber(rawLong.cachePrice) ?? asNumber(rawLong.cache_price), - cacheWritePrice: asNumber(rawLong.cacheWritePrice) ?? asNumber(rawLong.cache_write_price), - outputPrice: asNumber(rawLong.outputPrice) ?? asNumber(rawLong.output_price), - contextMax: asNumber(rawLong.contextMax) ?? asNumber(rawLong.context_max), + inputPrice: price(rawLong.inputPrice, rawLong.input_price), + cachePrice: price(rawLong.cacheReadPrice, rawLong.cachePrice, rawLong.cache_read_price, rawLong.cache_price), + cacheWritePrice: price(rawLong.cacheWritePrice, rawLong.cache_write_price), + outputPrice: price(rawLong.outputPrice, rawLong.output_price), + contextMax: asNumber(rawLong.maxPromptTokens) ?? asNumber(rawLong.contextMax) ?? asNumber(rawLong.max_prompt_tokens) ?? asNumber(rawLong.context_max), }; } @@ -172,11 +182,8 @@ function normalizePromo(billing: Record): ICAPIModelBilling['pr } /** - * Runtime shape of the CAPI model billing payload. The published SDK types (`CCAModelBilling`, `ModelBilling`) don't - * yet declare `tokenPrices`, `priceCategory`, or `discountPercent`, but the `/models` endpoint already carries them. - * Both Copilot and Claude agents narrow through this interface at the read boundary. - * - * Remove individual fields as the SDK catches up (tracked at microsoft/vscode-capi#85). + * Normalized model billing shape shared by CAPI-backed agents and the Copilot SDK model list. + * Raw snake_case and current SDK fields are converted at the read boundary by {@link normalizeCAPIBilling}. */ export interface ICAPIModelBilling { readonly multiplier?: number; @@ -216,8 +223,9 @@ export interface ICAPIModelBilling { * @param billing - The model's billing info, narrowed through {@link ICAPIModelBilling}. * @param priceCategory - An optional override for the price category (e.g. from `modelPickerPriceCategory` on the * model object itself). Falls back to `billing.priceCategory` when not provided. + * @param category - The model's capability category from its top-level `modelPickerCategory` field. */ -export function createPricingMetaFromBilling(billing: ICAPIModelBilling | undefined, priceCategory?: string): Record | undefined { +export function createPricingMetaFromBilling(billing: ICAPIModelBilling | undefined, priceCategory?: string, category?: string): Record | undefined { const tokenPrices = billing?.tokenPrices; const longContext = tokenPrices?.longContext; @@ -243,6 +251,7 @@ export function createPricingMetaFromBilling(billing: ICAPIModelBilling | undefi longContextCacheWriteCost: showLongContext ? (longContext.cacheWritePrice ?? tokenPrices?.cacheWritePrice) : undefined, longContextOutputCost: showLongContext ? (longContext.outputPrice ?? tokenPrices?.outputPrice) : undefined, priceCategory: priceCategory ?? (typeof billing?.priceCategory === 'string' ? billing.priceCategory : undefined), + category, discountPercent: typeof billing?.discountPercent === 'number' ? billing.discountPercent : undefined, promo: billing?.promo, }); diff --git a/src/vs/platform/agentHost/common/copilotCliConfig.ts b/src/vs/platform/agentHost/common/copilotCliConfig.ts index a2f206fd27ec60..dd63ca5927e41f 100644 --- a/src/vs/platform/agentHost/common/copilotCliConfig.ts +++ b/src/vs/platform/agentHost/common/copilotCliConfig.ts @@ -21,6 +21,8 @@ export const enum CopilotCliConfigKey { RubberDuck = 'rubberDuck', /** Apply Opus 4.8-tuned system-prompt overrides on Opus 4.8 models. Off by default. */ Opus48Prompt = 'opus48Prompt', + /** Enable runtime tool search (deferred-tool loading) for Copilot SDK sessions. Off by default. */ + ToolSearchEnabled = 'toolSearchEnabled', /** Override reasoning effort regardless of the picker value; unsupported values are ignored. */ ReasoningEffortOverride = 'reasoningEffortOverride', /** Per-model capability overrides (family aliases) keyed by model id. */ @@ -38,6 +40,8 @@ export const AgentHostCopilotSdkLogLevelSettingId = 'chat.agentHost.copilotSdk.l export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled'; +export const AgentHostToolSearchEnabledSettingId = 'chat.agentHost.copilot.toolSearch.enabled'; + export const AgentHostReasoningEffortOverrideSettingId = 'chat.agentHost.reasoningEffortOverride'; export const AgentHostModelCapabilityOverridesSettingId = 'chat.agentHost.modelCapabilityOverrides'; @@ -84,6 +88,12 @@ export const copilotCliConfigSchema = createSchema({ description: localize('agentHost.config.opus48Prompt.description', "When enabled, Copilot SDK sessions running a Claude Opus 4.8 model apply Opus 4.8-tuned system-prompt section overrides on top of the default system message."), default: false, }), + [CopilotCliConfigKey.ToolSearchEnabled]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.toolSearchEnabled.title', "Agent Host Tool Search"), + description: localize('agentHost.config.toolSearchEnabled.description', "When enabled, Copilot SDK sessions defer MCP and non-core VS Code tools behind a tool-search tool so the model discovers them on demand instead of loading every tool definition up front."), + default: false, + }), [CopilotCliConfigKey.ReasoningEffortOverride]: schemaProperty({ type: 'string', title: localize('agentHost.config.reasoningEffortOverride.title', "Reasoning Effort Override"), diff --git a/src/vs/platform/agentHost/common/copilotHome.ts b/src/vs/platform/agentHost/common/copilotHome.ts new file mode 100644 index 00000000000000..d19214240ad759 --- /dev/null +++ b/src/vs/platform/agentHost/common/copilotHome.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { join } from '../../../base/common/path.js'; +import { IProcessEnvironment } from '../../../base/common/platform.js'; + +export function getCopilotHomePath(userHomePath: string, environment: IProcessEnvironment): string { + return environment['COPILOT_HOME'] || join(userHomePath, '.copilot'); +} + +export function getCopilotRootPaths(userHomePath: string, environment: IProcessEnvironment): string[] { + return [...new Set([ + getCopilotHomePath(userHomePath, environment), + join(userHomePath, '.copilot'), + ])]; +} diff --git a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts index f934c13a459847..5741e6f6eff9a7 100644 --- a/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts @@ -46,6 +46,14 @@ export interface IToolCallMeta { * explicit user action), so the client can render it as setting-driven. */ readonly autoApproveBySetting?: boolean; + /** Transient runtime corpus for the local client tool-search invocation. */ + readonly toolSearchCandidates?: readonly IToolSearchCandidate[]; +} + +/** Minimal metadata needed to embed and rank a deferred tool. */ +export interface IToolSearchCandidate { + readonly name: string; + readonly description: string; } /** @@ -85,6 +93,27 @@ function readToolCallUiMeta(value: unknown): IToolCallUiMeta | undefined { return result; } +function readToolSearchCandidates(value: unknown): readonly IToolSearchCandidate[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const result: IToolSearchCandidate[] = []; + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + return undefined; + } + const raw = candidate as Record; + if (typeof raw['name'] !== 'string' || typeof raw['description'] !== 'string') { + return undefined; + } + result.push({ + name: raw['name'], + description: raw['description'], + }); + } + return result; +} + /** * Reads the well-known {@link IToolCallMeta} keys from a tool call's `_meta` * bag, dropping unknown keys and wrong-typed values. @@ -104,6 +133,8 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta { if (typeof meta['mcpServerName'] === 'string') { result.mcpServerName = meta['mcpServerName']; } if (typeof meta['mcpToolName'] === 'string') { result.mcpToolName = meta['mcpToolName']; } if (typeof meta['autoApproveBySetting'] === 'boolean') { result.autoApproveBySetting = meta['autoApproveBySetting']; } + const toolSearchCandidates = readToolSearchCandidates(meta['toolSearchCandidates']); + if (toolSearchCandidates) { result.toolSearchCandidates = toolSearchCandidates; } const ui = readToolCallUiMeta(meta['ui']); if (ui) { result.ui = ui; } return result; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 25a04d8ed36015..21acdd9cfdd9cf 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -14,7 +14,11 @@ import { decodeBase64, encodeBase64, VSBuffer } from '../../../../base/common/bu import { hasKey, type Mutable } from '../../../../base/common/types.js'; import { URI as ResourceURI } from '../../../../base/common/uri.js'; import type { IProductService } from '../../../product/common/productService.js'; +import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; import { + ResponsePartKind, + SessionStatus, + ToolCallStatus, SessionLifecycle, TerminalState, ToolResultContentType, @@ -671,6 +675,59 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto return summary; } +/** Activity bits (0-4) of {@link SessionStatus}; the high bits carry orthogonal flags (IsRead / IsArchived). */ +const STATUS_ACTIVITY_MASK = (1 << 5) - 1; + +/** Whether the active turn has a `PendingConfirmation` tool call auto-approved by the session's bypass setting. */ +function hasAutoApprovedPendingConfirmation(state: ChatState): boolean { + return !!state.activeTurn?.responseParts.some(part => + part.kind === ResponsePartKind.ToolCall + && part.toolCall.status === ToolCallStatus.PendingConfirmation + && readToolCallMeta(part.toolCall).autoApproveBySetting === true, + ); +} + +/** Whether the chat is genuinely blocked on user input (an open input request, an auth-required tool, or a non-auto-approved confirmation gate). */ +function chatAwaitsUserInput(state: ChatState): boolean { + return !!state.activeTurn?.responseParts.some(part => { + // An open elicitation always awaits the user until it is answered. + if (part.kind === ResponsePartKind.InputRequest) { + return part.response === undefined; + } + if (part.kind !== ResponsePartKind.ToolCall) { + return false; + } + const status = part.toolCall.status; + // Result-confirmation and auth-required gates always require the user; a + // parameter-confirmation gate only when it was not auto-approved. + if (status === ToolCallStatus.PendingResultConfirmation || status === ToolCallStatus.AuthRequired) { + return true; + } + return status === ToolCallStatus.PendingConfirmation + && readToolCallMeta(part.toolCall).autoApproveBySetting !== true; + }); +} + +/** + * Projects a chat's status for session-summary aggregation, demoting an + * `InputNeeded` back to `InProgress` only when it is caused solely by an + * auto-approved confirmation — otherwise a session with bypass approvals flashes + * "input needed" in the sessions list while an auto-approved tool runs. + */ +function chatSummaryStatus(state: ChatState): SessionStatus { + const status = state.status; + if ((status & SessionStatus.InputNeeded) !== SessionStatus.InputNeeded) { + return status; + } + // Only demote when we can positively attribute the InputNeeded to an + // auto-approved confirmation with no genuine blocker present; otherwise (e.g. + // a restored summary whose activeTurn is not loaded) preserve the status. + if (hasAutoApprovedPendingConfirmation(state) && !chatAwaitsUserInput(state)) { + return (status & ~STATUS_ACTIVITY_MASK) | SessionStatus.InProgress; + } + return status; +} + /** * Derives a {@link ChatSummary} from a fully-populated {@link ChatState} by * projecting out the denormalized summary fields. Used to keep the parent @@ -680,7 +737,7 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { const summary: ChatSummary = { resource: state.resource, title: state.title, - status: state.status, + status: chatSummaryStatus(state), modifiedAt: state.modifiedAt, }; if (state.activity !== undefined) { summary.activity = state.activity; } diff --git a/src/vs/platform/agentHost/common/toolSearchConstants.ts b/src/vs/platform/agentHost/common/toolSearchConstants.ts new file mode 100644 index 00000000000000..09cf9a98e56f3b --- /dev/null +++ b/src/vs/platform/agentHost/common/toolSearchConstants.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Runtime/model-facing override name. */ +export const RUNTIME_TOOL_SEARCH_TOOL_NAME = 'tool_search_tool'; + +/** Client/workbench-facing tool reference name. */ +export const CLIENT_TOOL_SEARCH_REFERENCE_NAME = 'toolSearch'; diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 642bec8773d23b..8285713da73f99 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -5,6 +5,7 @@ import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../telemetry/common/languageModelToolTelemetry.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../telemetry/common/telemetryUtils.js'; import { hash } from '../../../base/common/hash.js'; import { AgentSession } from '../common/agentService.js'; import type { ErrorInfo, MessageAttachment, SessionInputRequestKind, ToolDefinition } from '../common/state/protocol/state.js'; @@ -41,6 +42,7 @@ export type IAgentHostUserMessageSentClassification = { }; export type AgentHostTurnResult = 'success' | 'error' | 'cancelled'; +export type AgentHostModelTelemetryKind = 'trusted' | 'byok' | 'unknown'; type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit'; export type AgentHostTurnFailureStage = 'validation' | 'workingDirectory' | 'modelSelection' | 'sendMessage' | 'provider'; @@ -51,7 +53,7 @@ export interface IAgentHostTurnCompletedEvent { timeToFirstProgress: number | undefined; totalTime: number; result: AgentHostTurnResult; - model: string | undefined; + model: string | TelemetryTrustedValue | undefined; modelSelectionKind: AgentHostModelSelectionKind; permissionLevel: string | undefined; errorType: string | undefined; @@ -65,7 +67,7 @@ export type IAgentHostTurnCompletedClassification = { timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from turn start to the first visible progress (text delta, response part, tool call start, or reasoning).' }; totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from turn start to turn completion.' }; result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the turn completed successfully, with an error, or was cancelled.' }; - model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model identifier selected for the session at turn start (e.g. gemini-3.5-flash).' }; + model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The trusted provider model identifier selected at turn start, or a generic value for BYOK and unknown models.' }; modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the client used the provider default, Auto, or an explicit model.' }; permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' }; errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type when the turn fails.' }; @@ -116,6 +118,7 @@ export interface IAgentHostTurnCompletedReport { totalTime: number; result: AgentHostTurnResult; model: string | undefined; + modelTelemetryKind: AgentHostModelTelemetryKind | undefined; permissionLevel: string | undefined; failure: IAgentHostTurnFailure | undefined; } @@ -472,6 +475,11 @@ export class AgentHostTelemetryReporter { turnCompleted(report: IAgentHostTurnCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const model = report.model === undefined + ? undefined + : report.modelTelemetryKind === 'trusted' + ? new TelemetryTrustedValue(report.model) + : report.modelTelemetryKind === 'byok' ? 'byokModel' : 'unknown'; this._telemetryService.publicLog2('agentHost.turnCompleted', { provider: report.provider, agentSessionId: AgentSession.id(session), @@ -479,7 +487,7 @@ export class AgentHostTelemetryReporter { timeToFirstProgress: report.timeToFirstProgress, totalTime: report.totalTime, result: report.result, - model: report.model, + model, modelSelectionKind: report.model === undefined ? 'default' : report.model === 'auto' ? 'auto' : 'explicit', permissionLevel: report.permissionLevel, errorType: report.failure?.error.errorType, diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 95cf2ec9172504..9678f90649cee2 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { StopWatch } from '../../../base/common/stopwatch.js'; -import type { AgentHostTelemetryReporter, AgentHostTurnResult, IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; +import type { AgentHostModelTelemetryKind, AgentHostTelemetryReporter, AgentHostTurnResult, IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; /** Per-turn timing state, keyed by `session:turnId`. */ interface ITurnTiming { @@ -12,6 +12,7 @@ interface ITurnTiming { readonly provider: string; readonly session: string; readonly model: string | undefined; + readonly modelTelemetryKind: AgentHostModelTelemetryKind | undefined; readonly permissionLevel: string | undefined; firstProgressMs: number | undefined; } @@ -32,13 +33,14 @@ export class AgentHostTurnTracker { constructor(private readonly _reporter: AgentHostTelemetryReporter) { } - turnStarted(provider: string, session: string, turnId: string, model: string | undefined, permissionLevel: string | undefined): void { + turnStarted(provider: string, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, permissionLevel: string | undefined): void { const key = this._key(session, turnId); this._turnTimings.set(key, { stopWatch: StopWatch.create(false), provider, session, model, + modelTelemetryKind, permissionLevel, firstProgressMs: undefined, }); @@ -67,6 +69,7 @@ export class AgentHostTurnTracker { totalTime: timing.stopWatch.elapsed(), result, model: timing.model, + modelTelemetryKind: timing.modelTelemetryKind, permissionLevel: timing.permissionLevel, failure, }); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index c337200935d0f9..86ef43860ddcbf 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -16,6 +16,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; import { AgentSession, AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; @@ -55,7 +56,7 @@ import { import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; -import { AgentHostTelemetryReporter, type AgentHostTurnFailureStage, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; +import { AgentHostTelemetryReporter, type AgentHostModelTelemetryKind, type AgentHostTurnFailureStage, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostTurnTracker } from './agentHostTurnTracker.js'; @@ -378,7 +379,16 @@ export class AgentSideEffects extends Disposable { const authenticationId = this._toolAuthenticationNeededId(chatUri, turnId, toolCallId); const toolCall = this._findToolCall(chatUri, turnId, toolCallId); - const needsConfirmation = toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation; + // A call auto-approved by the session's bypass setting is run + // automatically by the owning client and never blocks on the user, so + // keep it out of the session `inputNeeded` queue (which would flash + // "input needed" in the sessions list). `autoApproveBySetting` covers + // only the parameter gate; a `PendingResultConfirmation` is a genuine + // prompt and is still surfaced. + const autoApproved = !!toolCall && readToolCallMeta(toolCall).autoApproveBySetting === true; + + const suppressAutoApprovedConfirmation = autoApproved && toolCall?.status === ToolCallStatus.PendingConfirmation; + const needsConfirmation = !suppressAutoApprovedConfirmation && (toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation); if (needsConfirmation && toolCall) { this._setSessionInputNeeded(chatUri, { id: confirmationId, @@ -392,7 +402,7 @@ export class AgentSideEffects extends Disposable { } const contributor = toolCall?.contributor; - if (toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) { + if (!autoApproved && toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) { this._setSessionInputNeeded(chatUri, { id: clientExecutionId, kind: SessionInputRequestKind.ToolClientExecution, @@ -1126,8 +1136,8 @@ export class AgentSideEffects extends Disposable { } const attachments = action.message.attachments; this._telemetryReporter.userMessageSent(agent.id, channel, state, 'direct', attachments); - const { model, permissionLevel } = this._getTurnTelemetryContext(state, action.message.model?.id); - this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, permissionLevel); + const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, state, action.message.model?.id); + this._turnTracker.turnStarted(agent.id, channel, action.turnId, model, modelTelemetryKind, permissionLevel); void this._sendTurnMessage({ agent, sessionChannel, @@ -1479,8 +1489,8 @@ export class AgentSideEffects extends Disposable { const attachments = msg.message.attachments; const queuedState = this._stateManager.getSessionState(session); this._telemetryReporter.userMessageSent(agent.id, session, queuedState, 'queued', attachments); - const { model, permissionLevel } = this._getTurnTelemetryContext(queuedState, msg.message.model?.id); - this._turnTracker.turnStarted(agent.id, session, turnId, model, permissionLevel); + const { model, modelTelemetryKind, permissionLevel } = this._getTurnTelemetryContext(agent, queuedState, msg.message.model?.id); + this._turnTracker.turnStarted(agent.id, session, turnId, model, modelTelemetryKind, permissionLevel); // Selection travels on the queued message; it is applied before sending. void this._sendTurnMessage({ agent, @@ -1495,10 +1505,21 @@ export class AgentSideEffects extends Disposable { } - private _getTurnTelemetryContext(state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; permissionLevel: string | undefined } { + private _getTurnTelemetryContext(agent: IAgent, state: SessionState | undefined, modelId: string | undefined): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined; permissionLevel: string | undefined } { const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove]; const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined; - return { model: modelId, permissionLevel }; + const model = modelId === undefined ? undefined : agent.models.get().find(model => model.id === modelId); + let modelTelemetryKind: AgentHostModelTelemetryKind | undefined; + if (modelId === 'auto') { + modelTelemetryKind = 'trusted'; + } else if (modelId === undefined) { + modelTelemetryKind = undefined; + } else if (model === undefined) { + modelTelemetryKind = 'unknown'; + } else { + modelTelemetryKind = readAgentModelByokIdentifier(model) === undefined ? 'trusted' : 'byok'; + } + return { model: modelId, modelTelemetryKind, permissionLevel }; } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index cd58b311b002c9..45ccaaa3caf73c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -31,7 +31,7 @@ import { INativeEnvironmentService } from '../../../../platform/environment/comm import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; -import { createPricingMetaFromBilling, hasLongContextSurcharge, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; +import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; @@ -44,6 +44,7 @@ import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ICopilotConfigSlashCommandState } from '../../common/copilotConfigSlashCommands.js'; +import { getCopilotHomePath } from '../../common/copilotHome.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -1059,11 +1060,8 @@ export class CopilotAgent extends Disposable implements IAgent { * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's * two-valued `contextTier` at the SDK boundary by {@link getCopilotContextTier}, using the model's long-context * window from {@link _longContextWindowFor}. - * - * `billing.tokenPrices` is present on the runtime CAPI `/models` payload but not yet declared on the published SDK - * `ModelBilling` type — narrow through {@link ICAPIModelBilling} until the SDK catches up. */ - private _createContextSizeConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { + private _createContextSizeConfigSchemaProperty(billing: ICAPIModelBilling | undefined): ConfigPropertySchema | undefined { const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; @@ -1072,7 +1070,7 @@ export class CopilotAgent extends Disposable implements IAgent { } // When both tiers cost the same and the user prefers long context, show only the long-context option as a non-switchable indicator. See microsoft/vscode#322950, microsoft/vscode#323116. - if (this._isPreferLongContextEnabled() && !hasLongContextSurcharge(billing as ICAPIModelBilling | undefined)) { + if (this._isPreferLongContextEnabled() && !hasLongContextSurcharge(billing)) { return { type: 'number', title: localize('copilot.modelContextSize.title', "Context Size"), @@ -1126,22 +1124,19 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Builds the open `_meta` pricing bag for a model from its billing info so the chat model picker can render its - * cost hover. Delegates to the shared {@link createPricingMetaFromBilling} helper. + * Builds the open `_meta` model picker bag from the SDK's billing and picker metadata. */ - private _createModelPricingMeta(modelInfo: ModelInfo | undefined): Record | undefined { - const billing = modelInfo?.billing as ICAPIModelBilling | undefined; - const priceCategory = typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined; - return createPricingMetaFromBilling(billing, priceCategory); + private _createModelPickerMeta(modelInfo: ModelInfo, billing: ICAPIModelBilling | undefined): Record | undefined { + return createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory); } - private _createModelConfigSchema(m: ModelInfo): ConfigSchema | undefined { + private _createModelConfigSchema(m: ModelInfo, billing: ICAPIModelBilling | undefined): ConfigSchema | undefined { const properties: ConfigSchema['properties'] = {}; const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(m.supportedReasoningEfforts, m.defaultReasoningEffort); if (thinkingLevel) { properties[ThinkingLevelConfigKey] = thinkingLevel; } - const contextSize = this._createContextSizeConfigSchemaProperty(m.billing); + const contextSize = this._createContextSizeConfigSchemaProperty(billing); if (contextSize) { properties[ContextSizeConfigKey] = contextSize; } @@ -1290,13 +1285,14 @@ export class CopilotAgent extends Disposable implements IAgent { this._freeLongContextModels.clear(); const preferLongContext = this._isPreferLongContextEnabled(); const result = models.map((m): IAgentModelInfo => { - const configSchema = this._createModelConfigSchema(m); + const billing = normalizeCAPIBilling(m.billing); + const configSchema = this._createModelConfigSchema(m, billing); // A model has free long context (larger window, no surcharge), but only treat it as free when the user prefers long context. - const tokenPrices = m.billing?.tokenPrices; + const tokenPrices = billing?.tokenPrices; const hasLargerLongContext = !!tokenPrices?.contextMax && !!tokenPrices.longContext?.contextMax && tokenPrices.longContext.contextMax > tokenPrices.contextMax; - if (preferLongContext && hasLargerLongContext && !hasLongContextSurcharge(m.billing as ICAPIModelBilling | undefined)) { + if (preferLongContext && hasLargerLongContext && !hasLongContextSurcharge(billing)) { this._freeLongContextModels.add(m.id); } return { @@ -1311,7 +1307,7 @@ export class CopilotAgent extends Disposable implements IAgent { supportsVision: !!m.capabilities?.supports?.vision, configSchema, policyState: m.policy?.state as PolicyState | undefined, - _meta: this._createModelPricingMeta(m), + _meta: this._createModelPickerMeta(m, billing), }; }); this._logService.info(`[Copilot] Found ${result.length} models: ${result.map(m => m.name).join(', ')}`); @@ -1650,17 +1646,6 @@ export class CopilotAgent extends Disposable implements IAgent { return { session: sessionUri, workingDirectory, provisional: true, ...(project ? { project } : {}) }; } - /** - * Root directory the Copilot CLI uses for per-session state. The CLI stores - * each session's files under `/session-state//` and resolves - * `` to `$COPILOT_HOME` or `~/.copilot`. The CLI subprocess inherits - * `COPILOT_HOME` from this process's environment (see {@link _ensureClient}, - * which never overrides it), so reading it here matches what the CLI sees. - */ - private _copilotConfigRoot(): string { - return process.env['COPILOT_HOME'] || join(os.homedir(), '.copilot'); - } - /** * Materializes an imported conversation into a real, editable Copilot * session. Translates the supplied turns into a Copilot event log, seeds it @@ -1682,7 +1667,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Detect the project concurrently with the (independent) event-log write // so the git probe and file I/O overlap on the session-creation path. const projectPromise = projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); - const eventsPath = join(this._copilotConfigRoot(), 'session-state', sessionId, 'events.jsonl'); + const eventsPath = join(getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), 'session-state', sessionId, 'events.jsonl'); const jsonl = buildSessionEventLogFromTurns(importConfig.turns, { sessionId, workingDirectory: workingDirectory.fsPath, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 85b1b7f79534a2..a635ea94e56d4b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotSession, ExitPlanModeRequest, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; +import type { CopilotSession, CurrentToolMetadata, ExitPlanModeRequest, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequestResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; import { DeferredPromise, Sequencer } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; @@ -26,7 +26,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; +import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; @@ -34,7 +34,7 @@ import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledCo import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle } from '../../common/agentService.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; +import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; @@ -47,6 +47,7 @@ import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +import { agentHostModelSupportsToolSearch, CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME } from './toolSearchDeferral.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; import { AgentHostRepoInfoTelemetry } from '../agentHostRepoInfoTelemetry.js'; @@ -646,6 +647,8 @@ export class CopilotAgentSession extends Disposable { private readonly _activeClientToolSet: ActiveClientToolSet; /** Tool names that are client-provided, derived from snapshot. */ private readonly _clientToolNames: ReadonlySet; + /** Launch-time tool-search decision; kept stable for the lifetime of the SDK session. */ + private readonly _toolSearchActive: boolean; /** Deferred promises for pending client tool calls, keyed by toolCallId. */ private readonly _pendingClientToolCalls = new PendingRequestRegistry(); /** Pending SDK MCP auth handler promises, keyed by SDK auth request id. */ @@ -749,6 +752,14 @@ export class CopilotAgentSession extends Disposable { this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); + const model = this._launchPlan.kind === 'create' ? this._launchPlan.model : this._launchPlan.fallback.model; + // Capability decisions use the family-aliased selection so an aliased + // preview model agrees with the launcher's tool-search gating (which + // also aliases before checking); the wire model id is unaffected. + const effectiveModel = applyModelFamilyAlias(model, this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ModelCapabilityOverrides)); + this._toolSearchActive = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchEnabled) === true + && agentHostModelSupportsToolSearch(effectiveModel?.id) + && this._clientToolNames.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME); // Share the agent's live ActiveClientToolSet when provided so client // contributions (and owner identity) are observed at stamp time. // Standalone / test construction uses a fresh empty registry, which @@ -1072,22 +1083,118 @@ export class CopilotAgentSession extends Disposable { if (tools.length === 0) { return []; } - return tools.map(def => ({ - name: def.name, - description: def.description ?? '', - parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, - handler: async (_args: Record, { toolCallId }) => { - try { - // The completion may legitimately arrive before this handler - // registers; the registry buffers early results so register() - // resolves immediately in that case. - return await this._pendingClientToolCalls.register(toolCallId); - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed in client tool handler: tool=${def.name}, toolCallId=${toolCallId}`); - throw error; + const toolSearchActive = this._isToolSearchActive(); + const sessionTools = toolSearchActive + ? tools + : tools.filter(def => def.name !== CLIENT_TOOL_SEARCH_REFERENCE_NAME); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return sessionTools.map((def): Tool => { + if (toolSearchActive && def.name === CLIENT_TOOL_SEARCH_REFERENCE_NAME) { + return { + name: RUNTIME_TOOL_SEARCH_TOOL_NAME, + description: def.description ?? '', + parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, + overridesBuiltInTool: true, + defer: 'never', + skipPermission: true, + handler: async (_args: Record, invocation) => { + try { + const candidates = this._toToolSearchCandidates(invocation.availableTools); + const clientResult = await this._pendingClientToolCalls.registerAndFire( + invocation.toolCallId, + () => this._emitToolSearchReady(invocation.toolCallId, candidates), + ); + return this._toToolSearchResult(clientResult, invocation.availableTools); + } catch (error) { + this._logService.error(error, `[Copilot:${this.sessionId}] Failed in tool-search handler: toolCallId=${invocation.toolCallId}`); + return this._toolSearchFailure(getErrorMessage(error)); + } + }, + }; + } + const defer: 'auto' | 'never' | undefined = toolSearchActive + ? (NON_DEFERRED_CLIENT_TOOL_NAMES.has(def.name) ? 'never' : 'auto') + : undefined; + return { + name: def.name, + description: def.description ?? '', + parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, + defer, + handler: async (_args: Record, { toolCallId }) => { + try { + return await this._pendingClientToolCalls.register(toolCallId); + } catch (error) { + this._logService.error(error, `[Copilot:${this.sessionId}] Failed in client tool handler: tool=${def.name}, toolCallId=${toolCallId}`); + throw error; + } + }, + }; + }); + } + + private _isToolSearchActive(): boolean { + return this._toolSearchActive; + } + + private _clientToolName(toolName: string): string { + return this._isToolSearchActive() + && toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME + ? CLIENT_TOOL_SEARCH_REFERENCE_NAME + : toolName; + } + + private _toToolSearchCandidates(availableTools: readonly CurrentToolMetadata[] | undefined): readonly IToolSearchCandidate[] { + return (availableTools ?? []) + .filter(tool => tool.deferLoading) + .map(tool => ({ + name: tool.name, + description: tool.description ?? '', + })); + } + + private _emitToolSearchReady(toolCallId: string, candidates: readonly IToolSearchCandidate[]): void { + const tracked = this._activeToolCalls.get(toolCallId); + if (!tracked) { + throw new Error(`Tool-search call '${toolCallId}' was not tracked.`); + } + this._emitAction({ + type: ActionType.ChatToolCallReady, + turnId: this._turnId, + toolCallId, + invocationMessage: getInvocationMessage(tracked.toolName, tracked.displayName, tracked.parameters), + toolInput: getToolInputString(tracked.toolName, tracked.parameters, tracked.parameters ? tryStringify(tracked.parameters) : undefined), + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: toToolCallMeta({ ...(tracked.meta ?? {}), toolSearchCandidates: candidates }), + }, tracked.parentToolCallId); + } + + private _toolSearchFailure(message: string): ToolResultObject { + return { textResultForLlm: message, resultType: 'failure', error: message, toolReferences: [] }; + } + + private _toToolSearchResult(clientResult: ToolResultObject, availableTools: readonly CurrentToolMetadata[] | undefined): ToolResultObject { + const deferred = new Set(); + for (const tool of availableTools ?? []) { + if (tool.deferLoading) { + deferred.add(tool.name); + if (tool.namespacedName) { + deferred.add(tool.namespacedName); } - }, - })); + } + } + const clientNames = this._parseToolSearchNames(clientResult.textResultForLlm); + const toolReferences = clientNames.filter(name => deferred.has(name)); + this._logService.info(`[Copilot:${this.sessionId}] tool_search override: availableTools=${availableTools?.length ?? 0}, deferred=${deferred.size}, clientMatched=[${clientNames.join(', ')}] -> toolReferences=[${toolReferences.join(', ')}]`); + return { ...clientResult, toolReferences }; + } + + private _parseToolSearchNames(text: string): string[] { + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed.filter((name): name is string => typeof name === 'string') : []; + } catch { + return []; + } } /** @@ -1107,6 +1214,7 @@ export class CopilotAgentSession extends Disposable { name: def.name, description: def.description ?? '', parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, + defer: 'never' as const, handler: async (args: Record): Promise => { try { const text = host.executeTool(this._chatChannelUri.toString(), def.name, args); @@ -2040,7 +2148,7 @@ export class CopilotAgentSession extends Disposable { if (recommendation === 'approve' && !request.requestSandboxBypass) { if (request.kind === 'custom-tool' && typeof request.toolName === 'string' - && this._clientToolNames.has(request.toolName) + && this._clientToolNames.has(this._clientToolName(request.toolName)) ) { const trackedToolCall = this._activeToolCalls.get(toolCallId); const displayName = trackedToolCall?.displayName ?? getToolDisplayName(request.toolName); @@ -2129,7 +2237,7 @@ export class CopilotAgentSession extends Disposable { if (request.kind === 'custom-tool' && typeof request.toolName === 'string' - && this._clientToolNames.has(request.toolName) + && this._clientToolNames.has(this._clientToolName(request.toolName)) && this._pendingClientToolCalls.hasBufferedResult(toolCallId) ) { this._logService.info(`[Copilot:${this.sessionId}] Auto-approving client tool ${request.toolName} because its result arrived before the permission request`); @@ -3139,8 +3247,9 @@ export class CopilotAgentSession extends Disposable { const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; let contributor: { readonly kind: ToolCallContributorKind.Client; readonly clientId: string } | { readonly kind: ToolCallContributorKind.MCP; readonly customizationId: string } | undefined; - const isClientTool = this._clientToolNames.has(e.data.toolName); - const ownerClientId = isClientTool ? this._activeClientToolSet.ownerOf(e.data.toolName, this._currentTurn?.senderClientId) : undefined; + const clientToolName = this._clientToolName(e.data.toolName); + const isClientTool = this._clientToolNames.has(clientToolName); + const ownerClientId = isClientTool ? this._activeClientToolSet.ownerOf(clientToolName, this._currentTurn?.senderClientId) : undefined; if (ownerClientId) { contributor = { kind: ToolCallContributorKind.Client, clientId: ownerClientId }; } else if (e.data.mcpServerName) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 1a72347791c3e4..8d6d6065975a64 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -10,6 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; +import { agentHostModelSupportsToolSearch, CLIENT_TOOL_SEARCH_REFERENCE_NAME } from './toolSearchDeferral.js'; import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -555,20 +556,23 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { const skillDirectories = toSdkSkillDirectories(pluginsWithoutDirs.flatMap(p => p.skills)); const instructionDirectories = toSdkInstructionDirectories(plugins.flatMap(p => p.instructions)); const model = plan.kind === 'create' ? plan.model : plan.fallback.model; - // Client tools (browser tools, tasks, etc.) are addressed by the name the - // agent sees them under; used to gate tool-specific prompt sections. const clientToolNames = clientToolNamesFromSnapshot(plan.snapshot); + // Prompt routing and capability decisions use the family-aliased + // selection; the wire model id in _createSession comes from plan.model + // and is unaffected. + const effectiveModel = applyModelFamilyAlias(model, this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ModelCapabilityOverrides)); + if (model && effectiveModel !== model) { + this._logService.info(`[Copilot:${plan.sessionId}] Model capability override: routing prompt for '${model.id}' as family '${effectiveModel?.id}'`); + } + const toolSearchActive = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchEnabled) === true + && agentHostModelSupportsToolSearch(effectiveModel?.id) + && clientToolNames.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME); const promptContext: IAgentHostPromptContext = { getSetting: key => this._configurationService.getRootValue(copilotCliConfigSchema, key), hasClientTool: name => clientToolNames.has(name), workspaceless: plan.workspaceless === true, + toolSearchActive, }; - // Prompt routing uses the family-aliased selection; the wire model id in - // _createSession comes from plan.model and is unaffected. - const effectiveModel = applyModelFamilyAlias(model, this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ModelCapabilityOverrides)); - if (model && effectiveModel !== model) { - this._logService.info(`[Copilot:${plan.sessionId}] Model capability override: routing prompt for '${model.id}' as family '${effectiveModel?.id}'`); - } // Resolved once per (re)launch — the SDK has no mid-session system-message // update, so this reflects the model/tools/settings at launch time. Log a // summary at info for prompt observability; the full config at trace. @@ -602,6 +606,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { skillDirectories, instructionDirectories, systemMessage, + toolSearch: toolSearchActive ? { enabled: true, deferThreshold: 1 } : { enabled: false }, pluginDirectories: coalesce(plugins.map(p => p.pluginDir)) .filter(d => d.scheme === Schemas.file).map(d => d.fsPath), tools: [...shellTools, ...runtime.createClientSdkTools(), ...runtime.createServerSdkTools()], diff --git a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md index bee8132900dc80..c5f04afb255351 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md +++ b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md @@ -73,6 +73,42 @@ default client-tool allowlist is `chat.agentHost.clientTools` (see These lines compose with a per-model `tool_instructions` override (see `composeToolInstructions`), so Lever 1 and Lever 2 stack. +## Tool search (deferred tool loading) + +When `chat.agentHost.copilot.toolSearch.enabled` is on AND the session's model +supports it (`agentHostModelSupportsToolSearch`), the launcher sets +`toolSearch: { enabled: true, deferThreshold: 1 }` and the session defers MCP + +non-core client tools behind the runtime's `tool_search_tool`: + +- **The override** (`copilotAgentSession._createClientSdkTools`): the client's + forwarded `toolSearch` tool is registered as `tool_search_tool` with + `overridesBuiltInTool: true` and `defer: 'never'`, so the runtime routes the + model's search to the client's semantic search. The SDK supplies the runtime's + live deferred-tool metadata to the override handler; Agent Host carries that + corpus as transient tool-call metadata and injects it only into the local + `toolSearch` invocation, so embeddings rank the runtime/MCP tools rather than + the extension's registry. The corpus is never added to model-facing tool input. + Every other client tool gets + `defer: 'never'` if it is in `NON_DEFERRED_CLIENT_TOOL_NAMES` + (`runTests`, `rename`, `usages`), else `defer: 'auto'`. Built-in runtime tools + are never deferred. The renderer (`agentHostSessionHandler._setupClientToolCall`) + maps `tool_search_tool` back to `toolSearch` to execute the real VS Code tool. +- **The prompt** (this folder): `toolSearchInstructionLines(toolSearchActive)` + adds a `tool_instructions` line (`toolSearchToolInstructions`) telling the + model to load deferred tools via `tool_search_tool` first — gated on + `context.toolSearchActive` because the `toolSearch` tool is *always* forwarded, + so presence alone can't gate it. The runtime already emits its own + deferred-tools reminder (`build_deferred_tools_user_message`) with the accurate + deferred set, so this layer intentionally does NOT re-list the deferred tools. + +The two identity/count levers are independent: `deferThreshold` is a total +tool-count gate (1 ⇒ always active), while each tool's `defer` flag decides +whether *that* tool is deferred. + +This B-inject bridge is intentionally interim. A follow-up moves tool-search +registration and ranking into VS Code core so Agent Host no longer depends on +the Copilot extension's tool implementation or embeddings plumbing. + ## Lever 2 — per-model contributor (`promptRegistry.ts` + `allPrompts.ts`) Guidance scoped to a model or family. Implement `IAgentHostPrompt` and register diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 97d27edc8a6c00..2e4bb7a6c0ee6d 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -8,7 +8,7 @@ import { copilotCliConfigSchema } from '../../../common/copilotCliConfig.js'; import type { SchemaValue } from '../../../common/agentHostSchema.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; import { appendSystemMessageContent, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; -import { resolveToolInstructionsOverride } from './toolInstructions.js'; +import { resolveToolInstructionsOverride, toolSearchInstructionLines } from './toolInstructions.js'; type CopilotCliConfigDefinition = typeof copilotCliConfigSchema.definition; @@ -44,6 +44,9 @@ export interface IAgentHostPromptContext { */ hasClientTool(name: string): boolean; + /** Whether deferred tool search is active for this session. */ + toolSearchActive: boolean; + /** * Whether this is a workspace-less session. When `true`, the * resolved system message gets a scratch/repoless section (see @@ -205,7 +208,7 @@ export class AgentHostPromptRegistry { if (config.mode !== 'customize') { return config; } - const toolInstructions = resolveToolInstructionsOverride(name => context.hasClientTool(name), config.sections?.tool_instructions); + const toolInstructions = resolveToolInstructionsOverride(name => context.hasClientTool(name), config.sections?.tool_instructions, toolSearchInstructionLines(context.toolSearchActive)); if (!toolInstructions) { return config; } diff --git a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts index c32495471cea57..6a72a57a8f3a7f 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/toolInstructions.ts @@ -6,6 +6,7 @@ import type { SectionOverride } from '@github/copilot-sdk'; import { coalesce } from '../../../../../base/common/arrays.js'; import { BrowserChatToolReferenceName, browserChatToolReferenceNames } from '../../../../browserView/common/browserChatToolReferenceNames.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../../common/toolSearchConstants.js'; /** * Model-agnostic guidance for the `tool_instructions` system-prompt section. @@ -65,6 +66,16 @@ const browserToolInstructions: ToolInstructionLine = hasTool => { */ const TOOL_INSTRUCTION_LINES: readonly ToolInstructionLine[] = [browserToolInstructions]; +/** Tool-search guidance mirrored from the Copilot extension prompt. */ +const toolSearchToolInstructions: ToolInstructionLine = hasTool => + hasTool(CLIENT_TOOL_SEARCH_REFERENCE_NAME) + ? `Most tools are deferred and hidden until you search for them. Before calling a tool that has not already been loaded, ALWAYS call \`${RUNTIME_TOOL_SEARCH_TOOL_NAME}\` first with a short description of the capability you need, then call the specific tool it returns; tools it returns are immediately available and must not be searched for again.` + : undefined; + +export function toolSearchInstructionLines(toolSearchActive: boolean): readonly ToolInstructionLine[] { + return toolSearchActive ? [...TOOL_INSTRUCTION_LINES, toolSearchToolInstructions] : TOOL_INSTRUCTION_LINES; +} + /** * Composes the applicable `lines` into a single block (one line each), or * `undefined` when none apply to the session. diff --git a/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts new file mode 100644 index 00000000000000..cc2fc054a275a4 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; + +/** + * Non-deferred client tools, mirroring the Copilot extension allowlist entries + * that are actually forwarded to Agent Host. + */ +export const NON_DEFERRED_CLIENT_TOOL_NAMES: ReadonlySet = new Set([ + 'runTests', + 'rename', + 'usages', +]); + +/** Mirrors the Copilot extension's string-form `modelSupportsToolSearch`. */ +export function agentHostModelSupportsToolSearch(modelId: string | undefined): boolean { + if (!modelId) { + return false; + } + const id = modelId.toLowerCase(); + const normalizedId = id.replace(/\./g, '-'); + // Disabled due to an SDK issue with the GPT tool search tool. + // const isGpt56 = id === 'gpt-5.6-sol' || id === 'gpt-5.6-terra' || id === 'gpt-5.6-luna'; + // if (normalizedId === 'gpt-5-4' || normalizedId === 'gpt-5-5' || isGpt56) { + // return true; + // } + if (!normalizedId.startsWith('claude') || normalizedId.startsWith('claude-haiku')) { + return false; + } + const isPre45 = + normalizedId.startsWith('claude-1') || + normalizedId.startsWith('claude-2') || + normalizedId.startsWith('claude-3') || + normalizedId.startsWith('claude-instant') || + normalizedId === 'claude-sonnet-4' || normalizedId.startsWith('claude-sonnet-4-2') || + normalizedId === 'claude-opus-4' || normalizedId.startsWith('claude-opus-4-1') || normalizedId.startsWith('claude-opus-4-2'); + return !isPre45; +} diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index ddbb106effec7a..eb531e16c3a269 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -64,6 +64,14 @@ const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, '**/*.lock': false, '**/*-lock.{yaml,json}': false, + // Files that can register lifecycle hooks running arbitrary shell commands. + // Writing them must never be auto-approved. Keep in sync with the hook and + // agent source locations in `promptFileLocations.ts`. + '**/.github/agents/**': false, + '**/.github/hooks/**': false, + '**/.claude/agents/**': false, + '**/.claude/settings.json': false, + '**/.claude/settings.local.json': false, }; const HOME_DIR = URI.file(homedir()); diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 0ece2508d4ac9b..ea5fe77949b49d 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -71,6 +71,13 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(readToolCallMeta(toolCall({ toolArguments: undefined })), {}); }); + test('reads valid tool-search candidates and drops malformed corpora', () => { + const candidates = [{ name: 'everything-get-sum', description: 'Adds numbers' }]; + assert.deepStrictEqual(readToolCallMeta(toolCall({ toolSearchCandidates: candidates })), { toolSearchCandidates: candidates }); + assert.deepStrictEqual(readToolCallMeta(toolCall({ toolSearchCandidates: [{ name: 1, description: 'bad' }] })), {}); + assert.deepStrictEqual(readToolCallMeta(toolCall({ toolSearchCandidates: 'bad' })), {}); + }); + test('toToolCallMeta round-trips and returns undefined when empty', () => { assert.strictEqual(toToolCallMeta({}), undefined); const wire = toToolCallMeta({ toolKind: 'search', language: undefined }); diff --git a/src/vs/platform/agentHost/test/common/copilotHome.test.ts b/src/vs/platform/agentHost/test/common/copilotHome.test.ts new file mode 100644 index 00000000000000..87d9b3f9804045 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/copilotHome.test.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { join } from '../../../../base/common/path.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { getCopilotHomePath, getCopilotRootPaths } from '../../common/copilotHome.js'; + +suite('copilotHome', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves the configured or default Copilot home', () => { + assert.deepStrictEqual([ + getCopilotHomePath('user-home', {}), + getCopilotHomePath('user-home', { COPILOT_HOME: 'custom-copilot' }), + ], [ + join('user-home', '.copilot'), + 'custom-copilot', + ]); + }); + + test('resolves all Copilot roots', () => { + assert.deepStrictEqual([ + getCopilotRootPaths('user-home', {}), + getCopilotRootPaths('user-home', { COPILOT_HOME: 'custom-copilot' }), + ], [ + [join('user-home', '.copilot')], + ['custom-copilot', join('user-home', '.copilot')], + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/state/chatSummaryStatus.test.ts b/src/vs/platform/agentHost/test/common/state/chatSummaryStatus.test.ts new file mode 100644 index 00000000000000..48a6e9eb5785f4 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/state/chatSummaryStatus.test.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { chatSummaryFromState, MessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, type ChatState, type ResponsePart, type ToolCallState } from '../../../common/state/sessionState.js'; + +/** A tool call awaiting confirmation, optionally flagged for setting-driven auto-approval. */ +function pendingToolCall(toolCallId: string, autoApprove: boolean): ResponsePart { + const toolCall: ToolCallState = { + status: ToolCallStatus.PendingConfirmation, + toolCallId, + toolName: 'browser_navigate', + displayName: 'Navigate Browser', + invocationMessage: 'Navigate', + confirmationTitle: 'Navigate', + ...(autoApprove ? { _meta: { autoApproveBySetting: true } } : {}), + }; + return { kind: ResponsePartKind.ToolCall, toolCall }; +} + +/** + * A tool call awaiting *result* confirmation (a post-execution gate). Even when + * the parameter confirmation was auto-approved (so `autoApproveBySetting` is + * preserved on the call), the result gate is a genuine user prompt. + */ +function pendingResultToolCall(toolCallId: string, autoApprove: boolean): ResponsePart { + const toolCall: ToolCallState = { + status: ToolCallStatus.PendingResultConfirmation, + toolCallId, + toolName: 'browser_navigate', + displayName: 'Navigate Browser', + invocationMessage: 'Navigate', + confirmed: ToolCallConfirmationReason.Setting, + success: true, + pastTenseMessage: 'Navigated', + ...(autoApprove ? { _meta: { autoApproveBySetting: true } } : {}), + }; + return { kind: ResponsePartKind.ToolCall, toolCall }; +} + +/** A minimal {@link ChatState} with an active turn carrying the given response parts. */ +function chatState(status: SessionStatus, parts: ResponsePart[]): ChatState { + return { + resource: 'agent-host-copilot:/session-1', + title: 'Chat', + status, + modifiedAt: '2024-01-01T00:00:00.000Z', + turns: [], + activeTurn: { id: 'turn-1', startedAt: '2024-01-01T00:00:00.000Z', message: { text: 'go', origin: { kind: MessageKind.User } }, responseParts: parts, usage: undefined }, + }; +} + +/** A restored {@link ChatState} whose active turn is not loaded (as produced by `createChatState`). */ +function restoredChatState(status: SessionStatus): ChatState { + return { + resource: 'agent-host-copilot:/session-1', + title: 'Chat', + status, + modifiedAt: '2024-01-01T00:00:00.000Z', + turns: [], + activeTurn: undefined, + }; +} + +suite('chatSummaryFromState status projection', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('demotes InputNeeded to InProgress when caused solely by auto-approved confirmations', () => { + // Preserves orthogonal flags (IsRead) while clearing the spurious InputNeeded activity. + const state = chatState(SessionStatus.InputNeeded | SessionStatus.IsRead, [pendingToolCall('tc-auto', true)]); + assert.strictEqual(chatSummaryFromState(state).status, SessionStatus.InProgress | SessionStatus.IsRead); + }); + + test('keeps InputNeeded for a genuine (non-auto-approved) confirmation', () => { + const state = chatState(SessionStatus.InputNeeded, [pendingToolCall('tc-user', false)]); + assert.strictEqual(chatSummaryFromState(state).status, SessionStatus.InputNeeded); + }); + + test('keeps InputNeeded when a genuine confirmation coexists with an auto-approved one', () => { + const state = chatState(SessionStatus.InputNeeded, [pendingToolCall('tc-auto', true), pendingToolCall('tc-user', false)]); + assert.strictEqual(chatSummaryFromState(state).status, SessionStatus.InputNeeded); + }); + + test('leaves non-InputNeeded statuses untouched', () => { + const state = chatState(SessionStatus.InProgress, [pendingToolCall('tc-auto', true)]); + assert.strictEqual(chatSummaryFromState(state).status, SessionStatus.InProgress); + }); + + test('keeps InputNeeded for a result confirmation even when the call was auto-approved', () => { + // The result gate is genuine input even though the parameter gate was auto-approved. + const state = chatState(SessionStatus.InputNeeded, [pendingResultToolCall('tc-auto', true)]); + assert.strictEqual(chatSummaryFromState(state).status, SessionStatus.InputNeeded); + }); + + test('preserves InputNeeded for a restored chat with no loaded active turn', () => { + // No blocker is attributable (activeTurn not loaded), so the status must not be fabricated. + const state = restoredChatState(SessionStatus.InputNeeded); + assert.strictEqual(chatSummaryFromState(state).status, SessionStatus.InputNeeded); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index 8cb10847ae8a43..8ced90ece56abd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -11,6 +11,7 @@ import type { ModelSelection } from '../../common/state/protocol/state.js'; import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../node/copilot/prompts/allPrompts.js'; @@ -18,12 +19,13 @@ import '../../node/copilot/prompts/allPrompts.js'; * Builds a prompt context backed by an in-memory bag of customization settings * and an optional set of available tool names. */ -function context(settings: SchemaValues = {}, tools: readonly string[] = [], workspaceless = false): IAgentHostPromptContext { +function context(settings: SchemaValues = {}, tools: readonly string[] = [], workspaceless = false, toolSearchActive = false): IAgentHostPromptContext { const toolNames = new Set(tools); return { getSetting: key => settings[key], hasClientTool: name => toolNames.has(name), workspaceless, + toolSearchActive, }; } @@ -260,4 +262,56 @@ suite('AgentHostPromptRegistry', () => { ); }); }); + + suite('tool search instructions wiring', () => { + // End-to-end guard that the registry layers the tool-search line only + // when `toolSearchActive` AND the client tool-search tool are both + // present; the composition/gating itself is covered in + // toolInstructions.test.ts. + const TOOL_SEARCH_LINE = `Most tools are deferred and hidden until you search for them. Before calling a tool that has not already been loaded, ALWAYS call \`${RUNTIME_TOOL_SEARCH_TOOL_NAME}\` first with a short description of the capability you need, then call the specific tool it returns; tools it returns are immediately available and must not be searched for again.`; + + test('layers the tool-search line onto the default config when active and the tool-search tool is present', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'm' }, context({}, [CLIENT_TOOL_SEARCH_REFERENCE_NAME], false, true)), + withFileLinkInstructions({ + mode: 'customize', + sections: { + identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, + tool_instructions: { action: 'append', content: `\n${TOOL_SEARCH_LINE}` }, + }, + }) + ); + }); + + test('is a no-op when tool search is inactive even if the tool-search tool is present', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'm' }, context({}, [CLIENT_TOOL_SEARCH_REFERENCE_NAME], false, false)), + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) + ); + }); + + test('is a no-op when active but the client does not expose the tool-search tool', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'], false, true)), + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) + ); + }); + + test('composes the tool-search line with a per-model tool_instructions override', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial> { + return { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, [CLIENT_TOOL_SEARCH_REFERENCE_NAME], false, true)), + withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${TOOL_SEARCH_LINE}` } } }) + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index afbf59fc00731e..e936675b00d727 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -12,6 +12,8 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../telemetry/common/telemetryUtils.js'; +import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; @@ -149,6 +151,11 @@ suite('AgentSideEffects — turn tracker telemetry', () => { return telemetry.events.filter(e => e.eventName === 'agentHost.turnCompleted'); } + function capturedModel(data: Record): { trusted: boolean; value: unknown } { + const model = data.model; + return model instanceof TelemetryTrustedValue ? { trusted: true, value: model.value } : { trusted: false, value: model }; + } + function failedEvents(): { eventName: string; data: unknown }[] { return telemetry.events.filter(e => e.eventName === 'agentHost.turnFailed'); } @@ -192,6 +199,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits turnCompleted with timing, model and permissionLevel on success', () => { setupSession(); + agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); setAutoApprove('autopilot'); startTurn('turn-1', 'hello', 'gpt-5.5'); @@ -205,13 +213,37 @@ suite('AgentSideEffects — turn tracker telemetry', () => { assert.strictEqual(data.agentSessionId, 'session-1'); assert.strictEqual(data.turnId, 'turn-1'); assert.strictEqual(data.result, 'success'); - assert.strictEqual(data.model, 'gpt-5.5'); + assert.deepStrictEqual(capturedModel(data), { trusted: true, value: 'gpt-5.5' }); assert.strictEqual(data.modelSelectionKind, 'explicit'); assert.strictEqual(data.permissionLevel, 'autopilot'); assert.strictEqual(typeof data.totalTime, 'number'); assert.strictEqual(typeof data.timeToFirstProgress, 'number'); }); + test('uses generic model values for BYOK and unknown selections', () => { + setupSession(); + agent.setModels([{ + provider: 'mock', + id: 'openrouter/private-model', + name: 'Private Model', + supportsVision: false, + _meta: createAgentModelByokMeta('openrouter/private-model'), + }]); + + startTurn('turn-byok', 'hello', 'openrouter/private-model'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-byok', duration: 1000 }); + startTurn('turn-unknown', 'hello', 'unadvertised/private-model'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-unknown', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { model: data.model, modelSelectionKind: data.modelSelectionKind }; + }), [ + { model: 'byokModel', modelSelectionKind: 'explicit' }, + { model: 'unknown', modelSelectionKind: 'explicit' }, + ]); + }); + test('timeToFirstProgress is undefined when no visible progress arrives before completion', () => { setupSession(); startTurn('turn-1'); @@ -229,10 +261,12 @@ suite('AgentSideEffects — turn tracker telemetry', () => { startTurn('turn-1', 'hello', 'auto'); fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); - const events = completedEvents(); - assert.strictEqual(events.length, 1); - assert.strictEqual((events[0].data as Record).result, 'cancelled'); - assert.strictEqual((events[0].data as Record).modelSelectionKind, 'auto'); + const data = completedEvents()[0].data as Record; + assert.deepStrictEqual({ + model: capturedModel(data), + result: data.result, + modelSelectionKind: data.modelSelectionKind, + }, { model: { trusted: true, value: 'auto' }, result: 'cancelled', modelSelectionKind: 'auto' }); }); test('emits result=error on ChatError', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 4010a6a6c890b1..84cfbf2aaff2b8 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -4438,6 +4438,58 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(sessionInputNeeded(), []); }); + test('auto-approved tool call is kept out of the session inputNeeded queue', () => { + setupSession(); + startTurn('turn-1'); + + // Auto-approved calls flow through PendingConfirmation then Running but never block the user. + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-auto', toolName: 'browser_navigate', displayName: 'Navigate Browser', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + _meta: { autoApproveBySetting: true }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: 'tc-auto', invocationMessage: 'Navigate', confirmationTitle: 'Navigate', + _meta: { autoApproveBySetting: true }, + }); + assert.deepStrictEqual(sessionInputNeeded(), [], 'no confirmation entry while PendingConfirmation'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', + toolCallId: 'tc-auto', approved: true, confirmed: ToolCallConfirmationReason.Setting, + }); + assert.deepStrictEqual(sessionInputNeeded(), [], 'no client-execution entry while Running'); + }); + + test('auto-approved tool still surfaces a genuine result confirmation', () => { + setupSession(); + startTurn('turn-1'); + + // The auto-approved parameter gate is suppressed, but a post-execution result gate is a genuine prompt. + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-auto-result', toolName: 'browser_navigate', displayName: 'Navigate Browser', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + _meta: { autoApproveBySetting: true }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: 'tc-auto-result', invocationMessage: 'Navigate', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallComplete, turnId: 'turn-1', + toolCallId: 'tc-auto-result', requiresResultConfirmation: true, + result: { success: true, pastTenseMessage: 'Navigated' }, + }); + + assert.deepStrictEqual( + sessionInputNeeded().map(r => ({ kind: r.kind, toolCallId: r.kind === SessionInputRequestKind.ToolConfirmation ? r.toolCall.toolCallId : undefined })), + [{ kind: SessionInputRequestKind.ToolConfirmation, toolCallId: 'tc-auto-result' }], + ); + }); + test('MCP tool authentication is produced while auth is required and removed once resolved', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index dba8f92bb49be6..46dad722ba4d1b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, ModelInfo, PermissionAllowAllMode, PermissionRequest, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotClient, CopilotClientOptions, CopilotSession, GitHubTelemetryNotification, PermissionAllowAllMode, PermissionRequest, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; @@ -290,6 +290,7 @@ class TestSessionDataService extends Disposable implements ISessionDataService { whenIdle(): Promise { return Promise.resolve(); } } type CopilotModelsList = CopilotClient['rpc']['models']['list']; +type CopilotModelInfo = Awaited>['models'][number]; interface ITestCopilotModelInfo { readonly id: string; @@ -298,31 +299,22 @@ interface ITestCopilotModelInfo { readonly supports?: { readonly vision?: boolean }; readonly limits?: { readonly max_context_window_tokens?: number; readonly max_output_tokens?: number; readonly max_prompt_tokens?: number }; }; - readonly policy?: { readonly state?: NonNullable['state'] }; - readonly billing?: ModelInfo['billing'] & { - readonly priceCategory?: string; - readonly tokenPrices?: { - readonly contextMax?: number; - readonly inputPrice?: number; - readonly cachePrice?: number; - readonly cacheWritePrice?: number; - readonly outputPrice?: number; - readonly longContext?: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number }; - }; - }; - readonly modelPickerPriceCategory?: string; - readonly supportedReasoningEfforts?: ModelInfo['supportedReasoningEfforts']; - readonly defaultReasoningEffort?: ModelInfo['defaultReasoningEffort']; + readonly policy?: { readonly state?: NonNullable['state'] }; + readonly billing?: CopilotModelInfo['billing']; + readonly modelPickerCategory?: CopilotModelInfo['modelPickerCategory']; + readonly modelPickerPriceCategory?: CopilotModelInfo['modelPickerPriceCategory']; + readonly supportedReasoningEfforts?: CopilotModelInfo['supportedReasoningEfforts']; + readonly defaultReasoningEffort?: CopilotModelInfo['defaultReasoningEffort']; } -interface ITestCopilotClient extends Pick { +interface ITestCopilotClient extends Pick { readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] }; readonly models: { readonly list: CopilotModelsList }; }; } -function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { +function toSdkModelInfo(model: ITestCopilotModelInfo): CopilotModelInfo { return { id: model.id, name: model.name, @@ -333,15 +325,13 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { }, limits: { max_context_window_tokens: model.capabilities?.limits?.max_context_window_tokens ?? 0, - // `max_output_tokens` is present on the RPC `models.list` shape the - // agent reads but absent from the SDK's `ModelInfo` limits type, so - // widen here to let fixtures exercise the real value. max_output_tokens: model.capabilities?.limits?.max_output_tokens, max_prompt_tokens: model.capabilities?.limits?.max_prompt_tokens, - } as ModelInfo['capabilities']['limits'], + }, }, ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), ...(model.billing ? { billing: model.billing } : {}), + ...(model.modelPickerCategory ? { modelPickerCategory: model.modelPickerCategory } : {}), ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), @@ -386,7 +376,6 @@ class TestCopilotClient implements ITestCopilotClient { this.listSessionCallCount++; return this._sessions; } - async listModels(): ReturnType { return this._models.map(toSdkModelInfo); } async getSessionMetadata(sessionId: string): ReturnType { this.getSessionMetadataCalls.push(sessionId); return this._sessions.find(s => s.sessionId === sessionId); @@ -1597,7 +1586,7 @@ suite('CopilotAgent', () => { } }); - test('models include token-price and price-category metadata when billing provides it', async () => { + test('models include picker and promo metadata when the SDK provides it', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'claude-sonnet', @@ -1605,14 +1594,22 @@ suite('CopilotAgent', () => { capabilities: { limits: { max_context_window_tokens: 200_000 } }, billing: { multiplier: 1, + promo: { + id: 'summer-sale', + discountPercent: 25, + endsAt: '2026-08-01T00:00:00Z', + message: 'Save on Claude Sonnet', + }, tokenPrices: { - contextMax: 200_000, - inputPrice: 3, - cachePrice: 1, - outputPrice: 15, - longContext: { contextMax: 1_000_000, inputPrice: 6, cachePrice: 1, outputPrice: 22.5 }, + batchSize: 100_000, + maxPromptTokens: 200_000, + inputPrice: 0.3, + cacheReadPrice: 0.1, + outputPrice: 1.5, + longContext: { maxPromptTokens: 1_000_000, inputPrice: 0.6, cacheReadPrice: 0.1, outputPrice: 2.25 }, }, }, + modelPickerCategory: 'powerful', modelPickerPriceCategory: 'medium', }]), }); @@ -1629,6 +1626,13 @@ suite('CopilotAgent', () => { longContextCacheCost: 1, longContextOutputCost: 22.5, priceCategory: 'medium', + category: 'powerful', + promo: { + id: 'summer-sale', + discountPercent: 25, + endsAt: '2026-08-01T00:00:00Z', + message: 'Save on Claude Sonnet', + }, }); } finally { await disposeAgent(agent); @@ -1667,8 +1671,8 @@ suite('CopilotAgent', () => { billing: { multiplier: 1, tokenPrices: { - contextMax: 200_000, - longContext: { contextMax: 1_000_000, inputPrice: 2 }, + maxPromptTokens: 200_000, + longContext: { maxPromptTokens: 1_000_000, inputPrice: 2 }, }, }, }]), diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 8a678ed7fc51b7..dcd465ab9d0c84 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type Anthropic from '@anthropic-ai/sdk'; -import type { CopilotSession, PermissionAllowAllMode, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotSession, CurrentToolMetadata, PermissionAllowAllMode, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; @@ -294,12 +294,13 @@ class CapturingLogService extends NullLogService { * {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s * handler implementation actually returns. */ -function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}): Promise { +function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}, availableTools?: CurrentToolMetadata[]): Promise { return Promise.resolve(tool.handler!(args, { sessionId: 'test-session-1', toolCallId, toolName: tool.name, arguments: args, + availableTools, })) as Promise; } @@ -365,6 +366,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { restrictedTelemetryContext?: IRestrictedTelemetryContext; restrictedTelemetryContextError?: Error; isLaunchTokenCurrent?: () => boolean; + modelId?: string; }): Promise<{ session: CopilotAgentSession; runtime: ICopilotSessionRuntime; @@ -422,7 +424,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager: undefined, githubToken: options?.githubToken, - model: undefined, + model: options?.modelId ? { id: options.modelId } : undefined, }; let launchedRuntime: ICopilotSessionRuntime | undefined; const sessionLauncher: ICopilotSessionLauncher = { @@ -4878,6 +4880,115 @@ suite('CopilotAgentSession', () => { }); }); + test('tool-search override routes to the client and injects deferred candidates', async () => { + const toolSearchSnapshot: IActiveClientSnapshot = { + tools: [{ name: 'toolSearch', description: 'Search tools', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } }], + plugins: [], + mcpServers: {}, + }; + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('tool-search-client', toolSearchSnapshot.tools); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { + clientSnapshot: toolSearchSnapshot, + activeClientToolSet, + modelId: 'claude-opus-4.8', + rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: true }, + }); + + const [override] = runtime.createClientSdkTools(); + assert.strictEqual(override.name, 'tool_search_tool'); + assert.strictEqual(override.overridesBuiltInTool, true); + assert.strictEqual(override.defer, 'never'); + assert.strictEqual(override.skipPermission, true); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-tool-search', + toolName: 'tool_search_tool', + arguments: { query: 'add numbers' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const start = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); + assert.ok(start && isAction(start, ActionType.ChatToolCallStart)); + assert.deepStrictEqual((start.action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'tool-search-client' }); + + const handlerPromise = invokeClientToolHandler(override, 'tc-tool-search', { query: 'add numbers' }, [ + { name: 'everything-get-sum', description: 'Adds numbers', deferLoading: true }, + { name: 'read_file', description: 'Reads a file', deferLoading: false }, + ]); + + const readySignal = await waitForSignal(s => isAction(s, ActionType.ChatToolCallReady)); + assert.ok(isAction(readySignal, ActionType.ChatToolCallReady)); + const ready = readySignal.action as ChatToolCallReadyAction; + assert.strictEqual(ready.confirmed, ToolCallConfirmationReason.NotNeeded); + assert.deepStrictEqual(ready._meta?.['toolSearchCandidates'], [{ name: 'everything-get-sum', description: 'Adds numbers' }]); + + session.handleClientToolCallComplete('tc-tool-search', { + success: true, + pastTenseMessage: 'Searched tools', + content: [{ type: ToolResultContentType.Text, text: '["everything-get-sum"]' }], + }); + + const result = await handlerPromise; + assert.strictEqual(result.resultType, 'success'); + assert.deepStrictEqual(result.toolReferences, ['everything-get-sum']); + }); + + test('toolSearch is omitted when the flag is off or the model is unsupported', async () => { + const toolSearchSnapshot: IActiveClientSnapshot = { + tools: [ + { name: 'toolSearch', description: 'Search tools', inputSchema: { type: 'object', properties: {} } }, + { name: 'my_tool', description: 'Regular tool', inputSchema: { type: 'object', properties: {} } }, + ], + plugins: [], + mcpServers: {}, + }; + + const flagOff = await createAgentSession(disposables, { + clientSnapshot: toolSearchSnapshot, + modelId: 'claude-opus-4.8', + rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: false }, + }); + assert.deepStrictEqual(flagOff.runtime.createClientSdkTools().map(tool => tool.name), ['my_tool']); + + const unsupported = await createAgentSession(disposables, { + clientSnapshot: toolSearchSnapshot, + modelId: 'claude-haiku-4.5', + rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: true }, + }); + assert.deepStrictEqual(unsupported.runtime.createClientSdkTools().map(tool => tool.name), ['my_tool']); + }); + + test('toolSearch honors a model-family alias so an aliased preview model is treated as supported', async () => { + const toolSearchSnapshot: IActiveClientSnapshot = { + tools: [ + { name: 'toolSearch', description: 'Search tools', inputSchema: { type: 'object', properties: {} } }, + { name: 'my_tool', description: 'Regular tool', inputSchema: { type: 'object', properties: {} } }, + ], + plugins: [], + mcpServers: {}, + }; + + // The raw preview id is unsupported on its own, so tool search stays off. + const withoutAlias = await createAgentSession(disposables, { + clientSnapshot: toolSearchSnapshot, + modelId: 'preview-model-x', + rootValues: { [CopilotCliConfigKey.ToolSearchEnabled]: true }, + }); + assert.deepStrictEqual(withoutAlias.runtime.createClientSdkTools().map(tool => tool.name), ['my_tool']); + + // Aliasing it to a tool-search-capable family enables tool search, matching + // the prompt/capability routing the launcher applies from the same override. + const withAlias = await createAgentSession(disposables, { + clientSnapshot: toolSearchSnapshot, + modelId: 'preview-model-x', + rootValues: { + [CopilotCliConfigKey.ToolSearchEnabled]: true, + [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4.8' } }, + }, + }); + assert.deepStrictEqual(withAlias.runtime.createClientSdkTools().map(tool => tool.name), ['tool_search_tool', 'my_tool']); + }); + test('agent-coordination client tools auto-ready with a tailored invocation message', async () => { const agentSnapshot: IActiveClientSnapshot = { tools: [{ name: 'list_agents', description: 'List agents', inputSchema: { type: 'object', properties: {} } }], @@ -5347,6 +5458,10 @@ suite('CopilotAgentSession', () => { const tools = runtime.createServerSdkTools(); assert.deepStrictEqual(tools.map(t => t.name).sort(), [...serverToolHost.toolNames].sort()); + // Server tools are always-available internal tools; they must be + // eager (`defer: 'never'`) so tool search never hides them behind + // `tool_search_tool`. + assert.deepStrictEqual(tools.map(t => t.defer), tools.map(() => 'never')); }); test('server tool handler routes to the host and returns a success result', async () => { diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts index 63b051d6f52234..099b623321096a 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts @@ -10,7 +10,7 @@ */ import assert from 'assert'; -import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -234,6 +234,25 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () ]); } + async function createWorkspace(prefix: string, isRepoRoot = true): Promise { + const workspaceDir = await mkdtemp(`${tmpdir()}/${prefix}`); + tempDirs.push(workspaceDir); + if (isRepoRoot) { + // Create a minimal repository root so discovery does not traverse into an outer repository. + const gitDir = join(workspaceDir, '.git'); + await Promise.all([ + mkdir(join(gitDir, 'objects'), { recursive: true }), + mkdir(join(gitDir, 'refs', 'heads'), { recursive: true }), + mkdir(join(gitDir, 'refs', 'tags'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/main\n'), + writeFile(join(gitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n\tfilemode = false\n\tbare = false\n'), + ]); + } + return realpath(workspaceDir); + } + async function setupSession(sessionUri: string, clientId: string, discoveryMode: SessionCustomizationDiscoveryMode, turnId = 'turn-customizations-empty-mock', configuredCustomizations?: readonly { uri: string; displayName: string; description?: string }[]): Promise { client.dispatch({ channel: ROOT_STATE_URI, @@ -282,8 +301,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () }; async function runEmptyWorkspaceCustomizationsTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-empty-mock-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace('ahp-customizations-empty-mock-'); const sessionUri = await createProviderSession(client, COPILOT_CONFIG, 'real-sdk-customizations-empty-mock', createdSessions, URI.file(workspaceDir)); const session = await setupSession(sessionUri, 'real-sdk-customizations-empty-client-mock', discoveryMode); @@ -319,8 +337,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () } async function runWorkspaceCustomizationsTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-test-mock-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace('ahp-customizations-test-mock-'); const githubDir = join(workspaceDir, '.github'); const agentsDir = join(githubDir, 'agents'); const instructionsDir = join(githubDir, 'instructions'); @@ -434,8 +451,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () } async function runWorkspaceAndPluginCustomizationsTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-workspace-plugin-mock-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace('ahp-customizations-workspace-plugin-mock-'); const workspaceAgentsDir = join(workspaceDir, '.github', 'agents'); const workspaceAgentFile = join(workspaceAgentsDir, 'workspace.agent.md'); @@ -537,8 +553,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () } async function runSyncedBundlePluginCustomizationsTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-workspace-synced-plugin-mock-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace('ahp-customizations-workspace-synced-plugin-mock-'); const syncedBundleDir = await mkdtemp(`${tmpdir()}/ahp-synced-customizations-plugin-mock-`); tempDirs.push(syncedBundleDir); @@ -664,8 +679,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () } async function runAgentInstructionsDiscoveryTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-agent-instructions-mock-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace('ahp-customizations-agent-instructions-mock-'); const workspaceGithubDir = join(workspaceDir, '.github'); const workspaceCopilotInstructionsFile = join(workspaceGithubDir, 'copilot-instructions.md'); const workspaceAgentsInstructionsFile = join(workspaceDir, 'AGENTS.md'); @@ -737,8 +751,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () async function runSimpleInstructionWatchTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-watch-simple-${discoveryMode}-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace(`ahp-customizations-watch-simple-${discoveryMode}-`); const instructionsDir = join(workspaceDir, '.github', 'instructions'); const instructionFile = join(instructionsDir, 'policy.instructions.md'); @@ -845,8 +858,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () } async function runSimpleAgentInstructionWatchTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-watch-simple-agent-instructions-${discoveryMode}-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace(`ahp-customizations-watch-simple-agent-instructions-${discoveryMode}-`); const workspaceAgentInstructionsFile = join(workspaceDir, 'AGENTS.md'); const workspaceClaudeInstructionsFile = join(workspaceDir, 'CLAUDE.md'); @@ -954,8 +966,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () async function runSimpleSkillWatchTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-watch-simple-skill-${discoveryMode}-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace(`ahp-customizations-watch-simple-skill-${discoveryMode}-`); const skillsDir = join(workspaceDir, '.github', 'skills'); const skillDir = join(skillsDir, 'watch-skill'); @@ -1046,8 +1057,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () } async function runSimpleAgentWatchTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { - const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-watch-simple-agent-${discoveryMode}-`); - tempDirs.push(workspaceDir); + const workspaceDir = await createWorkspace(`ahp-customizations-watch-simple-agent-${discoveryMode}-`); const agentsDir = join(workspaceDir, '.github', 'agents'); const agentFile = join(agentsDir, 'watch.agent.md'); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index a2a964a75c5598..2a4a77743e1d8d 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -58,6 +58,7 @@ import { type ProtocolMessage, } from '../../common/state/sessionProtocol.js'; import { AhpSnapshotRecorder, type IAhpSnapshotNormalization, type IAhpSnapshotOptions } from './e2e/harness/ahpSnapshot.js'; +import { isWindows } from '../../../../base/common/platform.js'; // ---- JSON-RPC test client --------------------------------------------------- @@ -727,10 +728,17 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin ...(options?.homeDir ? { HOME: options.homeDir, USERPROFILE: options.homeDir, + APPDATA: join(options.homeDir, 'AppData', 'Roaming'), + LOCALAPPDATA: join(options.homeDir, 'AppData', 'Local'), XDG_CONFIG_HOME: join(options.homeDir, '.config'), COPILOT_HOME: join(options.homeDir, '.copilot'), + COPILOT_SKILLS_DIRS: undefined, CLAUDE_CONFIG_DIR: undefined, CODEX_HOME: undefined, + ...(isWindows && options.homeDir.match(/^[A-Za-z]:[\\/]/) ? { + HOMEDRIVE: options.homeDir.slice(0, 2), + HOMEPATH: options.homeDir.slice(2).replace(/\//g, '\\'), + } : {}), } : {}), // Codex defaults to disabled; opt it in for the agent host e2e suite when a // codex SDK root is supplied so the provider actually registers. diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index 8c6de10b75f686..8019e724eac07f 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -108,6 +108,21 @@ suite('SessionPermissionManager', () => { assert.deepStrictEqual(results, files.map(() => undefined)); }); + test('requires confirmation for files that can register lifecycle hooks', async () => { + const files = [ + join('.github', 'agents', 'dev-helper.md'), + join('.github', 'hooks', 'say-hi.json'), + join('.claude', 'agents', 'dev-helper.md'), + join('.claude', 'settings.json'), + join('.claude', 'settings.local.json'), + ]; + const results: (ToolCallConfirmationReason | undefined)[] = []; + for (const file of files) { + results.push(await permissions.getAutoApproval(writeEvent(join(workDir, file)), sessionUri)); + } + assert.deepStrictEqual(results, files.map(() => undefined)); + }); + test('requires confirmation for paths containing null bytes', async () => { const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'a\u0000b.txt')), sessionUri); assert.strictEqual(result, undefined); diff --git a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts index 18c6ff025a8063..6d77c16d1b73ea 100644 --- a/src/vs/platform/agentHost/test/node/toolInstructions.test.ts +++ b/src/vs/platform/agentHost/test/node/toolInstructions.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import type { SectionOverride } from '@github/copilot-sdk'; -import { resolveToolInstructionsOverride, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; +import { resolveToolInstructionsOverride, toolSearchInstructionLines, universalToolInstructions } from '../../node/copilot/prompts/toolInstructions.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; /** Builds a `hasTool` predicate backed by the given available tool names. */ @@ -82,4 +83,41 @@ suite('toolInstructions', () => { assert.deepStrictEqual(resolveToolInstructionsOverride(hasTools('a'), { action: transform }, [lineFor('a')]), { action: transform }); }); }); + + // The tool-search line is the model-facing instruction gated on BOTH + // `toolSearchActive` (via `toolSearchInstructionLines`) and the client + // exposing the tool-search tool. These lock its content, gating, and + // composition so neither the instruction nor its gate can silently regress. + suite('toolSearchInstructionLines', () => { + const TOOL_SEARCH_LINE = `Most tools are deferred and hidden until you search for them. Before calling a tool that has not already been loaded, ALWAYS call \`${RUNTIME_TOOL_SEARCH_TOOL_NAME}\` first with a short description of the capability you need, then call the specific tool it returns; tools it returns are immediately available and must not be searched for again.`; + + test('active tool search contributes the tool-search line only when the client exposes the tool-search tool', () => { + assert.strictEqual(universalToolInstructions(hasTools(CLIENT_TOOL_SEARCH_REFERENCE_NAME), toolSearchInstructionLines(true)), TOOL_SEARCH_LINE); + // Active, but the client didn't expose the tool-search tool → gated out. + assert.strictEqual(universalToolInstructions(hasTools('other'), toolSearchInstructionLines(true)), undefined); + }); + + test('inactive tool search never contributes the tool-search line', () => { + assert.strictEqual(universalToolInstructions(hasTools(CLIENT_TOOL_SEARCH_REFERENCE_NAME), toolSearchInstructionLines(false)), undefined); + }); + + test('composes the tool-search line after the registered browser line', () => { + assert.strictEqual( + universalToolInstructions(hasTools('openBrowserPage', 'readPage', CLIENT_TOOL_SEARCH_REFERENCE_NAME), toolSearchInstructionLines(true)), + `Use the browser tools (openBrowserPage, readPage, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.\n${TOOL_SEARCH_LINE}` + ); + }); + + test('folds the tool-search line into an existing per-model override only while active', () => { + assert.deepStrictEqual( + resolveToolInstructionsOverride(hasTools(CLIENT_TOOL_SEARCH_REFERENCE_NAME), { action: 'append', content: 'A' }, toolSearchInstructionLines(true)), + { action: 'append', content: `\nA\n${TOOL_SEARCH_LINE}` } + ); + // Inactive with no other applicable line → keep the existing override (undefined). + assert.strictEqual( + resolveToolInstructionsOverride(hasTools(CLIENT_TOOL_SEARCH_REFERENCE_NAME), { action: 'append', content: 'A' }, toolSearchInstructionLines(false)), + undefined + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts b/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts new file mode 100644 index 00000000000000..04fcd4f6540e8d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/toolSearchDeferral.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { agentHostModelSupportsToolSearch, NON_DEFERRED_CLIENT_TOOL_NAMES } from '../../node/copilot/toolSearchDeferral.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +suite('toolSearchDeferral', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('agentHostModelSupportsToolSearch', () => { + test('supports Claude Sonnet/Opus 4.5 and up, including future families', () => { + for (const id of [ + 'claude-sonnet-4-5', 'claude-sonnet-4.5', 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-6', 'claude-sonnet-4.6', 'claude-sonnet-4-6@1.0.0', + 'claude-opus-4-5', 'claude-opus-4.5', 'claude-opus-4-5-20251101', + 'claude-opus-4-6', 'claude-opus-4.6', 'claude-opus-4.7', + 'claude-opus-4-7@1.0.0', 'claude-opus-4-8', 'claude-opus-4.8', 'claude-opus-5', + 'claude-future-version', + ]) { + assert.strictEqual(agentHostModelSupportsToolSearch(id), true, id); + } + }); + + test('rejects pre-4.5 models, including date-suffixed ones', () => { + for (const id of [ + 'claude-sonnet-4-20250514', 'claude-sonnet-4', + 'claude-opus-4', 'claude-opus-4-20250514', + 'claude-opus-4-1', 'claude-opus-4.1', 'claude-opus-4-1-20250805', + ]) { + assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); + } + }); + + test('rejects Haiku and legacy Claude families', () => { + for (const id of ['claude-haiku-4-5', 'claude-haiku-4.5', 'claude-3-5-sonnet-20241022', 'claude-3-opus']) { + assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); + } + }); + + test('rejects OpenAI model families due to an SDK issue', () => { + for (const id of ['gpt-5.4', 'gpt-5.5', 'gpt-5-4', 'gpt-5-5', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { + assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); + } + }); + + test('rejects suffixed GPT variants and other non-Claude models', () => { + for (const id of ['gpt-5', 'gpt-5.3', 'gpt-5.4-mini', 'gpt-5.4-preview', 'gpt-5.5-preview', 'gpt5.5-preview', 'gpt-5-6-luna', 'gpt-6', 'gemini-2.5-pro', '']) { + assert.strictEqual(agentHostModelSupportsToolSearch(id), false, id); + } + assert.strictEqual(agentHostModelSupportsToolSearch(undefined), false); + }); + }); + + suite('constants', () => { + test('runtime / client tool-search names are distinct and stable', () => { + assert.strictEqual(RUNTIME_TOOL_SEARCH_TOOL_NAME, 'tool_search_tool'); + assert.strictEqual(CLIENT_TOOL_SEARCH_REFERENCE_NAME, 'toolSearch'); + assert.notStrictEqual(RUNTIME_TOOL_SEARCH_TOOL_NAME, CLIENT_TOOL_SEARCH_REFERENCE_NAME); + }); + + test('non-deferred client allowlist holds the core VS Code tools, not the search tool', () => { + assert.ok(NON_DEFERRED_CLIENT_TOOL_NAMES.has('runTests')); + assert.ok(NON_DEFERRED_CLIENT_TOOL_NAMES.has('rename')); + assert.ok(NON_DEFERRED_CLIENT_TOOL_NAMES.has('usages')); + assert.strictEqual(NON_DEFERRED_CLIENT_TOOL_NAMES.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME), false); + }); + }); +}); diff --git a/src/vs/platform/localTranscription/common/localTranscription.ts b/src/vs/platform/localTranscription/common/localTranscription.ts index 8c696d940f20e9..2b76ac0457c986 100644 --- a/src/vs/platform/localTranscription/common/localTranscription.ts +++ b/src/vs/platform/localTranscription/common/localTranscription.ts @@ -67,7 +67,7 @@ export interface ILocalTranscriptionResult { * native runtime), which handles decoding, VAD and endpointing internally; the * default model is NVIDIA's `nemotron-speech-streaming-en-0.6b` streaming RNN-T * (the model the GitHub Copilot app ships for dictation). The model is chosen by - * the `chat.speechToText.model` setting. Runs in a utility process. A single + * the `dictation.model` setting. Runs in a utility process. A single * transcription session is active at a time (dictation is a singleton in the * renderer). * diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index e3cd178adecec4..dff9b52d79b030 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -13,7 +13,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IV8Profile } from '../../profiling/common/profiling.js'; import { AuthInfo, Credentials } from '../../request/common/request.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; -import { IColorScheme, IOpenedAuxiliaryWindow, IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IPoint, IRectangle, IWindowOpenable } from '../../window/common/window.js'; +import { AgentsWindowOpenSource, IColorScheme, IOpenedAuxiliaryWindow, IOpenedMainWindow, IOpenEmptyWindowOptions, IOpenWindowOptions, IPoint, IRectangle, IWindowOpenable } from '../../window/common/window.js'; export interface IToastOptions { readonly id: string; @@ -40,6 +40,12 @@ export type INativeZipFile = | { readonly path: string; readonly contents: string } | { readonly path: string; readonly source: URI; readonly size: number }; +export interface IOpenAgentsWindowOptions { + readonly folderUri?: UriComponents; + readonly sessionResource?: UriComponents; + readonly source?: AgentsWindowOpenSource; +} + export interface ICPUProperties { model: string; speed: number; @@ -225,7 +231,7 @@ export interface ICommonNativeHostService { openWindow(options?: IOpenEmptyWindowOptions): Promise; openWindow(toOpen: IWindowOpenable[], options?: IOpenWindowOptions): Promise; - openAgentsWindow(options?: { folderUri?: UriComponents; sessionResource?: UriComponents }): Promise; + openAgentsWindow(options?: IOpenAgentsWindowOptions): Promise; /** * Registers this window's set of system-wide (OS global) keybindings with the main process, diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 165f259b218989..f09c80af27d1c8 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -15,7 +15,7 @@ import { matchesSomeScheme, Schemas } from '../../../base/common/network.js'; import { dirname, join, posix, resolve, win32 } from '../../../base/common/path.js'; import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; import { AddFirstParameterToFunctions, hasKey } from '../../../base/common/types.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import { virtualMachineHint } from '../../../base/node/id.js'; import { Promises, SymlinkSupport } from '../../../base/node/pfs.js'; import { findFreePort, isPortFree } from '../../../base/node/ports.js'; @@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; import { IGlobalKeybindingsMainService } from '../../globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; @@ -315,12 +315,12 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }, options); } - async openAgentsWindow(windowId: number | undefined, options?: { folderUri?: UriComponents; sessionResource?: UriComponents }): Promise { + async openAgentsWindow(windowId: number | undefined, options?: IOpenAgentsWindowOptions): Promise { const windows = await this.windowsMainService.openAgentsWindow({ context: OpenContext.API, contextWindowId: windowId, cli: this.environmentMainService.args, - }, options?.folderUri ? URI.revive(options.folderUri) : undefined, options?.sessionResource ? URI.revive(options.sessionResource) : undefined); + }, options?.folderUri ? URI.revive(options.folderUri) : undefined, options?.sessionResource ? URI.revive(options.sessionResource) : undefined, options?.source); if (windows.length > 0) { windows[0].focus(); } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 9644b1f82fd581..ecb2a672e90499 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -329,6 +329,10 @@ overflow: hidden; } +.quick-input-list .monaco-list-row.focused .quick-input-list-label-meta { + opacity: 1; +} + /* preserve list-like styling instead of tree-like styling */ .quick-input-list .monaco-list .monaco-list-row .monaco-highlighted-label .highlight { font-weight: bold; diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 2187c2fb6c903f..1687e08393f209 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -104,6 +104,33 @@ export function isOpenedAuxiliaryWindow(candidate: IOpenedMainWindow | IOpenedAu export interface IOpenEmptyWindowOptions extends IBaseOpenWindowsOptions { } +export const enum AgentsWindowOpenSource { + CommandPalette = 'commandPalette', + KeyboardShortcut = 'keyboardShortcut', + TitleBar = 'titleBar', + ChatTitleBar = 'chatTitleBar', + ChatHandoff = 'chatHandoff', + Banner = 'banner', + CommandLine = 'commandLine', + Unknown = 'unknown', +} + +export function isAgentsWindowOpenSource(value: unknown): value is AgentsWindowOpenSource { + switch (value) { + case AgentsWindowOpenSource.CommandPalette: + case AgentsWindowOpenSource.KeyboardShortcut: + case AgentsWindowOpenSource.TitleBar: + case AgentsWindowOpenSource.ChatTitleBar: + case AgentsWindowOpenSource.ChatHandoff: + case AgentsWindowOpenSource.Banner: + case AgentsWindowOpenSource.CommandLine: + case AgentsWindowOpenSource.Unknown: + return true; + default: + return false; + } +} + export type IWindowOpenable = IWorkspaceToOpen | IFolderToOpen | IFileToOpen; export interface IBaseWindowOpenable { diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 1f407c25085dc7..6e312e95377c44 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -17,7 +17,7 @@ import { ServicesAccessor, createDecorator } from '../../instantiation/common/in import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; -import { IOpenEmptyWindowOptions, IWindowOpenable, IWindowSettings, TitlebarStyle, WindowMinimumSize, hasNativeTitlebar, useNativeFullScreen, useWindowControlsOverlay, zoomLevelToZoomFactor } from '../../window/common/window.js'; +import { AgentsWindowOpenSource, IOpenEmptyWindowOptions, IWindowOpenable, IWindowSettings, TitlebarStyle, WindowMinimumSize, hasNativeTitlebar, useNativeFullScreen, useWindowControlsOverlay, zoomLevelToZoomFactor } from '../../window/common/window.js'; import { ICodeWindow, IWindowState, WindowMode, defaultWindowState } from '../../window/electron-main/window.js'; export const IWindowsMainService = createDecorator('windowsMainService'); @@ -41,7 +41,7 @@ export interface IWindowsMainService { openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; - openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI): Promise; + openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource): Promise; sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index aa4b8cad096413..d8428b529b26fa 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -37,7 +37,7 @@ import product from '../../product/common/product.js'; import { IProtocolMainService } from '../../protocol/electron-main/protocol.js'; import { getRemoteAuthority } from '../../remote/common/remoteHosts.js'; import { IStateService } from '../../state/node/state.js'; -import { IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings } from '../../window/common/window.js'; +import { AgentsWindowOpenSource, IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings } from '../../window/common/window.js'; import { CodeWindow } from './windowImpl.js'; import { IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; import { findWindowOnExtensionDevelopmentPath, findWindowOnFile, findWindowOnWorkspaceOrFolder } from './windowsFinder.js'; @@ -292,7 +292,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } - async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI): Promise { + async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI, sessionResource?: URI, source?: AgentsWindowOpenSource): Promise { this.logService.trace('windowsManager#openAgentsWindow'); // Open in a new browser window with the agent sessions workspace @@ -302,8 +302,9 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic // session resource to open. The handler in the agents window sequences // them (folder → open session) so the session-open doesn't race the // folder-resolve. - if ((folderUri || sessionResource) && windows.length > 0) { - windows[0].sendWhenReady('vscode:selectAgentsFolder', CancellationToken.None, folderUri?.toJSON(), sessionResource?.toJSON()); + if (windows.length > 0) { + const openSource = source ?? (openConfig.cli.agents ? AgentsWindowOpenSource.CommandLine : AgentsWindowOpenSource.Unknown); + windows[0].sendWhenReady('vscode:selectAgentsFolder', CancellationToken.None, folderUri?.toJSON(), sessionResource?.toJSON(), openSource); } return windows; diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 94439f27c59001..b6708f9575c375 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -122,7 +122,7 @@ Remote agent hosts can also register **external harnesses** dynamically. Each re The Plugins section renders remote harness `itemProvider` entries with `type: 'plugin'` directly. This is separate from the prompt-file pipeline used for Agents, Skills, Instructions, Prompts, and Hooks. -Local plugin discovery is aggregated by `IAgentPluginService` from priority-ordered discovery providers: configured paths, VS Code marketplace installs, extension-contributed plugins, and Copilot CLI installs. Each provider reports `undefined` until its initial scan completes; the service waits for every provider to complete before exposing plugins. Once ready, plugins are canonicalized into collision groups so the same plugin discovered from multiple install roots (for example a VS Code marketplace install and a Copilot CLI direct install) remains visible but only the highest-priority copy is enabled by default. Enabling one copy disables the other copies in the same collision group. +Local plugin discovery is aggregated by `IAgentPluginService` from priority-ordered discovery providers: configured paths, VS Code marketplace installs, extension-contributed plugins, and Copilot CLI installs. Each provider reports `undefined` until its initial scan completes; the service waits for every provider to complete before exposing plugins. Once ready, plugins are canonicalized into collision groups so the same plugin discovered from multiple install roots (for example a VS Code marketplace install and a Copilot CLI direct install) remains visible but only the highest-priority copy is enabled by default. Enabling one copy disables the other copies in the same collision group. Uninstalling a plugin discovered through `chat.pluginLocations` removes its configuration entry without deleting the plugin folder; users can open the folder separately when they want to remove its files. Agent Plugins use the portable Agent Plugin layout alongside the existing Copilot, Claude, and Open Plugin adapters. A package is recognized when root `plugin.json` declares an `agent-plugins.org` plugin schema. Compatible schema revisions are accepted, malformed optional metadata is ignored, and a recognized manifest takes precedence over `.plugin/plugin.json`. Agent Plugins contribute only immediate-child `skills/*/SKILL.md` skills and root `mcp.json` servers. They ignore legacy custom paths, inline components, `.mcp.json`, root `SKILL.md`, commands, agents, rules, hooks, LSP servers, and output styles. diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 7d2b5fd32e5779..ced990be17176b 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -118,7 +118,9 @@ When multiple remote agent hosts are known, a dropdown pill in the left toolbar ### Blocked Sessions (Center) -When at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to a light orange "N sessions require input" state (orange label with a subtle background and border), and blinks gently twice whenever a newly blocked occurrence appears. A session counts as blocked when it needs input, or - while not in progress - has failing CI checks. Pull request comments do not make a session blocked. Raw detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`), which reuses the shared, background-polled GitHub CI models and identifies CI occurrences by commit. The widget refines this into what the title bar surfaces via the `BlockedSessionsIndicatorModel` (`blockedSessionsIndicatorModel.ts`) it instantiates: it acknowledges the current occurrence when the user views the session or explicitly ignores it, applies optimistic approval dismissals, classifies the homogeneous requires-input reason (for the specific message), builds the pill label, and decides when the attention blink plays. Acknowledgement lasts only for that input request or CI failure; a later approval, a new failing commit, or an unblock-to-block transition surfaces the session again. Clicking the widget opens those sessions rendered exactly like the sessions list but flat - no sections, groups or workspace headers - via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. Its rows use `Menus.BlockedSessionsItem` instead of the main session-item toolbar menu and contribute **Ignore Input Needed** / **Ignore CI Failure** actions with the same bell-slash icon. When no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven by the `BlockedSessionsIndicatorModel`'s `blockedSessions` observable. +When at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to a light orange "N sessions require input" state (orange label with a subtle background and border), and blinks gently twice whenever a newly blocked occurrence appears. A session counts as blocked when it needs input, or - while not in progress - has failing CI checks. Pull request comments do not make a session blocked. Raw detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`), which reuses the shared, background-polled GitHub CI models and identifies CI occurrences by commit. The widget refines this into what the title bar surfaces via the `BlockedSessionsIndicatorModel` (`blockedSessionsIndicatorModel.ts`) it instantiates: it acknowledges the current occurrence when the user views the session or explicitly ignores it, applies optimistic approval dismissals, classifies the homogeneous requires-input reason (for the specific message), builds the pill label, and decides when the attention blink plays. Acknowledgement lasts only for that input request or CI failure; a later approval, a new failing commit, or an unblock-to-block transition surfaces the session again. Clicking the widget opens those sessions rendered exactly like the sessions list but flat - no sections, groups or workspace headers - via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. Its header toolbar offers **Show All Sessions**, **Ignore All Input Needed**, and a trailing **Close** action whose hover shows the `Escape` keybinding. Its rows use `Menus.BlockedSessionsItem` instead of the main session-item toolbar menu and contribute **Ignore Input Needed** / **Ignore CI Failure** actions with the same bell-slash icon. When no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven by the `BlockedSessionsIndicatorModel`'s `blockedSessions` observable. + +Approval acknowledgement must use the pending tool call's stable id, not the approval model's load-time timestamp. Opening the new-session view can dispose and later reload the chat model; a timestamp-based id would make the same approval appear blocked again after that reload. ### Account Widget (Right) @@ -167,6 +169,8 @@ The chat view inside a session view is one of three kinds (`ChatViewKind` in [br Concrete implementations live under `contrib/chat/` and are obtained via `IChatViewFactory` so the `browser/` layer doesn't have to import contrib code. +The `NewChatView` input uses the control-tier corner radius for its send button, so the primary action is a rounded square in both desktop and phone layouts rather than a circular control. The focus outline follows the same control-tier shape. + `ChatView` mounts session input banners directly above the chat input. The CI failures banner uses the orange accent for the card border/icon and for the primary Fix Checks button background/border. When a `ChatView` loads its chat model (`acquireOrLoadSession`), it surfaces progress on **its own** progress bar, pinned to the top of that grid leaf. This mirrors how each editor group owns its `ProgressBar` (see `EditorGroupView`): the bar is created by the leaf host `AbstractChatView`, wrapped in a `ScopedProgressIndicator` (reused from `vs/workbench`) with an always-active scope, and driven via `AbstractChatView.showProgressWhile(promise, delay)`. Concurrent loads in other visible sessions each show their own progress instead of competing for a single part-wide bar, and overlapping loads on the same leaf are joined by the indicator so the bar only hides once all have settled. A short delay avoids flashing the bar for fast (cached) loads. @@ -179,7 +183,7 @@ Key invariants: - **Multiple visible sessions, one active.** The Sessions Part may show one or several session views side-by-side. Exactly one of them is the **active** session at any time — the one that receives keyboard focus, drives context keys, and is reflected in the titlebar / sidebar / auxiliary bar. - **Active session is observable.** Visible and active sessions are exposed as `IObservable` and `IObservable` respectively. `SessionsService` (services) owns the single reconcile autorun: it subscribes once and calls `SessionsPartService.updateVisibleSessions(visible, active)`, which forwards to `SessionsPart`. The part is a **passive renderer** — it injects neither the model nor the view. -- **One slot may be the "empty" slot.** A visible session of `undefined` represents a not-yet-created chat — its session view renders the `'newSession'` chat view (workspace picker + input). At most **one** slot may be `undefined` at any time. When the user submits its first message, the placeholder transitions into a real session and the grid slot is preserved. +- **One slot may be the "empty" slot.** A visible session of `undefined` represents a not-yet-created chat — its session view renders the `'newSession'` chat view (workspace picker + input). The workspace and harness pickers are capped at 400px and 200px, respectively, so long labels truncate without crowding out the other controls. At most **one** slot may be `undefined` at any time. When the user submits its first message, the placeholder transitions into a real session and the grid slot is preserved. - **Sticky vs non-sticky.** The visibility model marks each slot as sticky (user-pinned) or non-sticky. Non-sticky slots are recycled when a new session opens; sticky slots are preserved. The empty slot is always non-sticky. This lets the user pin a session to keep it visible while still flowing through other sessions in the remaining slots. - **Slot reuse on reconcile.** `SessionsPart.updateVisibleSessions` grows or shrinks its internal pool of `SessionView`s to match the visible count, then rebinds each surviving slot to its session by position via `SessionView.openSession(session)`. Slots are never destroyed and recreated for an existing session — only added at the right or popped from the right when the count changes. - **Focus promotes to active.** Focus-in or pointer-down on a non-placeholder session view promotes that session to active (via `SessionsPartService.onDidFocusSession` → `ISessionsService.setActive`, which updates the active visible slot — and hence `ISessionsService.activeSession`). diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index b339c5518fe44e..56d6de7d4ff0de 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -609,6 +609,12 @@ Providers may fire `onDidReplaceSession` when a temporary (untitled) session is Provider add notifications are authoritative upserts. A provisional `listSessions()` entry may already be cached when the backend publishes its materialized project and working directory, so providers update the existing session adapter in place and report it as changed rather than replacing its identity. +### First-Time Window-Open Telemetry + +Editor entry points pass an `AgentsWindowOpenSource` through `INativeHostService.openAgentsWindow` and the `vscode:selectAgentsFolder` startup handoff. The source distinguishes command-palette, keyboard, title-bar, chat-title, handoff-tip, discovery-banner, and command-line opens without collecting workspace or session identifiers. + +On the first handoff in a window, `SelectAgentsFolderContribution` starts `SessionsWindowOpenTelemetry` only when the application-scoped `TOTAL_SESSIONS_KEY` counter is still zero. The collector freezes whether the settled initial view is a workspace-preselected new-session view (or records `undefined` when a created session is visible), reads whether the initial setup flow showed its sign-in dialog, and emits `agents/firstTimeWindowOpen` once. A close within three minutes includes `windowCloseDurationMs`; otherwise the event emits at the three-minute boundary with that field undefined. + ### Automation Run Lifecycle `AutomationRunner` exposes separate dispatch and lifecycle promises. It resolves diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 53dc6d0471e706..14d562f4fe55b5 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -574,6 +574,12 @@ min-width: max-content; } +/* Keep phone chips unconstrained so long labels scroll horizontally instead of clipping. */ +.agent-sessions-workbench.phone-layout .new-chat-widget-container .sessions-chat-session-type-picker, +.agent-sessions-workbench.phone-layout .new-chat-widget-container .new-chat-session-type-picker-host { + max-width: none; +} + /* Chip-pill style for each picker / toolbar action item in the chip row. * Each chip is a rounded, touch-friendly pill with a subtle background, * styled to match the iOS reference: compact padding, no visible border, diff --git a/src/vs/sessions/browser/sessionsSetUpService.ts b/src/vs/sessions/browser/sessionsSetUpService.ts index d3cedab3dec2e0..efa5703b80480b 100644 --- a/src/vs/sessions/browser/sessionsSetUpService.ts +++ b/src/vs/sessions/browser/sessionsSetUpService.ts @@ -41,6 +41,7 @@ export const ISessionsSetUpService = createDecorator('ses export interface ISessionsSetUpService { readonly _serviceBrand: undefined; + readonly initialSignInDialogShown: boolean; /** * Resolves when the welcome/setup flow has completed (or immediately * if it is not currently active). Use this to defer work until after @@ -69,12 +70,14 @@ class SessionsSetUpWidget extends Disposable { private readonly dialogRef = this._register(new MutableDisposable()); private readonly watcherRef = this._register(new MutableDisposable()); + private _initialSetupFlow = true; // Non-service params must come before @-decorated service params constructor( private readonly onCompleted: () => void, private readonly serviceWhenSetupDone: () => Promise, private readonly serviceMarkDone: () => void, + private readonly onInitialSignInDialogShown: () => void, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IProductService private readonly productService: IProductService, @IStorageService private readonly storageService: IStorageService, @@ -105,7 +108,7 @@ class SessionsSetUpWidget extends Disposable { } if (isWeb) { - this._checkWebAuth(); + void this._checkWebAuth().finally(() => this._initialSetupFlow = false); this._watchWebAuth(); return; } @@ -113,9 +116,9 @@ class SessionsSetUpWidget extends Disposable { const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); if (isFirstLaunch) { - this._showWelcome(true); + void this._showWelcome(true).finally(() => this._initialSetupFlow = false); } else { - this._watchSignInState(); + void this._watchSignInState().finally(() => this._initialSetupFlow = false); } } @@ -300,6 +303,9 @@ class SessionsSetUpWidget extends Disposable { } private async _showSignInDialog(): Promise { + if (this._initialSetupFlow) { + this.onInitialSignInDialogShown(); + } this.logService.info('[sessions welcome] Showing sign-in dialog'); const signingInDialogRef = new MutableDisposable(); @@ -401,6 +407,11 @@ export class SessionsSetUpService extends Disposable implements ISessionsSetUpSe private readonly _initPromise: Promise; private readonly _welcomeDoneDeferred = new DeferredPromise(); + private _initialSignInDialogShown = false; + + get initialSignInDialogShown(): boolean { + return this._initialSignInDialogShown; + } constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -417,7 +428,8 @@ export class SessionsSetUpService extends Disposable implements ISessionsSetUpSe SessionsSetUpWidget, () => this._welcomeDoneDeferred.complete(), () => this.whenSetupDone(), - () => this.markDone() + () => this.markDone(), + () => this._initialSignInDialogShown = true )); } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index c34450fd328d31..44f3d3a7e687d4 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -249,10 +249,11 @@ align-items: center; justify-content: center; flex-shrink: 0; + margin-left: var(--vscode-spacing-size40); position: relative; width: 22px; height: 22px; - border-radius: 50%; + border-radius: var(--vscode-cornerRadius-small); } .sessions-chat-send-button .monaco-button { @@ -263,7 +264,7 @@ height: 22px; min-width: 22px; padding: 0; - border-radius: 50%; + border-radius: var(--vscode-cornerRadius-small); color: var(--vscode-icon-foreground); background: transparent; border: none; @@ -295,9 +296,8 @@ .monaco-workbench .sessions-chat-send-button .monaco-button.codicon[class*='codicon-']::before, .monaco-workbench .sessions-chat-send-button .monaco-button .codicon[class*='codicon-']::before { - /* Optical alignment: nudge arrow glyph 1px left to visually center it. */ display: inline-block; - transform: translateX(-0.5px); + transform: translateY(0.5px); } /* Ensure no underline / link decoration ever shows under the codicon glyph diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInputMobile.css b/src/vs/sessions/contrib/chat/browser/media/chatInputMobile.css index 13c9876549c2a4..2a72e64dfa1c02 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInputMobile.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInputMobile.css @@ -11,7 +11,7 @@ * without needing `!important`. * * Scope: - * - Send button: 36×36 circular accent button + * - Send button: 36x36 rounded-square accent button * - Editor: bump vertical padding so the empty new-chat input area * matches the running-session input height (the running-session * `interactive-input-editor` natively renders ~64px tall while the @@ -32,21 +32,21 @@ padding-bottom: 6px; } -/* The send button on phone grows to a 36×36 circle with the same solid +/* The send button on phone grows to a 36x36 rounded square with the same solid * primary button fill as the desktop button. The wrapper and inner Button * widget both grow so the focus outline (drawn on the wrapper) and * the click target (the wrapper's `:has(...)` rules) align with the - * circular pill. */ + * button surface. */ .agent-sessions-workbench.phone-layout .sessions-chat-send-button { width: 36px; height: 36px; - border-radius: 50%; + border-radius: var(--vscode-cornerRadius-small); } .agent-sessions-workbench.phone-layout .sessions-chat-send-button .monaco-button { width: 36px; height: 36px; min-width: 36px; - border-radius: 50%; + border-radius: var(--vscode-cornerRadius-small); margin-bottom: 18px; } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 7d021fe32e28ab..9d7885b9afc083 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -239,7 +239,7 @@ justify-content: center; } -/* Pickers row - two equal halves */ +/* Pickers row */ .session-workspace-picker { display: flex; flex-direction: row; @@ -252,6 +252,15 @@ padding: 0; } +.new-chat-widget-container .new-session-workspace-picker-container .sessions-chat-workspace-picker { + max-width: 400px; +} + +.new-chat-widget-container .sessions-chat-session-type-picker, +.new-chat-widget-container .new-chat-session-type-picker-host { + max-width: 200px; +} + .session-workspace-picker:empty { display: none; } diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index 34792c5a0db1ff..9d62da4a45b218 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable } from '../../../../base/common/observable.js'; import { localize2 } from '../../../../nls.js'; import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -22,6 +22,7 @@ import { SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionModelSelectionModel } from './sessionModelSelectionModel.js'; import { INewChatModelPickerService } from './newChatModelPicker.js'; import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; +import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; /** * The sessions-core model picker. Unlike the previous per-provider pickers, @@ -36,6 +37,7 @@ export class ModelPicker extends Disposable { private readonly _delegate: IModelPickerDelegate; private readonly _modelPicker: ModelPickerActionItem; + private readonly _renderDisposables = this._register(new DisposableStore()); private _container: HTMLElement | undefined; constructor( @@ -116,8 +118,12 @@ export class ModelPicker extends Disposable { } render(container: HTMLElement): void { + this._renderDisposables.clear(); this._container = container; this._modelPicker.render(container); + this._renderDisposables.add(markOnboardingTarget(container, 'sessions.newSession.modelPicker', { + open: () => this._modelPicker.openModelPicker(), + })); this._updatePickerState(); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 3206046f134c46..29d96c39972d12 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -85,9 +85,10 @@ import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../ import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { runDictationShortcut } from '../../../../workbench/contrib/chat/browser/actions/chatSpeechToTextActions.js'; +import { notifyDictationSubmitted } from '../../../../workbench/contrib/chat/browser/speechToText/dictationSession.js'; import { combineVoiceInput } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceInputUtils.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { DictationDownloadRing } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; +import { DictationDownloadRing, getDictationPreparingLabel } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; @@ -752,8 +753,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); - // Dictation (speech-to-text) mic button. Shares the STT service, mic - // device, and gating (on-device support + `chat.speechToText.enabled`) + // Dictation mic button. Shares the STT service, mic + // device, and gating (backend support + `dictation.enabled`) // with the main chat input; inserts the transcript into this composer's // editor. Placed before the voice controls so dictation leads the // mic-related group. @@ -813,23 +814,33 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const downloadRing = this._register(new MutableDisposable()); const renderState = () => { const preparing = sttService.isPreparingModel; - const recording = sttService.state !== ChatSpeechToTextState.Idle; + // Only the active Recording state should read as "recording" (filled + // mic). Once the user stops, the service enters Transcribing while it + // waits for the final transcript (up to a few seconds on the cloud + // backend); during that the mic must already read as idle, matching + // the chat toolbar which flips as soon as recording stops. + const recording = sttService.state === ChatSpeechToTextState.Recording; + const active = sttService.state !== ChatSpeechToTextState.Idle; dom.clearNode(button); downloadRing.clear(); if (preparing) { - // First-use only: render a download icon wrapped by a determinate - // progress ring instead of a plain spinner, matching the chat - // toolbar, so the model download reads as progress rather than a hang. - dom.append(button, renderIcon(Codicon.cloudDownload)); - downloadRing.value = new DictationDownloadRing(button, sttService); + // First-use only. The on-device backend downloads a model, so + // render a download icon wrapped by a progress ring; the cloud + // backend just connects, so render a plain spinner instead. + if (sttService.currentBackend === 'mai') { + dom.append(button, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); + } else { + dom.append(button, renderIcon(Codicon.cloudDownload)); + downloadRing.value = new DictationDownloadRing(button, sttService); + } } else { - dom.append(button, renderIcon(recording ? Codicon.stopCircle : Codicon.mic)); + dom.append(button, renderIcon(recording ? Codicon.micFilled : Codicon.mic)); } button.classList.toggle('recording', recording && !preparing); button.classList.toggle('preparing', preparing); button.ariaLabel = preparing - ? localize('sessionsStt.preparing', "Preparing Speech to Text Model…") - : (recording ? stopLabel : micLabel); + ? getDictationPreparingLabel(sttService) + : (active ? stopLabel : micLabel); }; renderState(); this._register(sttService.onDidChangeState(renderState)); @@ -849,7 +860,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation updateVisibility(); })); this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('chat.speechToText.enabled')) { + // Both the enable kill-switch and the model selection can change + // availability (e.g. an unsupported on-device platform becomes + // configured when switching to the cloud backend). + if (e.affectsConfiguration('dictation.enabled') || e.affectsConfiguration('dictation.model')) { updateVisibility(); } })); @@ -883,8 +897,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } /** - * Toggle on-device dictation into this composer's editor, honoring the - * tap-vs-hold `chat.speechToText.mode` setting. Shared by the mic button and + * Toggle dictation into this composer's editor. Shared by the mic button and * the Cmd/Ctrl+I chord ({@link TOGGLE_DICTATION_COMMAND_ID}); the shared * Dictate action can't target this composer since it isn't an `IChatWidget`. */ @@ -895,7 +908,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation await runDictationShortcut({ speechService: this.chatSpeechToTextService, keybindingService: this.keybindingService, - configurationService: this.configurationService, logService: this.logService, }, TOGGLE_DICTATION_COMMAND_ID, this._editor); } @@ -977,6 +989,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation return; } + // Measure any pending dictation accuracy against the text being sent, + // before the editor is cleared below. + notifyDictationSubmitted(this._editor); + const session = this.options.session.get(); if (session && await this.chatSubmitRequestHandlerService.tryHandle({ sessionResource: session.resource, diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index ac2214eb60bc28..c951db245efeee 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -339,7 +339,9 @@ export class SessionTypePicker extends Disposable { this._triggerElement = trigger; // Onboarding spotlight target — id is referenced by the "new session view" // tour in vs/sessions/contrib/onboardingTours. - this._renderDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.harnessPicker')); + this._renderDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.harnessPicker', { + open: () => this._showPicker(), + })); this._updateTriggerLabel(); this._renderDisposables.add(Gesture.addTarget(trigger)); diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 45a3159ef982a4..900afecf78f045 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -275,7 +275,9 @@ export class WorkspacePicker extends Disposable { this._renderTriggerLabel(trigger); // Onboarding spotlight target — id is referenced by the "new session" tour // in vs/sessions/contrib/onboardingTours. - triggerDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker')); + triggerDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker', { + open: () => this.showPicker(false, trigger), + })); triggerDisposables.add(touch.Gesture.addTarget(trigger)); [dom.EventType.CLICK, touch.EventType.Tap].forEach(eventType => { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b548cf89ac34bb..6dfdb6d5c0b530 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -31,7 +31,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); - content.push(localize('sessionsChat.dictation', "When speech to text is configured, dictate your message into the input using on-device speech to text{0}. Tap to start and stop, or hold to dictate only while pressed.", '')); + content.push(localize('sessionsChat.dictation', "When dictation is configured, dictate your message into the input{0}. Tap to start and stop, or hold to dictate only while pressed.", '')); content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '')); content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index bff94fc82c3140..e5b889b48dc119 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -5,7 +5,7 @@ import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; import { AgentHostByokLmHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.js'; @@ -21,10 +21,17 @@ import { ISessionsSetUpService } from '../../../browser/sessionsSetUpService.js' import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { SessionStatus } from '../../../services/sessions/common/session.js'; import { SessionsCopilotConfigSlashSubmitHandlerContribution } from '../browser/copilotConfigSlashSubmitHandler.js'; +import { AgentsWindowOpenSource, isAgentsWindowOpenSource } from '../../../../platform/window/common/window.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; +import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.selectAgentsFolder'; + private readonly _windowOpenTelemetry = this._register(new MutableDisposable()); + private _didHandleInitialWindowOpen = false; constructor( @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @@ -35,20 +42,64 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon @ISessionsSetUpService private readonly sessionsSetUpService: ISessionsSetUpService, @ILogService private readonly logService: ILogService, @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, + @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); const handleSelectAgentsFolder = (_: unknown, ...args: unknown[]) => { const folderUri = args[0] ? URI.revive(args[0] as UriComponents) : undefined; const sessionResource = args[1] ? URI.revive(args[1] as UriComponents) : undefined; + const source = isAgentsWindowOpenSource(args[2]) ? args[2] : AgentsWindowOpenSource.Unknown; this.logService.info(`[AgentsHandoff] IPC received: folderUri=${folderUri?.toString() ?? '(none)'} sessionResource=${sessionResource?.toString() ?? '(none)'}`); + this._startWindowOpenTelemetry(source); - this.handleOpenIntent(folderUri, sessionResource) + this._handleOpenIntentAndCaptureInitialState(folderUri, sessionResource) .catch(err => this.logService.error('[AgentsHandoff] handleOpenIntent failed', err)); }; ipcRenderer.on('vscode:selectAgentsFolder', handleSelectAgentsFolder); this._register({ dispose: () => ipcRenderer.removeListener('vscode:selectAgentsFolder', handleSelectAgentsFolder) }); } + private _startWindowOpenTelemetry(source: AgentsWindowOpenSource): void { + if (this._didHandleInitialWindowOpen) { + return; + } + this._didHandleInitialWindowOpen = true; + if (this.storageService.getNumber(TOTAL_SESSIONS_KEY, StorageScope.APPLICATION, 0) !== 0) { + return; + } + + this._windowOpenTelemetry.value = new SessionsWindowOpenTelemetry( + source, + () => this.sessionsSetUpService.initialSignInDialogShown, + () => this._getWindowOpenViewState(), + this.telemetryService, + this.lifecycleService, + ); + } + + private async _captureInitialWindowViewState(): Promise { + await this.lifecycleService.when(LifecyclePhase.Eventually); + this._windowOpenTelemetry.value?.captureInitialViewState(); + } + + private async _handleOpenIntentAndCaptureInitialState(folderUri: URI | undefined, sessionResource: URI | undefined): Promise { + try { + await this.handleOpenIntent(folderUri, sessionResource); + } finally { + await this._captureInitialWindowViewState(); + } + } + + private _getWindowOpenViewState(): ISessionsWindowOpenViewState { + const activeSession = this.sessionsService.activeSession.get(); + return { + workspacePreselected: !activeSession || !activeSession.isCreated.get() + ? activeSession?.workspace.get() !== undefined + : undefined, + }; + } + private async handleOpenIntent(folderUri: URI | undefined, sessionResource: URI | undefined): Promise { // Opening an existing session establishes its own workspace context, so // the folder selection is only needed for the folder-only handoff (no diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts new file mode 100644 index 00000000000000..c1ef4bcc59c925 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../base/common/async.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableValue } from '../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; +import { isOnboardingDeveloperModeEnabled, IOnboardingScenarioService } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { SessionWorkspacePickerVisibleContext } from '../../../common/contextkeys.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; +import { createNewSessionViewV2Tour, NEW_SESSION_VIEW_V2_TOUR_ID } from './tours/newSessionViewV2Tour.js'; + +class NewSessionViewV2TourContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.onboardingTours.newSessionViewV2Tour'; + + private static readonly MAX_REQUESTS_FOR_TOUR = 1; + private static readonly SETTLE_DELAY_MS = 1_000; + + private readonly _trigger = observableValue(this, false); + private readonly _pendingCheck = this._register(new MutableDisposable()); + + constructor( + @IOnboardingScenarioService private readonly onboardingScenarioService: IOnboardingScenarioService, + @ISessionsService private readonly sessionsService: ISessionsService, + @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + ) { + super(); + + this._register(onboardingScenarioRegistry.register(createNewSessionViewV2Tour(this._trigger))); + if (!this._isEligibleUser()) { + return; + } + + this._register(autorun(reader => { + if (this._isTriggeredOrShown()) { + this._pendingCheck.clear(); + return; + } + const activeSession = this.sessionsService.activeSession.read(reader); + const newSessionViewOpen = !activeSession || !activeSession.isCreated.read(reader); + const loggedIn = this.chatEntitlementService.entitlementObs.read(reader) !== ChatEntitlement.Unknown; + if (!newSessionViewOpen || !loggedIn) { + this._pendingCheck.clear(); + return; + } + if (!this._pendingCheck.value) { + this._armReadyCheck(); + } + })); + } + + private _isEligibleUser(): boolean { + if (isOnboardingDeveloperModeEnabled(this.configurationService, NEW_SESSION_VIEW_V2_TOUR_ID)) { + return true; + } + const requestsSent = this.storageService.getNumber(TOTAL_SESSIONS_KEY, StorageScope.APPLICATION, 0); + return requestsSent <= NewSessionViewV2TourContribution.MAX_REQUESTS_FOR_TOUR; + } + + private _isTriggeredOrShown(): boolean { + return this._trigger.get() || this.onboardingScenarioService.hasBeenShown(NEW_SESSION_VIEW_V2_TOUR_ID); + } + + private _armReadyCheck(): void { + const store = new DisposableStore(); + const check = () => { + if (this._isTriggeredOrShown() || !this._isReady()) { + return; + } + this._trigger.set(true, undefined); + this._pendingCheck.clear(); + }; + + const watchedKeys = new Set([SessionWorkspacePickerVisibleContext.key]); + store.add(this.contextKeyService.onDidChangeContext(event => { + if (event.affectsSome(watchedKeys)) { + check(); + } + })); + store.add(disposableTimeout(check, NewSessionViewV2TourContribution.SETTLE_DELAY_MS)); + this._pendingCheck.value = store; + } + + private _isReady(): boolean { + const activeSession = this.sessionsService.activeSession.get(); + const newSessionViewOpen = !activeSession || !activeSession.isCreated.get(); + return newSessionViewOpen + && this.chatEntitlementService.entitlement !== ChatEntitlement.Unknown + && this.contextKeyService.getContextKeyValue(SessionWorkspacePickerVisibleContext.key) === true; + } +} + +registerWorkbenchContribution2(NewSessionViewV2TourContribution.ID, NewSessionViewV2TourContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts index f3225c63170516..07e30afd343141 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/onboardingTours.contribution.ts @@ -9,4 +9,5 @@ // `vs/workbench/contrib/onboarding` and are booted from the workbench // contribution imported in the entry point. import './newSessionTourContribution.js'; +import './newSessionViewV2TourContribution.js'; import './newSessionViewTourContribution.js'; diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts index 72c43fd2730b84..68eae0b1ca465d 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts @@ -27,9 +27,8 @@ export const NEW_SESSION_TOUR_ID = 'sessions.onboarding.newSession'; /** * Shared "shown" persistence key for the new-session onboarding tours. The - * {@link createNewSessionTour} and `createNewSessionViewTour` variants teach the - * same new-session pickers, so they record their once-per-user state under this - * single key: once a user has seen either variant, neither runs again. + * The new-session tour variants teach the same setup flow, so they record their + * once-per-user state under this single key. * * The value matches {@link NEW_SESSION_TOUR_ID} so users who already saw the * original tour (state persisted under that id) are not shown the variant. diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts index 131fbc403e0d56..4997f022ed26f9 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts @@ -5,13 +5,13 @@ import { IObservable } from '../../../../../base/common/observable.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { EditorPartModalContext } from '../../../../../workbench/common/contextkeys.js'; +import { EditorPartModalVisibleContext } from '../../../../../workbench/common/contextkeys.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; import { localize } from '../../../../../nls.js'; import { NEW_SESSION_ONBOARDING_SEEN_KEY } from './newSessionTour.js'; -import { SessionHarnessPickerVisibleContext, SessionIsolationPickerVisibleContext, SessionWorkspacePickerVisibleContext } from '../../../../common/contextkeys.js'; +import { IsNewChatSessionContext, SessionHarnessPickerVisibleContext, SessionIsolationPickerVisibleContext, SessionWorkspacePickerVisibleContext } from '../../../../common/contextkeys.js'; /** * Spotlight steps that walk a brand-new user through the new-session view the @@ -62,6 +62,7 @@ const newSessionViewPayload: ISpotlightPayload = { description: localize('sessions.onboarding.newSessionView.workspace.description', "Choose between the folders and repositories you work in. Run multiple sessions at once in a single workspace, or across many."), placement: 'above', when: SessionWorkspacePickerVisibleContext, + allowTargetInteraction: true, }, { id: 'harnessPicker', @@ -70,6 +71,7 @@ const newSessionViewPayload: ISpotlightPayload = { description: localize('sessions.onboarding.newSessionView.harness.description', "Each has different strengths; choose what works best for your task and switch anytime."), placement: 'above', when: SessionHarnessPickerVisibleContext, + allowTargetInteraction: true, }, { id: 'isolation', @@ -78,6 +80,7 @@ const newSessionViewPayload: ISpotlightPayload = { description: localize('sessions.onboarding.newSessionView.isolation.description', "Use a worktree to work on multiple tasks in the same project without conflicts. Each task stays isolated, so you can experiment freely and safely."), placement: 'below', when: SessionIsolationPickerVisibleContext, + allowTargetInteraction: true, }, ], }; @@ -98,7 +101,7 @@ export function createNewSessionViewTour(signal: IObservable): IOnboard return { id: NEW_SESSION_VIEW_TOUR_ID, seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, - when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorPartModalContext.toNegated()), + when: ContextKeyExpr.and(ChatContextKeys.enabled, IsNewChatSessionContext, EditorPartModalVisibleContext.toNegated()), trigger: { kind: 'observable', signal }, priority: 100, experiment: NEW_SESSION_VIEW_EXPERIMENT, diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts new file mode 100644 index 00000000000000..606f1a8f48dd65 --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../../base/common/observable.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { localize } from '../../../../../nls.js'; +import { EditorPartModalVisibleContext } from '../../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; +import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; +import { ChatEntitlementContextKeys } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { IsNewChatSessionContext, SessionHasWorkspaceContext, SessionWorkspacePickerVisibleContext } from '../../../../common/contextkeys.js'; +import { NEW_SESSION_ONBOARDING_SEEN_KEY } from './newSessionTour.js'; + +export const NEW_SESSION_VIEW_V2_TOUR_ID = 'sessions.onboarding.newSessionViewV2'; + +const NEW_SESSION_VIEW_V2_EXPERIMENT = { + behaviorFlag: 'onb.newSessionViewV2.show', + assignmentContextIdFlag: 'onb.newSessionViewV2.id', +} as const; + +const WAIT_FOR_PICKER = { kind: 'wait', timeoutMs: 5_000 } as const; + +const newSessionViewV2Payload: ISpotlightPayload = { + steps: [ + { + id: 'workspacePicker', + targetId: 'sessions.newSession.workspacePicker', + title: localize('sessions.onboarding.newSessionViewV2.workspace.title', "Choose a workspace"), + description: localize('sessions.onboarding.newSessionViewV2.workspace.description', "A workspace is the folder or repository where your agent reads context and makes changes. Choose one so it can understand your project and work on the right files."), + placement: 'above', + when: ContextKeyExpr.and(SessionWorkspacePickerVisibleContext, SessionHasWorkspaceContext.toNegated()), + missingTarget: { kind: 'skip' }, + openTarget: true, + allowTargetInteraction: true, + advanceWhen: SessionHasWorkspaceContext, + }, + { + id: 'harnessPicker', + targetId: 'sessions.newSession.harnessPicker', + title: localize('sessions.onboarding.newSessionViewV2.harness.title', "Choose a harness"), + description: localize('sessions.onboarding.newSessionViewV2.harness.description', "A harness is the agent runtime that plans, uses tools, and carries out your task. Choose one based on the capabilities your work needs."), + placement: 'above', + missingTarget: WAIT_FOR_PICKER, + openTarget: false, + allowTargetInteraction: true, + }, + { + id: 'modelPicker', + targetId: 'sessions.newSession.modelPicker', + title: localize('sessions.onboarding.newSessionViewV2.model.title', "Choose a model"), + description: localize('sessions.onboarding.newSessionViewV2.model.description', "The model powers your agent's reasoning. Choose one based on the balance of speed and capability your task needs."), + placement: 'below', + missingTarget: WAIT_FOR_PICKER, + openTarget: false, + allowTargetInteraction: true, + }, + ], +}; + +/** Builds the interactive new-session view tour. */ +export function createNewSessionViewV2Tour(signal: IObservable): IOnboardingScenario { + return { + id: NEW_SESSION_VIEW_V2_TOUR_ID, + seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + IsNewChatSessionContext, + ChatEntitlementContextKeys.Entitlement.signedOut.toNegated(), + EditorPartModalVisibleContext.toNegated(), + ), + trigger: { kind: 'observable', signal }, + priority: 110, + experiment: NEW_SESSION_VIEW_V2_EXPERIMENT, + presentation: { + kind: SPOTLIGHT_PRESENTATION_KIND, + payload: newSessionViewV2Payload, + }, + }; +} diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts new file mode 100644 index 00000000000000..33af34ae444f8b --- /dev/null +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IsNewChatSessionContext, SessionHasWorkspaceContext } from '../../../../common/contextkeys.js'; +import { createNewSessionViewV2Tour, NEW_SESSION_VIEW_V2_TOUR_ID } from '../../browser/tours/newSessionViewV2Tour.js'; +import { NEW_SESSION_ONBOARDING_SEEN_KEY } from '../../browser/tours/newSessionTour.js'; +import { createNewSessionViewTour } from '../../browser/tours/newSessionViewTour.js'; + +suite('NewSessionViewV2Tour', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('defines the interactive workspace, harness, and model flow', () => { + const trigger = observableValue(disposables, false); + const scenario = createNewSessionViewV2Tour(trigger); + const steps = scenario.presentation.payload.steps; + + assert.deepStrictEqual({ + id: scenario.id, + seenKey: scenario.seenKey, + priority: scenario.priority, + experiment: scenario.experiment, + steps: steps.map(step => ({ + id: step.id, + targetId: step.targetId, + missingTarget: step.missingTarget, + openTarget: step.openTarget, + allowTargetInteraction: step.allowTargetInteraction, + advanceWhenWorkspaceSelected: step.advanceWhen === SessionHasWorkspaceContext, + })), + }, { + id: NEW_SESSION_VIEW_V2_TOUR_ID, + seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, + priority: 110, + experiment: { + behaviorFlag: 'onb.newSessionViewV2.show', + assignmentContextIdFlag: 'onb.newSessionViewV2.id', + }, + steps: [ + { + id: 'workspacePicker', + targetId: 'sessions.newSession.workspacePicker', + missingTarget: { kind: 'skip' }, + openTarget: true, + allowTargetInteraction: true, + advanceWhenWorkspaceSelected: true, + }, + { + id: 'harnessPicker', + targetId: 'sessions.newSession.harnessPicker', + missingTarget: { kind: 'wait', timeoutMs: 5_000 }, + openTarget: false, + allowTargetInteraction: true, + advanceWhenWorkspaceSelected: false, + }, + { + id: 'modelPicker', + targetId: 'sessions.newSession.modelPicker', + missingTarget: { kind: 'wait', timeoutMs: 5_000 }, + openTarget: false, + allowTargetInteraction: true, + advanceWhenWorkspaceSelected: false, + }, + ], + }); + }); + + test('requires the new-session view for both view tours', () => { + const trigger = observableValue(disposables, false); + const scenarios = [createNewSessionViewTour(trigger), createNewSessionViewV2Tour(trigger)]; + + assert.deepStrictEqual( + scenarios.map(scenario => scenario.when?.keys().includes(IsNewChatSessionContext.key)), + [true, true], + ); + }); + + test('keeps picker targets interactive in both view tours', () => { + const trigger = observableValue(disposables, false); + const scenarios = [createNewSessionViewTour(trigger), createNewSessionViewV2Tour(trigger)]; + + assert.deepStrictEqual( + scenarios.map(scenario => scenario.presentation.payload.steps.map(step => step.allowTargetInteraction)), + [[true, true, true], [true, true, true]], + ); + }); +}); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css index 5221910f525693..510ad7614ed0a6 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css @@ -85,7 +85,7 @@ white-space: nowrap; } -/* Outlined ghost style for secondary action buttons (e.g. "Reveal Checks"). */ +/* Outlined ghost style for secondary action buttons (e.g. "Reveal"). */ .session-input-banner .session-input-banner-action.secondary { border: var(--vscode-strokeThickness) solid var(--vscode-button-secondaryBackground, var(--vscode-button-border, var(--vscode-contrastBorder))); background: transparent; diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index 509ee90b44fe33..7bfb7352338c34 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -222,7 +222,7 @@ export class SessionInputBanners extends Disposable { run: () => state.debug ? undefined : this._executeCommand(FIX_CI_CHECKS_COMMAND_ID), }, { - label: localize('ci.revealChecks', "Reveal Checks"), + label: localize('ci.revealChecks', "Reveal"), run: () => { if (!state.debug) { void this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID); } }, }, ], @@ -252,7 +252,7 @@ export class SessionInputBanners extends Disposable { run: () => state.debug ? undefined : this._addressComments(state.sessionResource).catch(err => this.logService.error('[SessionInputBanners] Failed to address comments', err)), }, { - label: localize('comments.reveal', "Reveal Comments"), + label: localize('comments.reveal', "Reveal"), run: () => { if (!state.debug) { this._revealComment(state.sessionResource, state.firstCommentId); } }, }, ], diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts index 44602c64f1e292..1a6f1e48781890 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts @@ -55,7 +55,7 @@ function ciBanner(failed: number, completed: number, pending: number): ISessionI dismissTooltip: 'Hide for this session', actions: [ { label: 'Fix Checks', primary: true, run: () => console.log('Fix Checks') }, - { label: 'Reveal Checks', run: () => console.log('Reveal Checks') }, + { label: 'Reveal', run: () => console.log('Reveal Checks') }, ], dismiss: () => console.log('Dismiss CI banner'), }; @@ -72,7 +72,7 @@ function commentsBanner(count: number, kind: 'pr' | 'agent' | 'mixed'): ISession dismissTooltip: 'Hide for this session', actions: [ { label: 'Address Comments', primary: true, run: () => console.log('Address Comments') }, - { label: 'Reveal Comments', run: () => console.log('Reveal Comments') }, + { label: 'Reveal', run: () => console.log('Reveal Comments') }, ], dismiss: () => console.log('Dismiss comments banner'), }; diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts index 796ae405a291c1..bb4cab1eeb068c 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts @@ -285,6 +285,19 @@ export class BlockedSessionsIndicatorModel extends Disposable { this._ignoreOccurrence(blocked, this._getBlockOccurrenceId(blocked, undefined, this._ignoredBlockOccurrences.get().get(session.sessionId))); } + /** Ignore every blocked occurrence currently surfaced by the indicator. */ + ignoreAllSessions(): void { + const blockedSessions = this.blockedSessions.get(); + if (blockedSessions.length === 0) { + return; + } + const next = new Map(this._ignoredBlockOccurrences.get()); + for (const blocked of blockedSessions) { + next.set(blocked.session.sessionId, this._getBlockOccurrenceId(blocked, undefined, next.get(blocked.session.sessionId))); + } + this._ignoredBlockOccurrences.set(next, undefined); + } + /** * Remember that the user allowed this exact approval so the session drops out of * the blocked set immediately. diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts index 8ae8c4f35cc270..a529710c93a453 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts @@ -23,6 +23,12 @@ import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/br export const IGNORE_INPUT_NEEDED_COMMAND_ID = 'sessions.blockedSessions.ignoreInputNeeded'; export const IGNORE_CI_FAILURE_COMMAND_ID = 'sessions.blockedSessions.ignoreCIFailure'; +export interface IBlockedSessionsHeaderActionContext { + readonly showAllSessions: () => void; + readonly ignoreAllSessions: () => void; + readonly close: () => void; +} + /** Register the actions shown in blocked-session row toolbars. */ export function registerBlockedSessionsItemActions(): IDisposable { return combinedDisposable( @@ -67,6 +73,12 @@ export interface IBlockedSessionsListOptions { readonly ciFixModel?: ISessionCIFixModel; /** Ignores the session's current blocked occurrence. */ readonly onIgnoreSession: (session: ISession) => void; + /** Opens the full sessions picker. */ + readonly onShowAllSessions: () => void; + /** Ignores every blocked occurrence currently shown. */ + readonly onIgnoreAllSessions: () => void; + /** Closes the surrounding blocked-sessions overlay. */ + readonly onClose: () => void; } /** @@ -111,6 +123,16 @@ export class BlockedSessionsList extends Disposable { const headerActions = append(header, $('.agent-sessions-blocked-list-header-actions')); this._register(instantiationService.createInstance(MenuWorkbenchToolBar, headerActions, Menus.BlockedSessionsHeader, { hiddenItemStrategy: HiddenItemStrategy.NoHide, + menuOptions: { + arg: { + showAllSessions: options.onShowAllSessions, + ignoreAllSessions: () => { + options.onIgnoreAllSessions(); + status(localize('allInputNeededIgnored', "All current blocked sessions were ignored.")); + }, + close: options.onClose, + } satisfies IBlockedSessionsHeaderActionContext, + }, toolbarOptions: { primaryGroup: () => true }, telemetrySource: 'blockedSessionsList.header', })); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 83709df21f1bfe..af7da5966f8b9d 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -5,7 +5,7 @@ import './media/sessionsTitleBarWidget.css'; import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, getDomNodePagePosition, getWindow, isAncestor, reset } from '../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { localize } from '../../../../nls.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -34,7 +34,7 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; -import { BlockedSessionsList, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; +import { BlockedSessionsList, IBlockedSessionsHeaderActionContext, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; import { SessionActionFeedback } from './sessionActionFeedback.js'; import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; @@ -48,6 +48,9 @@ import { openSessionToTheSide } from './views/sessionsView.js'; */ const SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID = 'sessions.blockedSessions.showAllSessions'; +/** Internal command behind the blocked-sessions dropdown header's bulk-ignore action. */ +const IGNORE_ALL_INPUT_NEEDED_COMMAND_ID = 'sessions.blockedSessions.ignoreAllInputNeeded'; + /** * Internal command that dismisses the blocked-sessions dropdown. Bound to Escape * (scoped to {@link SessionsBlockedSessionsVisibleContext}) so the dropdown can @@ -56,13 +59,54 @@ const SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID = 'sessions.blockedSessions */ const HIDE_BLOCKED_SESSIONS_COMMAND_ID = 'sessions.blockedSessions.hide'; +/** Register the actions shown in the blocked-sessions dropdown header toolbar. */ +export function registerBlockedSessionsHeaderActions(): IDisposable { + return combinedDisposable( + MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { + command: { + id: SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, + title: localize('showAllSessions', "Show All Sessions"), + icon: Codicon.listSelection, + }, + group: 'navigation', + order: 1, + }), + MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { + command: { + id: IGNORE_ALL_INPUT_NEEDED_COMMAND_ID, + title: localize('ignoreAllInputNeeded', "Ignore All Input Needed"), + icon: Codicon.bellSlash, + }, + group: 'navigation', + order: 2, + }), + MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { + command: { + id: HIDE_BLOCKED_SESSIONS_COMMAND_ID, + title: localize('closeBlockedSessions', "Close"), + icon: Codicon.close, + }, + group: 'z_close', + order: 1, + }), + ); +} + +/** Register the commands invoked by the blocked-sessions header toolbar. */ +export function registerBlockedSessionsHeaderCommands(): IDisposable { + return combinedDisposable( + CommandsRegistry.registerCommand(SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => { + context.showAllSessions(); + }), + CommandsRegistry.registerCommand(IGNORE_ALL_INPUT_NEEDED_COMMAND_ID, (_accessor, context: IBlockedSessionsHeaderActionContext) => { + context.ignoreAllSessions(); + }), + ); +} + /** - * The currently-open blocked-sessions dropdown, if any. Shared at module scope so - * command handlers registered outside the widget instance (the Escape keybinding - * and the "Show All Sessions" action) can close this specific context view via its - * {@link IOpenContextView.close}, rather than blindly hiding whatever context view - * happens to be open. Owned by {@link SessionsTitleBarWidget}, which keeps it in - * sync when the dropdown opens and clears it when it hides. + * The currently-open blocked-sessions dropdown, shared with the Escape command so + * it closes this specific context view. */ let openBlockedSessionsView: IOpenContextView | undefined; @@ -539,6 +583,12 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._openBlockedSession(resource, preserveFocus, sideBySide); }, onIgnoreSession: session => this._blockedIndicator.ignoreSession(session), + onShowAllSessions: () => { + this._openContextView?.close(); + this._showSessionsPicker(); + }, + onIgnoreAllSessions: () => this._blockedIndicator.ignoreAllSessions(), + onClose: () => this._openContextView?.close(), })); list.setSessions(this._blockedIndicator.blockedSessions.get().map(entry => entry.session)); store.add(list.onDidChangeContentHeight(() => this.contextViewService.layout())); @@ -710,23 +760,8 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben // The blocked-sessions dropdown header's "Show All Sessions" action dismisses // the dropdown (a transient context view) before opening the full sessions // picker, so the popup doesn't linger behind it. - this._register(CommandsRegistry.registerCommand(SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, accessor => { - openBlockedSessionsView?.close(); - return accessor.get(ICommandService).executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); - })); - - // Contribute the action to the blocked-sessions dropdown header toolbar. - this._register(MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { - command: { - id: SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, - title: localize('showAllSessions', "Show All Sessions"), - icon: Codicon.listSelection, - }, - group: 'navigation', - order: 1, - when: IsAuxiliaryWindowContext.negate() - })); - + this._register(registerBlockedSessionsHeaderCommands()); + this._register(registerBlockedSessionsHeaderActions()); this._register(registerBlockedSessionsItemActions()); this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarSessionTitle, (action, options) => { @@ -748,5 +783,11 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ weight: KeybindingWeight.SessionsContrib + 100, when: SessionsBlockedSessionsVisibleContext, primary: KeyCode.Escape, - handler: () => openBlockedSessionsView?.close(), + handler: (_accessor, context?: IBlockedSessionsHeaderActionContext) => { + if (context) { + context.close(); + } else { + openBlockedSessionsView?.close(); + } + }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts b/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts new file mode 100644 index 00000000000000..4561e339fa21a9 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsWindowOpenTelemetry.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../base/common/async.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { AgentsWindowOpenSource } from '../../../../platform/window/common/window.js'; +import { ILifecycleService, ShutdownReason } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; + +export const FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS = 3 * 60 * 1000; + +export interface ISessionsWindowOpenViewState { + readonly workspacePreselected: boolean | undefined; +} + +type FirstTimeWindowOpenEvent = { + source: string; + signInDialogShown: boolean; + workspacePreselected: boolean | undefined; + windowCloseDurationMs: number | undefined; +}; + +type FirstTimeWindowOpenClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The editor entry point used to open the Agents window.' }; + signInDialogShown: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the initial Agents setup flow showed a sign-in dialog.' }; + workspacePreselected: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the initial new-session view had a workspace selected. Undefined when a created session was visible.' }; + windowCloseDurationMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Milliseconds before the Agents window closed, capped at three minutes.' }; + owner: 'benibenj'; + comment: 'Tracks how users who have never started an Agents session enter and initially experience the Agents window.'; +}; + +export class SessionsWindowOpenTelemetry extends Disposable { + + private _viewState: ISessionsWindowOpenViewState | undefined; + private _didSend = false; + private readonly _openedAt = Date.now(); + + constructor( + private readonly _source: AgentsWindowOpenSource, + private readonly _getSignInDialogShown: () => boolean, + private readonly _getViewState: () => ISessionsWindowOpenViewState, + private readonly _telemetryService: ITelemetryService, + lifecycleService: ILifecycleService, + ) { + super(); + + const remainingDuration = Math.max(0, FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS - this._elapsed()); + this._register(disposableTimeout(() => this._send(undefined), remainingDuration)); + this._register(lifecycleService.onWillShutdown(event => { + const windowCloseDurationMs = event.reason === ShutdownReason.CLOSE || event.reason === ShutdownReason.QUIT + ? this._getCloseDuration() + : undefined; + this._send(windowCloseDurationMs); + })); + } + + captureInitialViewState(): void { + this._viewState ??= this._getViewState(); + } + + private _elapsed(): number { + return Math.max(0, Date.now() - this._openedAt); + } + + private _getCloseDuration(): number | undefined { + const duration = this._elapsed(); + return duration <= FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS ? duration : undefined; + } + + private _send(windowCloseDurationMs: number | undefined): void { + if (this._didSend) { + return; + } + this._didSend = true; + this.captureInitialViewState(); + + this._telemetryService.publicLog2('agents/firstTimeWindowOpen', { + source: this._source, + signInDialogShown: this._getSignInDialogShown(), + workspacePreselected: this._viewState?.workspacePreselected, + windowCloseDurationMs, + }); + } +} diff --git a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts index de44631f9dc613..dee4c9f3cd0258 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts @@ -98,6 +98,22 @@ suite('BlockedSessionsIndicatorModel', () => { assert.deepStrictEqual({ blocked: blockedIds(model), blink: model.consumePendingBlink() }, { blocked: [], blink: false }); }); + test('keeps an approval acknowledged when its chat model reloads', () => { + const { model, blockedModel, approvalModel, sessionsService } = createModel(); + const s1 = new TestSession('s1'); + approvalModel.setApproval(s1.resource, approval(AgentSessionApprovalKind.Terminal, new Date(1000), 'tool-call-1')); + blockedModel.setBlocked([needsInput(s1)]); + sessionsService.setVisible([s1]); + sessionsService.setVisible([]); + + approvalModel.setApproval(s1.resource, undefined); + approvalModel.setApproval(s1.resource, approval(AgentSessionApprovalKind.Terminal, new Date(2000), 'tool-call-1')); + const afterReload = blockedIds(model); + approvalModel.setApproval(s1.resource, approval(AgentSessionApprovalKind.Terminal, new Date(3000), 'tool-call-2')); + + assert.deepStrictEqual({ afterReload, afterNewApproval: blockedIds(model) }, { afterReload: [], afterNewApproval: ['s1'] }); + }); + test('blinks again when an additional, not-yet-visible session becomes blocked', () => { const { model, blockedModel } = createModel(); const s1 = new TestSession('s1'); @@ -231,6 +247,20 @@ suite('BlockedSessionsIndicatorModel', () => { assert.deepStrictEqual(blockedIds(model), ['s1']); }); + test('ignores all currently surfaced blocked sessions', () => { + const { model, blockedModel } = createModel(); + const input = new TestSession('input'); + const ci = new TestSession('ci'); + blockedModel.setBlocked([needsInput(input), failingCI(ci, 'sha1')]); + model.ignoreAllSessions(); + const ignored = blockedIds(model); + + blockedModel.setBlocked([]); + blockedModel.setBlocked([needsInput(input), failingCI(ci, 'sha2')]); + + assert.deepStrictEqual({ ignored, afterNewOccurrences: blockedIds(model) }, { ignored: [], afterNewOccurrences: ['input', 'ci'] }); + }); + test('reports nothing and never blinks when disabled (stable quality)', () => { const { model, blockedModel } = createModel({ quality: 'stable' }); blockedModel.setBlocked([needsInput(new TestSession('s1'))]); @@ -246,8 +276,8 @@ function failingCI(session: TestSession, headSha: string = 'sha'): IBlockedSessi return { session: session as unknown as ISession, reason: BlockedSessionReason.FailingCI, occurrenceId: `${BlockedSessionReason.FailingCI}:${headSha}` }; } -function approval(kind: AgentSessionApprovalKind, since: Date = new Date()): IAgentSessionApprovalInfo { - return { kind, label: 'npm run build', languageId: undefined, since, confirm: () => { } }; +function approval(kind: AgentSessionApprovalKind, since: Date = new Date(), approvalId: string = `${kind}:${since.getTime()}`): IAgentSessionApprovalInfo { + return { approvalId, kind, label: 'npm run build', languageId: undefined, since, confirm: () => { } }; } class TestSession { diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts new file mode 100644 index 00000000000000..be2c3e8bf1d55f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowOpenTelemetry.test.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; +import { TestLifecycleService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { ShutdownReason } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS, SessionsWindowOpenTelemetry } from '../../browser/sessionsWindowOpenTelemetry.js'; + +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: unknown }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } +} + +suite('SessionsWindowOpenTelemetry', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('emits captured initial state and close duration for a quick close', async () => { + await runWithFakedTimers({ useFakeTimers: true, startTime: 10_000 }, async () => { + const lifecycleService = disposables.add(new TestLifecycleService()); + const telemetryService = new TestTelemetryService(); + let workspacePreselected = true; + const tracker = disposables.add(new SessionsWindowOpenTelemetry( + AgentsWindowOpenSource.TitleBar, + () => true, + () => ({ workspacePreselected }), + telemetryService, + lifecycleService, + )); + + tracker.captureInitialViewState(); + workspacePreselected = false; + await timeout(4_000); + lifecycleService.fireShutdown(ShutdownReason.CLOSE); + + assert.deepStrictEqual(telemetryService.events, [{ + name: 'agents/firstTimeWindowOpen', + data: { + source: 'titleBar', + signInDialogShown: true, + workspacePreselected: true, + windowCloseDurationMs: 4_000, + }, + }]); + tracker.dispose(); + lifecycleService.dispose(); + }); + }); + + test('emits once after three minutes without a close duration', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const lifecycleService = disposables.add(new TestLifecycleService()); + const telemetryService = new TestTelemetryService(); + const tracker = disposables.add(new SessionsWindowOpenTelemetry( + AgentsWindowOpenSource.CommandPalette, + () => false, + () => ({ workspacePreselected: undefined }), + telemetryService, + lifecycleService, + )); + + await timeout(FIRST_TIME_WINDOW_OPEN_DURATION_LIMIT_MS); + lifecycleService.fireShutdown(ShutdownReason.CLOSE); + + assert.deepStrictEqual(telemetryService.events, [{ + name: 'agents/firstTimeWindowOpen', + data: { + source: 'commandPalette', + signInDialogShown: false, + workspacePreselected: undefined, + windowCloseDurationMs: undefined, + }, + }]); + tracker.dispose(); + lifecycleService.dispose(); + }); + }); +}); diff --git a/src/vs/workbench/api/common/extHostAuthentication.ts b/src/vs/workbench/api/common/extHostAuthentication.ts index 198ae091b161ac..1926bb3034524f 100644 --- a/src/vs/workbench/api/common/extHostAuthentication.ts +++ b/src/vs/workbench/api/common/extHostAuthentication.ts @@ -9,7 +9,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { MainContext, MainThreadAuthenticationShape, ExtHostAuthenticationShape } from './extHost.protocol.js'; import { Disposable, ProgressLocation } from './extHostTypes.js'; import { IExtensionDescription, ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; -import { INTERNAL_AUTH_PROVIDER_PREFIX, isAuthenticationWwwAuthenticateRequest } from '../../services/authentication/common/authentication.js'; +import { IAuthenticationGetSessionsOptions, IAuthenticationProviderSessionOptions, INTERNAL_AUTH_PROVIDER_PREFIX, isAuthenticationWwwAuthenticateRequest } from '../../services/authentication/common/authentication.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -190,7 +190,7 @@ export class ExtHostAuthentication implements ExtHostAuthenticationShape { }); } - $getSessions(providerId: string, scopes: ReadonlyArray | undefined, options: vscode.AuthenticationProviderSessionOptions): Promise> { + $getSessions(providerId: string, scopes: ReadonlyArray | undefined, options: IAuthenticationGetSessionsOptions): Promise> { return this._providerOperations.queue(providerId, async () => { const providerData = this._authenticationProviders.get(providerId); if (providerData) { @@ -466,6 +466,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { protected _clientSecret: string | undefined, onDidDynamicAuthProviderTokensChange: Emitter<{ authProviderId: string; clientId: string; tokens: IAuthorizationToken[] }>, initialTokens: IAuthorizationToken[], + private readonly _fetch: typeof fetch = fetch, ) { const stringifiedServer = authorizationServer.toString(true); // Auth Provider Id is a combination of the authorization server and the resource, if provided. @@ -510,7 +511,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { return this._clientSecret; } - async getSessions(scopes: readonly string[] | undefined, _options: vscode.AuthenticationProviderSessionOptions): Promise { + async getSessions(scopes: readonly string[] | undefined, options: IAuthenticationProviderSessionOptions): Promise { this._logger.info(`Getting sessions for scopes: ${scopes?.join(' ') ?? 'all'}`); if (!scopes) { return this._tokenStore.sessions; @@ -541,7 +542,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { continue; } try { - const newToken = await this.exchangeRefreshTokenForToken(token.refresh_token); + const newToken = await this.exchangeRefreshTokenForToken(token.refresh_token, options.silent !== true); // TODO@TylerLeonhardt: When the core scope handling doesn't care about order, this check should be // updated to not care about order if (newToken.scope !== scopeStr) { @@ -773,7 +774,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { this._logger.trace(`Token request body: ${tokenRequest.toString()}`); let response: Response; try { - response = await fetch(this._serverMetadata.token_endpoint, { + response = await this._fetch(this._serverMetadata.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -803,7 +804,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { throw new Error(`Invalid authorization token response: ${JSON.stringify(result)}`); } - protected async exchangeRefreshTokenForToken(refreshToken: string): Promise { + protected async exchangeRefreshTokenForToken(refreshToken: string, allowClientRegistration: boolean): Promise { if (!this._serverMetadata.token_endpoint) { throw new Error('Token endpoint not available in server metadata'); } @@ -823,7 +824,7 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { tokenRequest.append('client_secret', this._clientSecret); } - const response = await fetch(this._serverMetadata.token_endpoint, { + const response = await this._fetch(this._serverMetadata.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -839,6 +840,10 @@ export class DynamicAuthProvider implements vscode.AuthenticationProvider { created_at: Date.now(), }; } else if (isAuthorizationErrorResponse(result) && result.error === AuthorizationErrorType.InvalidClient) { + if (!allowClientRegistration) { + this._logger.warn(`Client ID (${this._clientId}) was invalid while silently refreshing the token.`); + throw new Error(`Client ID was invalid while silently refreshing the token.`); + } this._logger.warn(`Client ID (${this._clientId}) was invalid, generated a new one.`); await this._generateNewClientId(); throw new Error(`Client ID was invalid, generated a new one. Please try again.`); diff --git a/src/vs/workbench/api/test/browser/extHostAuthentication.test.ts b/src/vs/workbench/api/test/browser/extHostAuthentication.test.ts index ad5b4a2dd541a0..450e6e1d231a97 100644 --- a/src/vs/workbench/api/test/browser/extHostAuthentication.test.ts +++ b/src/vs/workbench/api/test/browser/extHostAuthentication.test.ts @@ -6,20 +6,28 @@ import assert from 'assert'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogger } from '../../../../platform/log/common/log.js'; -import { IAuthorizationToken, TokenStore } from '../../common/extHostAuthentication.js'; +import { ILogger, ILoggerService, NullLogger } from '../../../../platform/log/common/log.js'; +import { IAuthenticationProviderSessionOptions } from '../../../services/authentication/common/authentication.js'; +import { DynamicAuthProvider, IAuthorizationToken, TokenStore } from '../../common/extHostAuthentication.js'; +import { MainThreadAuthenticationShape } from '../../common/extHost.protocol.js'; +import { IExtHostInitDataService } from '../../common/extHostInitDataService.js'; +import { IExtHostProgress } from '../../common/extHostProgress.js'; +import { IExtHostUrlsService } from '../../common/extHostUrls.js'; +import { IExtHostWindow } from '../../common/extHostWindow.js'; + +/** Builds a structurally-valid JWT carrying the given claims. */ +function jwt(claims: object): string { + const segment = (value: object) => encodeBase64(VSBuffer.fromString(JSON.stringify(value))); + return `${segment({ alg: 'none', typ: 'JWT' })}.${segment(claims)}.signature`; +} suite('TokenStore', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - /** Builds a structurally-valid JWT (header.payload.signature) carrying the given claims. */ - function jwt(claims: object): string { - const segment = (value: object) => encodeBase64(VSBuffer.fromString(JSON.stringify(value))); - return `${segment({ alg: 'none', typ: 'JWT' })}.${segment(claims)}.signature`; - } - function createStore(initialTokens: IAuthorizationToken[]): TokenStore { const persistence = { onDidChange: disposables.add(new Emitter()).event, @@ -45,3 +53,78 @@ suite('TokenStore', () => { ); }); }); + +suite('DynamicAuthProvider', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + class TestDynamicAuthProvider extends DynamicAuthProvider { + generateNewClientIdCalls = 0; + + protected override async _generateNewClientId(): Promise { + this.generateNewClientIdCalls++; + } + } + + test('does not rotate the client while silently refreshing a token', async () => { + let fetchCalls = 0; + const fetcher: typeof fetch = async () => { + fetchCalls++; + return new Response(JSON.stringify({ error: 'invalid_client' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + }; + const loggerService = new class extends mock() { + override createLogger(): ILogger { + return new NullLogger(); + } + }(); + const proxy = new class extends mock() { + override $setSessionsForDynamicAuthProvider(): Promise { + return Promise.resolve(); + } + }(); + const provider = disposables.add(new TestDynamicAuthProvider( + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + loggerService, + proxy, + URI.parse('https://mcp.example.com'), + { + issuer: 'https://mcp.example.com', + response_types_supported: ['code'], + token_endpoint: 'https://mcp.example.com/token', + }, + { resource: 'https://mcp.example.com/resource' }, + 'client-id', + undefined, + disposables.add(new Emitter()), + [{ + access_token: jwt({ sub: 'account' }), + token_type: 'Bearer', + scope: '', + expires_in: 1, + refresh_token: 'refresh-token', + created_at: 0, + }], + fetcher, + )); + + const sessions = await provider.getSessions([], { silent: true } satisfies IAuthenticationProviderSessionOptions); + + assert.deepStrictEqual({ + sessions, + fetchCalls, + generateNewClientIdCalls: provider.generateNewClientIdCalls, + clientId: provider.clientId, + }, { + sessions: [], + fetchCalls: 1, + generateNewClientIdCalls: 0, + clientId: 'client-id', + }); + }); +}); diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 53b0cab557b6e0..30785a3e76f3ee 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -29,6 +29,7 @@ import { DeepPartial } from '../../../../base/common/types.js'; import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; +import { EditorPartModalVisibleContext } from '../../../common/contextkeys.js'; interface IEditorPartsUIState { readonly auxiliary: IAuxiliaryEditorPartState[]; @@ -63,6 +64,7 @@ export class EditorParts extends MultiWindowParts; // Most recently active parts across all windows. Multiple parts can // share the same window (e.g. main part and modal part both live in @@ -78,6 +80,7 @@ export class EditorParts extends MultiWindowParts { const workingSetsRaw = this.storageService.get(EditorParts.EDITOR_WORKING_SETS_STORAGE_KEY, StorageScope.WORKSPACE); @@ -217,17 +220,25 @@ export class EditorParts extends MultiWindowParts { - const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create({ - ...options, - maximized: options?.maximized ?? this.modalEditorMaximized, - size: options?.size ?? this.modalEditorSize, - position: options?.position ?? this.modalEditorPosition, - sidebar: options?.sidebar ? { - ...options.sidebar, - sidebarWidth: options.sidebar.sidebarWidth ?? this.modalEditorSidebarWidth, - sidebarHidden: options.sidebar.sidebarHidden ?? this.modalEditorSidebarHidden - } : undefined - }); + this.modalEditorVisibleContext.set(true); + let result; + try { + result = await this.instantiationService.createInstance(ModalEditorPart, this).create({ + ...options, + maximized: options?.maximized ?? this.modalEditorMaximized, + size: options?.size ?? this.modalEditorSize, + position: options?.position ?? this.modalEditorPosition, + sidebar: options?.sidebar ? { + ...options.sidebar, + sidebarWidth: options.sidebar.sidebarWidth ?? this.modalEditorSidebarWidth, + sidebarHidden: options.sidebar.sidebarHidden ?? this.modalEditorSidebarHidden + } : undefined + }); + } catch (error) { + this.modalEditorVisibleContext.set(false); + throw error; + } + const { part, instantiationService, disposables } = result; // Keep instantiation service and reference to reuse this.modalEditorPart = part; @@ -245,6 +256,7 @@ export class EditorParts extends MultiWindowParts('editorPartMaximizedEditorGroup', false, localize('editorPartEditorGroupMaximized', "Editor Part has a maximized group")); export const EditorPartModalContext = new RawContextKey('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part")); +export const EditorPartModalVisibleContext = new RawContextKey('editorPartModalVisible', false, localize('editorPartModalVisible', "Whether a modal editor part is visible")); export const EditorPartModalMaximizedContext = new RawContextKey('editorPartModalMaximized', false, localize('editorPartModalMaximized', "Whether the modal editor part is maximized")); export const EditorPartModalNavigationContext = new RawContextKey('editorPartModalNavigation', false, localize('editorPartModalNavigation', "Whether the modal editor part has navigation context")); export const EditorPartModalSidebarContext = new RawContextKey('editorPartModalSidebar', false, localize('editorPartModalSidebar', "Whether the modal editor part has a sidebar")); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index a6f153a7afb07b..81cb60c924d0e7 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -515,7 +515,7 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'auto', }, - tags: ['experimental', 'advanced'], + tags: ['experimental'], scope: ConfigurationScope.APPLICATION, restricted: true, }, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 1efe07f5eb1cbc..9084813d496bea 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -12,6 +12,7 @@ import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/w import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { process } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService, PreferredGroup, SIDE_GROUP, USE_MODAL_EDITOR_SETTING, UseModalEditorMode } from '../../../services/editor/common/editorService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -33,8 +34,9 @@ import { IChatWidgetService } from '../../chat/browser/chat.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; +import { getCopilotRootPaths } from '../../../../platform/agentHost/common/copilotHome.js'; import { localChatSessionType } from '../../chat/common/chatSessionsService.js'; -import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-browser/environmentService.js'; import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy.js'; export const BrowserMaxHistoryEntriesSettingId = 'workbench.browser.maxHistoryEntries'; @@ -117,7 +119,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV @IWorkspaceTrustEnablementService private readonly workspaceTrustEnablementService: IWorkspaceTrustEnablementService, @ILogService private readonly logService: ILogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @INativeWorkbenchEnvironmentService private readonly environmentService: INativeWorkbenchEnvironmentService, @IThemeService private readonly themeService: IThemeService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { @@ -521,7 +523,8 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } private _getTrustedFileRoots(): string[] { - const roots = new Set(); + // Trust Copilot roots so agents can create HTML files and open them in the browser. + const roots = new Set(getCopilotRootPaths(this.environmentService.userHome.fsPath, process.env)); if (this.workspaceTrustManagementService.isWorkspaceTrusted()) { for (const folder of this.workspaceContextService.getWorkspace().folders) { if (folder.uri.scheme === Schemas.file) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index d32a069541e178..259e63492719bf 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -84,7 +84,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.attachments.inlineReferenceHover', 'To inspect an inline attachment reference, place the cursor on it and invoke Show or Focus Hover{0}. Image references include a preview, while file and folder references include their path.', '')); } content.push(localize('chat.attachments.removal', 'To remove attached contexts, focus an attachment and press Delete or Backspace.')); - content.push(localize('workbench.action.chat.toggleSpeechToText', 'To dictate your request into the input box using on-device speech-to-text, invoke the Dictate command{0}. Invoke it again to stop; recording start and stop are indicated by accessibility signals.', '')); + content.push(localize('workbench.action.chat.toggleSpeechToText', 'To dictate your request into the input box, invoke the Dictate command{0}. Invoke it again to stop; recording start and stop are indicated by accessibility signals.', '')); content.push(localize('workbench.action.chat.cancelSpeechToText', 'While dictating, invoke the Cancel Dictation command{0} to stop and discard the dictated text.', '')); content.push(localize('chat.speechToText.contextMenu', 'To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu{0} (for example Shift+F10).', '')); content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '')); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts index 13c90e2f10f960..8b8d6974ad1be1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts @@ -10,7 +10,6 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -37,35 +36,17 @@ export const ChatSpeechToTextPreparing = ContextKeyExpr.has(ChatContextKeys.spee /** Releases shorter than this are treated as an accidental tap and discarded. */ const HOLD_TO_TALK_THRESHOLD_MS = 500; -/** Setting that controls the tap-vs-hold behavior of the dictation shortcut. */ -const DICTATION_MODE_SETTING = 'chat.speechToText.mode'; - -/** - * How the dictation shortcut behaves: - * - `toggle`: tap to start, tap again to stop. - * - `pushToTalk`: dictate only while the shortcut is held (release stops). - * - `auto` (default): a quick tap toggles, holding is push-to-talk. - */ -type DictationMode = 'auto' | 'toggle' | 'pushToTalk'; - -function getDictationMode(configurationService: IConfigurationService): DictationMode { - const value = configurationService.getValue(DICTATION_MODE_SETTING); - return value === 'toggle' || value === 'pushToTalk' ? value : 'auto'; -} - /** Services required to run the dictation shortcut, see {@link runDictationShortcut}. */ export interface IDictationShortcutContext { readonly speechService: IChatSpeechToTextService; readonly keybindingService: IKeybindingService; - readonly configurationService: IConfigurationService; readonly logService: ILogService; } /** - * Run the dictation shortcut for `editor`, honoring the tap-vs-hold - * `chat.speechToText.mode` setting. Shared by the main chat input toggle action - * and the Agents composer's Cmd/Ctrl+I command so both behave identically: - * tapping toggles, holding is push-to-talk (release stops). + * Run the dictation shortcut for `editor`. Shared by the main chat input toggle + * action and the Agents composer's Cmd/Ctrl+I command so both behave + * identically: tapping toggles, holding is push-to-talk (release stops). * * `commandId` is the keybinding command being run; it is used to detect a held * shortcut via {@link IKeybindingService.enableKeybindingHoldMode}, which @@ -73,11 +54,10 @@ export interface IDictationShortcutContext { * or the command palette), collapsing the behavior to a plain toggle. */ export async function runDictationShortcut(context: IDictationShortcutContext, commandId: string, editor: ICodeEditor): Promise { - const { speechService, keybindingService, configurationService, logService } = context; + const { speechService, keybindingService, logService } = context; - // A second invocation while dictating always stops (toggles off), regardless - // of mode. This is the tap-again-to-stop path and also how the toolbar stop - // button behaves. + // A second invocation while dictating always stops (toggles off). This is the + // tap-again-to-stop path and also how the toolbar stop button behaves. if (isDictating()) { await stopDictation(); return; @@ -88,12 +68,11 @@ export async function runDictationShortcut(context: IDictationShortcutContext, c } const window = getWindow(editor.getDomNode()) ?? getActiveWindow(); - const mode = getDictationMode(configurationService); - // Pure toggle mode (or when the action is not invoked via a held keybinding, - // e.g. the toolbar mic or the command palette): just start dictating and rely - // on the next invocation to stop. - const holdMode = mode === 'toggle' ? undefined : keybindingService.enableKeybindingHoldMode(commandId); + // Attempt to detect a held keybinding. Returns `undefined` when not invoked + // through a held key (e.g. the toolbar mic or the command palette), which + // collapses the behavior to a plain toggle. + const holdMode = keybindingService.enableKeybindingHoldMode(commandId); await startDictation(speechService, editor, window, logService); if (!holdMode) { return; @@ -106,13 +85,8 @@ export async function runDictationShortcut(context: IDictationShortcutContext, c const heldMs = Date.now() - heldFrom; if (heldMs < HOLD_TO_TALK_THRESHOLD_MS) { - if (mode === 'pushToTalk') { - // A quick tap in push-to-talk mode is treated as accidental and - // discarded rather than transcribing a fraction of a second. - cancelDictation(); - } - // In auto mode a quick tap means "toggle on": leave dictation running so - // the user can tap again to stop. + // A quick tap means "toggle on": leave dictation running so the user can + // tap again to stop. return; } @@ -168,7 +142,6 @@ export class ToggleChatSpeechToTextAction extends Action2 { await runDictationShortcut({ speechService: accessor.get(IChatSpeechToTextService), keybindingService: accessor.get(IKeybindingService), - configurationService: accessor.get(IConfigurationService), logService: accessor.get(ILogService), }, ToggleChatSpeechToTextAction.ID, widget.inputEditor); } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts index 569ad24eb89ee9..4da5e304932637 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts @@ -45,12 +45,20 @@ export class InstallPluginAction extends Action { } export class UninstallPluginAction extends Action { - constructor(plugin: IAgentPlugin) { + constructor(plugin: IAgentPlugin & { remove(): void }) { super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true, - () => { plugin.remove?.(); return Promise.resolve(); }); + () => { plugin.remove(); return Promise.resolve(); }); } } +function isRemovableAgentPlugin(plugin: IAgentPlugin): plugin is IAgentPlugin & { remove(): void } { + return plugin.remove !== undefined; +} + +export function createUninstallPluginAction(plugin: IAgentPlugin): UninstallPluginAction | undefined { + return isRemovableAgentPlugin(plugin) ? new UninstallPluginAction(plugin) : undefined; +} + export class OpenPluginFolderAction extends Action { constructor( plugin: IAgentPlugin, @@ -124,8 +132,9 @@ export function getInstalledPluginContextMenuActions(plugin: IAgentPlugin, insta instantiationService.createInstance(OpenPluginFolderAction, plugin), instantiationService.createInstance(OpenPluginReadmeAction, joinPath(plugin.uri, 'README.md')), ]); - if (plugin.fromMarketplace) { - groups.push([new UninstallPluginAction(plugin)]); + const uninstallAction = createUninstallPluginAction(plugin); + if (uninstallAction) { + groups.push([uninstallAction]); } return groups; }); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index 8abd1df838f6ce..7683dded76d6ab 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -43,7 +43,7 @@ import { AgentPluginEditorInput } from './agentPluginEditorInput.js'; import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem } from './agentPluginItems.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { EnablementStatusWidget, pluginEnablementLabels } from '../enablementStatusWidget.js'; -import { InstallPluginAction, UninstallPluginAction, createEnablePluginDropDown, createDisablePluginDropDown, createPolicyBlockedEnableAction, isPluginPolicyBlocked, EnablementDropDownAction, EnablementDropdownActionViewItem } from '../agentPluginActions.js'; +import { InstallPluginAction, createUninstallPluginAction, createEnablePluginDropDown, createDisablePluginDropDown, createPolicyBlockedEnableAction, isPluginPolicyBlocked, EnablementDropDownAction, EnablementDropdownActionViewItem } from '../agentPluginActions.js'; import './media/agentPluginEditor.css'; interface IAgentPluginEditorTemplate { @@ -339,7 +339,10 @@ export class AgentPluginEditor extends EditorPane { actions.push(createEnablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); actions.push(createDisablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); } - actions.push(new UninstallPluginAction(item.plugin)); + const uninstallAction = createUninstallPluginAction(item.plugin); + if (uninstallAction) { + actions.push(uninstallAction); + } return actions; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index 0589fcc148e214..44c77aa75ff8cd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { fetchAuthorizationServerMetadata } from '../../../../../../base/common/oauth.js'; +import { SequencerByKey } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { URI } from '../../../../../../base/common/uri.js'; import { type McpOAuthClient, type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -358,10 +359,8 @@ export async function resolveMcpServerAuthentication( const authenticationMcpAccessService = accessor.get(IAuthenticationMcpAccessService); const authenticationMcpService = accessor.get(IAuthenticationMcpService); const authenticationMcpUsageService = accessor.get(IAuthenticationMcpUsageService); + const dynamicAuthenticationProviderStorageService = accessor.get(IDynamicAuthenticationProviderStorageService); const logService = accessor.get(ILogService); - const dynamicAuthenticationProviderStorageService = options.oauthClient - ? accessor.get(IDynamicAuthenticationProviderStorageService) - : undefined; const agentHostMeta = options.agentHost ? { authority: options.agentHost.authority, label: accessor.get(ILabelService).getHostLabel(options.agentHost.scheme, options.agentHost.authority) } : undefined; @@ -369,58 +368,77 @@ export async function resolveMcpServerAuthentication( const scopes = options.scopes.length > 0 || isGitHubMcpResource(protectedResource) ? options.scopes : protectedResource.scopes_supported ?? []; + const authenticationOperations = getMcpAuthenticationOperations(authenticationService); for (const authorizationServer of protectedResource.authorization_servers ?? []) { const authorizationServerUri = URI.parse(authorizationServer); - const providerId = await getOrCreateProviderForMcpResource( - authorizationServerUri, - protectedResource, - options.oauthClient, - authenticationService, - dynamicAuthenticationProviderStorageService, - logService, - options.logPrefix, - options.allowInteraction, - options.authorizationServerMetadataFetcher ?? fetchAuthorizationServerMetadata, - ); - if (!providerId) { - continue; - } - - const oauthClientOptions = options.oauthClient - ? { clientId: options.oauthClient.clientId, clientSecret: options.oauthClient.clientSecret } - : {}; - const sessions = await authenticationService.getSessions(providerId, [...scopes], { - authorizationServer: authorizationServerUri, - resource: protectedResource.resource, - ...oauthClientOptions, - }, true); - const allowedSession = getAllowedMcpSession(providerId, sessions, authenticationMcpAccessService, authenticationMcpService, options); - if (allowedSession) { - await authenticateMcpSession(providerId, allowedSession, scopes, authenticationMcpAccessService, authenticationMcpService, authenticationMcpUsageService, logService, options, false, agentHostMeta); - return true; - } - - if (!options.allowInteraction) { - continue; - } + const providerOperationId = getDynamicAuthenticationProviderId(authorizationServerUri, protectedResource); + const authenticated = await authenticationOperations.queue(providerOperationId, async () => { + const providerId = await getOrCreateProviderForMcpResource( + authorizationServerUri, + protectedResource, + options.oauthClient, + authenticationService, + dynamicAuthenticationProviderStorageService, + logService, + options.logPrefix, + options.allowInteraction, + options.authorizationServerMetadataFetcher ?? fetchAuthorizationServerMetadata, + ); + if (!providerId) { + return false; + } - const provider = authenticationService.getProvider(providerId); - const session = sessions.length - ? provider.supportsMultipleAccounts - ? await authenticationMcpService.selectSession(providerId, options.mcpServerId, options.mcpServerName, [...scopes], sessions) - : sessions[0] - : await authenticationService.createSession(providerId, [...scopes], { - activateImmediate: true, + const oauthClientOptions = options.oauthClient + ? { clientId: options.oauthClient.clientId, clientSecret: options.oauthClient.clientSecret } + : {}; + const sessions = await authenticationService.getSessions(providerId, [...scopes], { authorizationServer: authorizationServerUri, resource: protectedResource.resource, ...oauthClientOptions, - }); - await authenticateMcpSession(providerId, session, scopes, authenticationMcpAccessService, authenticationMcpService, authenticationMcpUsageService, logService, options, true, agentHostMeta); - return true; + silent: !options.allowInteraction, + }, true); + const allowedSession = getAllowedMcpSession(providerId, sessions, authenticationMcpAccessService, authenticationMcpService, options); + if (allowedSession) { + await authenticateMcpSession(providerId, allowedSession, scopes, authenticationMcpAccessService, authenticationMcpService, authenticationMcpUsageService, logService, options, false, agentHostMeta); + return true; + } + + if (!options.allowInteraction) { + return false; + } + + const provider = authenticationService.getProvider(providerId); + const session = sessions.length + ? provider.supportsMultipleAccounts + ? await authenticationMcpService.selectSession(providerId, options.mcpServerId, options.mcpServerName, [...scopes], sessions) + : sessions[0] + : await authenticationService.createSession(providerId, [...scopes], { + activateImmediate: true, + authorizationServer: authorizationServerUri, + resource: protectedResource.resource, + ...oauthClientOptions, + }); + await authenticateMcpSession(providerId, session, scopes, authenticationMcpAccessService, authenticationMcpService, authenticationMcpUsageService, logService, options, true, agentHostMeta); + return true; + }); + if (authenticated) { + return true; + } } return false; } +const mcpAuthenticationOperations = new WeakMap>(); + +function getMcpAuthenticationOperations(authenticationService: IAuthenticationService): SequencerByKey { + let operations = mcpAuthenticationOperations.get(authenticationService); + if (!operations) { + operations = new SequencerByKey(); + mcpAuthenticationOperations.set(authenticationService, operations); + } + return operations; +} + function isGitHubMcpResource(resource: ProtectedResourceMetadata): boolean { return resource.resource_name === 'GitHub MCP Server'; } @@ -430,18 +448,17 @@ async function getOrCreateProviderForMcpResource( protectedResource: ProtectedResourceMetadata, oauthClient: McpOAuthClient | undefined, authenticationService: IAuthenticationService, - dynamicAuthenticationProviderStorageService: IDynamicAuthenticationProviderStorageService | undefined, + dynamicAuthenticationProviderStorageService: IDynamicAuthenticationProviderStorageService, logService: ILogService, logPrefix: string, allowCreation: boolean, authorizationServerMetadataFetcher: typeof fetchAuthorizationServerMetadata, ): Promise { const resourceUri = URI.parse(protectedResource.resource); + const dynamicProviderId = getDynamicAuthenticationProviderId(authorizationServer, protectedResource); + let clientId = oauthClient?.clientId; + let clientSecret = oauthClient?.clientSecret; if (oauthClient) { - if (!dynamicAuthenticationProviderStorageService) { - throw new Error('Dynamic authentication provider storage is required for a configured OAuth client.'); - } - const dynamicProviderId = getDynamicAuthenticationProviderId(authorizationServer, protectedResource); const isProviderActive = authenticationService.isDynamicAuthenticationProvider(dynamicProviderId); const registeredClient = await dynamicAuthenticationProviderStorageService.getClientRegistration(dynamicProviderId); const clientMatches = registeredClient?.clientId === oauthClient.clientId && registeredClient.clientSecret === oauthClient.clientSecret; @@ -460,14 +477,20 @@ async function getOrCreateProviderForMcpResource( } } else { const existing = await authenticationService.getOrActivateProviderIdForServer(authorizationServer, resourceUri); - if (existing || !allowCreation) { + if (existing) { return existing; } + const registeredClient = await dynamicAuthenticationProviderStorageService.getClientRegistration(dynamicProviderId); + if (!registeredClient?.clientId && !allowCreation) { + return undefined; + } + clientId = registeredClient?.clientId; + clientSecret = registeredClient?.clientSecret; } try { const { metadata } = await authorizationServerMetadataFetcher(authorizationServer.toString(true)); - const provider = await authenticationService.createDynamicAuthenticationProvider(authorizationServer, metadata, protectedResource, oauthClient?.clientId, oauthClient?.clientSecret); + const provider = await authenticationService.createDynamicAuthenticationProvider(authorizationServer, metadata, protectedResource, clientId, clientSecret); return provider?.id; } catch (err) { logService.warn(`${logPrefix} Failed to create MCP auth provider for ${authorizationServer.toString(true)}`, err); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts index 9ae67247a1c896..9ccac0145526e1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableStore } from '../../../../../../base/common/lifec import { isObject } from '../../../../../../base/common/types.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; -import { AgentHostCopilotSdkLogLevelSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, CopilotCliConfigKey, type CopilotCliModelCapabilityOverrides, type CopilotSdkLogLevelSetting } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostCopilotSdkLogLevelSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey, type CopilotCliModelCapabilityOverrides, type CopilotSdkLogLevelSetting } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { AgentHostRootConfigForwarder, type IForwardedRootConfigKey } from './agentHostRootConfigForwarder.js'; @@ -42,6 +42,11 @@ export class AgentHostCopilotCliSettingsContribution extends Disposable implemen computeValue: () => this._configurationService.getValue(AgentHostOpus48PromptEnabledSettingId) === true, registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostOpus48PromptEnabledSettingId), }, + { + key: CopilotCliConfigKey.ToolSearchEnabled, + computeValue: () => this._configurationService.getValue(AgentHostToolSearchEnabledSettingId) === true, + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostToolSearchEnabledSettingId), + }, { key: CopilotCliConfigKey.ReasoningEffortOverride, computeValue: () => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 583c08b13b38a8..370a0782e80791 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -105,6 +105,7 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu longContextCacheWriteCost: pricing.longContextCacheWriteCost, longContextOutputCost: pricing.longContextOutputCost, priceCategory: pricing.priceCategory, + category: pricing.category, promo: pricing.promo, targetChatSessionType: this._sessionType, // Group agent-host models in the picker by their upstream provider 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 cb96aa3d40588a..866196c27ec55b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -34,6 +34,7 @@ import { readToolCallMeta } from '../../../../../../platform/agentHost/common/me import { readCompletionAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/toolSearchConstants.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ChatTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -41,7 +42,7 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, SessionInputRequestKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentChatUri, getToolSubagentContent, isChatReadOnly, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getToolSubagentContent, isChatReadOnly, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readUsageInfoMeta, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type SessionState, type StringOrMarkdown, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -363,6 +364,19 @@ function getClientToolPreApproval(toolCall: ToolCallState): ConfirmedReason | un return undefined; } +/** + * Returns the tool call's `_meta` with the transient + * {@link IToolCallMeta.toolSearchCandidates} corpus removed. Always returns an + * object (never `undefined`) so a completion action can force-replace the prior + * `_meta` — the reducer keeps the existing bag when an action omits one, so an + * explicit empty replacement is what actually drops the candidates. + */ +function metaWithoutToolSearchCandidates(source: { readonly _meta?: Record }): Record { + const meta = { ...source._meta }; + delete meta['toolSearchCandidates']; + return meta; +} + /** * Converts carousel answers (IChatQuestionAnswers) to protocol * ChatInputAnswer records, handling text, single-select, @@ -2862,7 +2876,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC adopted.didExecuteTool(undefined); } - const toolData = this._toolsService.getToolByName(toolName); + const clientToolName = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME ? CLIENT_TOOL_SEARCH_REFERENCE_NAME : toolName; + const toolData = this._toolsService.getToolByName(clientToolName); if (!toolData) { this._logService.warn(`[AgentHost] Client tool call for unknown tool: ${toolName}`); this._dispatchAction(opts.backendSession, { @@ -2981,11 +2996,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const protocolToolCall = part$.get().toolCall; const isProtocolToolCallComplete = protocolToolCall.status === ToolCallStatus.Completed || protocolToolCall.status === ToolCallStatus.Cancelled; if (!isProtocolToolCallComplete) { + // The tool-search ready action stashes the (potentially large) + // deferred-tool corpus in `_meta.toolSearchCandidates` purely to + // seed this invocation. The completion reducer keeps the prior + // `_meta` when the action omits one, so without an explicit + // replacement the corpus would persist on the completed call and + // across reconnects. Carry a candidate-stripped `_meta` on the + // tool-search completion to drop it once the search has run. + const clearedMeta = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME + ? metaWithoutToolSearchCandidates(protocolToolCall) + : undefined; this._dispatchAction(opts.backendSession, { type: ActionType.ChatToolCallComplete, turnId: opts.turnId, toolCallId, result: toolResultToProtocol(result ?? { content: [] }, toolName), + ...(clearedMeta !== undefined ? { _meta: clearedMeta } : {}), }, opts.chatURI); } }; @@ -3025,6 +3051,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (invoked || cts.token.isCancellationRequested) { return; } + const toolSearchCandidates = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME + ? readToolCallMeta(tc).toolSearchCandidates + : undefined; + if (toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME && toolSearchCandidates === undefined) { + return; + } // eslint-disable-next-line local/code-no-in-operator let toolInput = 'toolInput' in tc ? tc.toolInput : undefined; if (toolInput === undefined) { @@ -3047,6 +3079,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC parameters = parsed as Record; } catch { this._logService.warn(`[AgentHost] Failed to parse tool input for ${toolName}`); + const clearedMeta = toolName === RUNTIME_TOOL_SEARCH_TOOL_NAME + ? metaWithoutToolSearchCandidates(tc) + : undefined; this._dispatchAction(opts.backendSession, { type: ActionType.ChatToolCallComplete, turnId: opts.turnId, @@ -3056,9 +3091,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC pastTenseMessage: `Failed to execute ${toolName}`, error: { message: `Invalid tool input for "${toolName}": expected JSON object parameters` }, }, + ...(clearedMeta !== undefined ? { _meta: clearedMeta } : {}), }, opts.chatURI); return; } + if (toolSearchCandidates !== undefined) { + parameters = { ...parameters, candidateTools: toolSearchCandidates }; + } const inv: IToolInvocation = { callId: toolCallId, @@ -4296,6 +4335,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (entry.value === undefined) { continue; } + if (entry.uri?.scheme === Schemas.vscodeBrowser) { + continue; + } if (skipUntitled && entry.uri?.scheme === Schemas.untitled) { continue; } @@ -5009,15 +5051,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC */ export function toolResultToProtocol(result: IToolResult, toolName: string): { success: boolean; - pastTenseMessage: string; + pastTenseMessage: StringOrMarkdown; content?: ({ type: ToolResultContentType.Text; text: string } | { type: ToolResultContentType.EmbeddedResource; data: string; contentType: string })[]; error?: { message: string }; } { const isError = !!result.toolResultError; - const pastTense = typeof result.toolResultMessage === 'string' + const defaultPastTense = isError ? `${toolName} failed` : `Ran ${toolName}`; + const pastTense: StringOrMarkdown = typeof result.toolResultMessage === 'string' ? result.toolResultMessage - : result.toolResultMessage?.value - ?? (isError ? `${toolName} failed` : `Ran ${toolName}`); + : result.toolResultMessage + ? { markdown: result.toolResultMessage.value } + : defaultPastTense; const content: ({ type: ToolResultContentType.Text; text: string } | { type: ToolResultContentType.EmbeddedResource; data: string; contentType: string })[] = []; for (const part of result.content) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index fa629d25943df4..d2cc9ea39f132e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -11,7 +11,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, MessageKind, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; @@ -1499,6 +1499,14 @@ function createSessionTitleFromArgs(toolInput: string | undefined): string | und } } +function completedToolCallConfirmedReason(tc: ICompletedToolCall): NonNullable { + if (tc.status === ToolCallStatus.Completed) { + return { type: ToolConfirmKind.ConfirmationNotNeeded }; + } + + return { type: tc.reason === ToolCallCancellationReason.Skipped ? ToolConfirmKind.Skipped : ToolConfirmKind.Denied }; +} + /** * Converts a completed tool call from the protocol state into a serialized * tool invocation suitable for history replay. @@ -1524,9 +1532,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn invocationMessage: invocationMsg, originMessage: undefined, pastTenseMessage: pastTenseMsg, - isConfirmed: isSuccess - ? { type: ToolConfirmKind.ConfirmationNotNeeded } - : { type: ToolConfirmKind.Denied }, + isConfirmed: completedToolCallConfirmedReason(tc), isComplete: true, presentation: undefined, subAgentInvocationId: subAgentInvocationId, @@ -1580,9 +1586,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn invocationMessage: invocationMsg, originMessage: undefined, pastTenseMessage: isTerminal ? undefined : pastTenseMsg, - isConfirmed: isSuccess - ? { type: ToolConfirmKind.ConfirmationNotNeeded } - : { type: ToolConfirmKind.Denied }, + isConfirmed: completedToolCallConfirmedReason(tc), isComplete: true, presentation: undefined, subAgentInvocationId: subAgentInvocationId, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts index e3606fc36b9d09..2c9c7e0fc0ca14 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts @@ -26,6 +26,7 @@ export const enum AgentSessionApprovalKind { } export interface IAgentSessionApprovalInfo { + readonly approvalId: string; readonly kind: AgentSessionApprovalKind; readonly label: string; readonly languageId: string | undefined; @@ -34,11 +35,10 @@ export interface IAgentSessionApprovalInfo { } /** - * A stable identity for a specific pending approval, distinguishing it from a - * later, distinct approval on the same session (a fresh `since` yields a new id). + * A stable identity for a specific pending approval. */ export function agentSessionApprovalId(info: IAgentSessionApprovalInfo): string { - return `${info.kind}\u0000${info.label}\u0000${info.since.getTime()}`; + return info.approvalId; } /** @@ -93,7 +93,7 @@ export class AgentSessionApprovalModel extends Disposable { if (current === value) { return; } - if (current !== undefined && value !== undefined && current.kind === value.kind && current.label === value.label && current.languageId === value.languageId) { + if (current !== undefined && value !== undefined && current.approvalId === value.approvalId && current.kind === value.kind && current.label === value.label && current.languageId === value.languageId) { return; } settable.set(value, undefined); @@ -137,6 +137,7 @@ export class AgentSessionApprovalModel extends Disposable { const confirmState = state; setIfChanged({ + approvalId: part.toolCallId, kind, label, languageId, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts index 06ef4c060f3abc..98693e52cc7605 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsBanner.ts @@ -11,6 +11,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID } from '../../common/constants.js'; +import { AgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; type AgentsBannerClickedEvent = { @@ -76,7 +77,7 @@ export function createAgentsBanner( disposables.add(addDisposableListener(button, 'click', () => { options.onButtonClick?.(); telemetryService.publicLog2('agentsBanner.clicked', { source: options.source, action: 'openAgentsWindow' }); - commandService.executeCommand(OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, { forceNewWindow: true }); + commandService.executeCommand(OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, { source: AgentsWindowOpenSource.Banner }); })); const element = $(`.${options.cssClass}`, {}, button); 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 395c3e5f21cee2..3b1c4a70f62e12 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -13,7 +13,7 @@ import '../../../../platform/agentHost/common/agentHostEnablementService.js'; import '../../../../platform/agentHost/browser/agentHostEnablementService.js'; import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostSdkSandboxEnabledSettingId, ClaudePreferAgentHostAgentsSettingId, ClaudePreferAgentHostEditorSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostToolSearchEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; @@ -254,37 +254,30 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.fontFamily', "Controls the font family in chat messages."), default: 'default' }, - 'chat.speechToText.enabled': { + 'dictation.enabled': { type: 'boolean', - markdownDescription: nls.localize('chat.speechToText.enabled', "Enables dictation into the chat input using on-device speech-to-text. When enabled on a supported platform, a microphone button appears in the chat input; the transcription model is downloaded on first use and runs locally."), + markdownDescription: nls.localize('dictation.enabled', "Enables dictation across the product (chat input, editor, and terminal). When enabled on a supported platform, a microphone button appears in the chat input and the dictation shortcut becomes available; the on-device transcription model is downloaded on first use and runs locally."), default: product.quality !== 'stable', tags: ['experimental'] }, - 'chat.speechToText.model': { + 'dictation.model': { type: 'string', enum: [ 'nemotron-speech-streaming-en-0.6b', + 'mai', + ], + enumItemLabels: [ + nls.localize('dictation.model.nemotronStreaming.label', "Nemotron Streaming (English) — On-Device"), + nls.localize('dictation.model.mai.label', "MAI — Cloud"), ], - enumItemLabels: ['Nemotron Streaming (English)'], markdownEnumDescriptions: [ - nls.localize('chat.speechToText.model.nemotronStreaming', "NVIDIA Nemotron streaming RNN-T (English), run through Microsoft Foundry Local. Low-latency, high accuracy, matches the GitHub Copilot app."), + nls.localize('dictation.model.nemotronStreaming', "NVIDIA Nemotron streaming RNN-T (English), run on-device through Microsoft Foundry Local. Works offline; no audio leaves the device. Downloaded on first use and cached on disk."), + nls.localize('dictation.model.mai', "Cloud transcription through the same Microsoft AI voice service used by Voice Mode. Requires a network connection and GitHub sign-in; audio is streamed to the service."), ], - markdownDescription: nls.localize('chat.speechToText.model', "The on-device model used for chat dictation. The model is downloaded on first use and cached on disk. Transcription runs locally through Microsoft Foundry Local."), + markdownDescription: nls.localize('dictation.model', "The model used for dictation. On-device models download on first use and run locally through Microsoft Foundry Local; the cloud option streams audio to the Microsoft AI voice service."), default: 'nemotron-speech-streaming-en-0.6b', tags: ['experimental'] }, - 'chat.speechToText.mode': { - type: 'string', - enum: ['auto', 'toggle', 'pushToTalk'], - enumDescriptions: [ - nls.localize('chat.speechToText.mode.auto', "Tap the dictation shortcut to start and stop, or press and hold it to dictate only while held (push-to-talk)."), - nls.localize('chat.speechToText.mode.toggle', "Tap the dictation shortcut to start dictating and tap again to stop."), - nls.localize('chat.speechToText.mode.pushToTalk', "Press and hold the dictation shortcut to dictate; dictation stops as soon as the shortcut is released."), - ], - markdownDescription: nls.localize('chat.speechToText.mode.description', "Controls how the chat dictation shortcut ({0}) behaves.", '`Cmd/Ctrl+I`'), - default: 'auto', - tags: ['experimental'] - }, 'chat.editor.fontSize': { type: 'number', description: nls.localize('interactiveSession.editor.fontSize', "Controls the font size in pixels in chat codeblocks."), @@ -1435,6 +1428,12 @@ configurationRegistry.registerConfiguration({ default: false, tags: ['experimental', 'advanced'], }, + [AgentHostToolSearchEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.copilot.toolSearch.enabled', "When enabled, Copilot SDK sessions defer MCP and non-core VS Code tools behind a tool-search tool so the model discovers them on demand instead of loading every tool definition up front."), + default: false, + tags: ['experimental', 'advanced'], + }, [AgentHostReasoningEffortOverrideSettingId]: { type: 'string', markdownDescription: nls.localize('chat.agentHost.reasoningEffortOverride', "Overrides the reasoning effort for Copilot SDK agent sessions regardless of the per-model picker value. Set it to a level the selected model supports (for example `low`, `medium`, `high`, or `xhigh`) — choosing a level the model does not support may be rejected by the model. A value that isn't a recognized effort level is ignored and the session falls back to the picker value. Applied when a session is created and when its model changes. Only affects Copilot CLI agent sessions.\n\n**Note**: This is an advanced setting for experimentation."), @@ -2280,21 +2279,46 @@ Registry.as(Extensions.ConfigurationMigration). // The on-device dictation runtime moved to Foundry Local; the old // transformers.js/onnxruntime model IDs no longer resolve and would fail // with an unknown-model error. Map any explicitly-stored legacy value to - // the new default so existing users keep working. + // the new default so existing users keep working. Also migrate the setting + // from its old `chat.speechToText.model` id to `dictation.model`. key: 'chat.speechToText.model', - migrateFn: (value: unknown) => { + migrateFn: (value: unknown, accessor) => { const legacyModelIds = [ 'onnx-community/whisper-tiny', 'onnx-community/whisper-base', 'onnx-community/whisper-small', 'onnx-community/nemotron-3.5-asr-streaming-0.6b-onnx-int4', ]; - if (typeof value === 'string' && legacyModelIds.includes(value)) { - return { value: 'nemotron-speech-streaming-en-0.6b' }; + const migrated = (typeof value === 'string' && legacyModelIds.includes(value)) + ? 'nemotron-speech-streaming-en-0.6b' + : value; + const pairs: ConfigurationKeyValuePairs = [['chat.speechToText.model', { value: undefined }]]; + // Never clobber an explicitly configured new key (e.g. after settings + // sync brought both keys across versions). + if (accessor('dictation.model') === undefined) { + pairs.push(['dictation.model', { value: migrated }]); } - return []; + return pairs; + } + }, + { + // Dictation settings were regrouped under the top-level `dictation.*` + // namespace (they govern dictation across chat, editor, and terminal). + key: 'chat.speechToText.enabled', + migrateFn: (value: unknown, accessor) => { + const pairs: ConfigurationKeyValuePairs = [['chat.speechToText.enabled', { value: undefined }]]; + if (accessor('dictation.enabled') === undefined) { + pairs.push(['dictation.enabled', { value }]); + } + return pairs; } }, + { + // `chat.speechToText.mode` was removed (the shortcut is always tap-toggle / + // hold-to-talk); clear it so it does not linger as an unknown setting. + key: 'chat.speechToText.mode', + migrateFn: () => ([['chat.speechToText.mode', { value: undefined }]]) + }, ]); class ChatResolverContribution extends Disposable { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index b8cf4f346459ce..34fbbdc0b5f38d 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -5,7 +5,9 @@ import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { VSBuffer, encodeBase64 } from '../../../../../base/common/buffer.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { computeLevenshteinDistance } from '../../../../../base/common/diff/diff.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -19,6 +21,9 @@ import { IStorageService, StorageScope } from '../../../../../platform/storage/c import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ILocalTranscriptionModelStatus, ILocalTranscriptionService, LocalTranscriptionModelState } from '../../../../../platform/localTranscription/common/localTranscription.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; +import { IVoiceClientService, IVoiceSessionContext, IVoiceTranscription, IVoiceTurnConfig } from '../../common/voiceClient/voiceClientService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js'; @@ -34,30 +39,55 @@ const SAMPLE_RATE = 16000; const PCM_CAPTURE_CHUNK_SIZE = 4096; /** Setting that enables the dictation feature; a kill-switch for rollout. */ -const ENABLED_SETTING = 'chat.speechToText.enabled'; -/** On-device model (Whisper or Nemotron) to use for dictation. */ -const MODEL_SETTING = 'chat.speechToText.model'; -/** Setting that controls the tap-vs-hold behavior of the dictation shortcut. */ -const MODE_SETTING = 'chat.speechToText.mode'; +const ENABLED_SETTING = 'dictation.enabled'; +/** + * Selects the dictation model. On-device model ids (e.g. + * `nemotron-speech-streaming-en-0.6b`) run through {@link ILocalTranscriptionService}; + * the sentinel {@link MAI_MODEL_ID} routes to the cloud voice service instead. + */ +const MODEL_SETTING = 'dictation.model'; + +/** `dictation.model` sentinel selecting the cloud voice backend used by Voice Mode. */ +const MAI_MODEL_ID = 'mai'; + +/** + * Which backend transcribes dictation audio: + * - `nemo`: an on-device model via {@link ILocalTranscriptionService} (Foundry Local). + * - `mai`: the cloud voice service used by Voice Mode, via {@link IVoiceClientService}. + */ +type DictationBackend = 'nemo' | 'mai'; + +/** How long to wait for the voice websocket to connect before failing an MAI session. */ +const MAI_CONNECT_TIMEOUT_MS = 8000; +/** How long to wait after `ptt_end` for the backend's final transcript before returning what we have. */ +const MAI_FINAL_TIMEOUT_MS = 4000; +/** How long to wait for the backend to acknowledge the opened session before streaming audio anyway. */ +const MAI_SESSION_INIT_TIMEOUT_MS = 4000; type SpeechToTextSessionEvent = { outcome: 'completed' | 'cancelled' | 'error'; + backend: string; surface: string; - mode: string; durationMs: number; segments: number; + partialUpdates: number; transcriptLength: number; + timeToFirstTranscriptMs: number; + finalizeMs: number; errorCode: string; }; type SpeechToTextSessionClassification = { owner: 'meganrogge'; - comment: 'Tracks usage and reliability of built-in on-device dictation (speech-to-text).'; + comment: 'Tracks usage and reliability of built-in dictation (speech-to-text), sliced by backend so backends can be compared.'; outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the dictation session ended.' }; + backend: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which transcription backend was used (nemo on-device or mai cloud).' }; surface: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which surface dictated: chat, editor, or terminal.' }; - mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Configured dictation shortcut mode (auto, toggle, or pushToTalk).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Recording duration in milliseconds.' }; segments: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of transcript segments returned.' }; + partialUpdates: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of interim transcript updates received; a proxy for transcript churn/stability.' }; transcriptLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Character length of the final transcript.' }; + timeToFirstTranscriptMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the first streamed audio chunk to the first transcript update; the backend transcription latency (excludes mic acquisition and model download). -1 when no transcript arrived.' }; + finalizeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from the user stopping recording until the final transcript resolved; the post-stop wait. -1 when not applicable.' }; errorCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Short error identifier when the session failed, else empty.' }; }; @@ -76,6 +106,45 @@ type SpeechToTextModelPrepareClassification = { errorCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Short error identifier when preparation failed, else empty.' }; }; +type SpeechToTextAccuracyEvent = { + backend: string; + surface: string; + submitted: boolean; + dictatedLength: number; + editDistance: number; + editRate: number; + edited: boolean; +}; +type SpeechToTextAccuracyClassification = { + owner: 'meganrogge'; + comment: 'Measures how much dictated text the user edited before sending it, as a proxy for transcription accuracy, sliced by backend so backends can be compared. No transcript text is logged, only aggregate character metrics.'; + backend: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which transcription backend produced the dictated text (nemo on-device or mai cloud).' }; + surface: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which surface dictated: chat, editor, or terminal.' }; + submitted: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the measurement was taken at an actual input submission (true) versus the input being cleared or torn down without a confirmed send (false).' }; + dictatedLength: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Character length of the text originally inserted by dictation.' }; + editDistance: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Levenshtein distance between the dictated text and what the user actually submitted; the number of character corrections.' }; + editRate: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'editDistance normalized by dictatedLength and capped at 1; the fraction of the dictated text that was corrected. Lower is more accurate.' }; + edited: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the dictated text was changed at all before submission.' }; +}; + +/** + * A completed dictation whose text has now left the input (submitted or + * cleared), measured to compare what was dictated against what was sent. Only + * aggregate character metrics are logged; the transcript text never is. + */ +export interface IDictationAccuracyMeasurement { + /** The text originally inserted by dictation. */ + readonly dictatedText: string; + /** The text occupying the dictated region at the moment it left the input. */ + readonly submittedText: string; + /** Backend that produced the dictated text, captured when dictation finished. */ + readonly backend: string; + /** Surface the dictation ran in, for slicing. */ + readonly surface: ChatDictationSurface; + /** Whether this was measured at an actual submit versus a clear/teardown. */ + readonly submitted: boolean; +} + export const enum ChatSpeechToTextState { /** Not recording. */ Idle = 'idle', @@ -163,6 +232,16 @@ export interface IChatSpeechToTextService { /** Abort an in-progress recording without keeping the transcript. */ cancel(): void; + + /** The backend selected for the current/most-recent session (`nemo` or `mai`). */ + readonly currentBackend: string; + + /** + * Report how much a finished dictation was edited before it was submitted, as + * an accuracy proxy. Computes the edit distance internally and logs only + * aggregate metrics; no transcript text is emitted. + */ + logDictationAccuracy(measurement: IDictationAccuracyMeasurement): void; } export class ChatSpeechToTextService extends Disposable implements IChatSpeechToTextService { @@ -218,10 +297,30 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private readonly _localSessionDisposables = this._register(new DisposableStore()); + /** Backend selected for the in-progress session; set at `start`. */ + private _activeBackend: DictationBackend = 'nemo'; + + // --- MAI (cloud voice) session state. --- + /** Disposables for the active MAI session (transcription listener, etc.). */ + private readonly _maiSessionDisposables = this._register(new DisposableStore()); + /** Capture turn id for the active MAI push-to-talk turn. */ + private _maiTurnId = ''; + /** Highest transcription revision seen for the active MAI turn; drops stale/out-of-order events. */ + private _maiRevision = -1; + /** Whether this dictation established the shared voice connection (and may thus tear it down). */ + private _maiOwnsConnection = false; + /** Resolves when the backend emits the final transcript after `ptt_end`. */ + private _maiFinalTranscript: DeferredPromise | undefined; + get isConfigured(): boolean { if (this._configurationService.getValue(ENABLED_SETTING) === false) { return false; } + if (this._getBackend() === 'mai') { + // The cloud backend needs a configured voice websocket endpoint; + // GitHub sign-in and connectivity are validated when a session starts. + return !!this._voiceWsUrl(); + } // On-device transcription needs no configuration — the model downloads // on first use. It is only unavailable where the platform lacks native // inference support (e.g. web). @@ -236,8 +335,15 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // Per-session telemetry accumulators. private _sessionStartMs = 0; private _sessionSegments = 0; + private _sessionPartialUpdates = 0; private _sessionErrorCode = ''; private _sessionSurface: ChatDictationSurface = 'chat'; + /** Timestamp of the first streamed audio chunk, to measure transcription latency. */ + private _firstAudioMs = 0; + /** Timestamp of the first transcript update, to measure transcription latency. */ + private _firstTranscriptMs = 0; + /** Milliseconds from stopping recording to the final transcript resolving; -1 until measured. */ + private _finalizeMs = -1; // Model-preparation telemetry accumulator. `_prepareStartMs` is non-zero // while a preparation is being tracked, so the terminal Ready/Error status @@ -254,6 +360,9 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo @ITelemetryService private readonly _telemetryService: ITelemetryService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, @ILocalTranscriptionService private readonly _localTranscription: ILocalTranscriptionService, + @IVoiceClientService private readonly _voiceClientService: IVoiceClientService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IProductService private readonly _productService: IProductService, @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, ) { @@ -263,12 +372,46 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._preparingContextKey = ChatContextKeys.speechToTextPreparing.bindTo(contextKeyService); this._updateConfiguredContextKey(); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ENABLED_SETTING)) { + if (e.affectsConfiguration(ENABLED_SETTING) || e.affectsConfiguration(MODEL_SETTING)) { this._updateConfiguredContextKey(); } })); } + /** Read the configured dictation backend, derived from the selected model. */ + private _getBackend(): DictationBackend { + return this._configurationService.getValue(MODEL_SETTING) === MAI_MODEL_ID ? 'mai' : 'nemo'; + } + + get currentBackend(): string { + return this._activeBackend; + } + + logDictationAccuracy(measurement: IDictationAccuracyMeasurement): void { + const { dictatedText, submittedText, backend, surface, submitted } = measurement; + if (!dictatedText) { + return; + } + const editDistance = computeLevenshteinDistance(dictatedText, submittedText); + const editRate = Math.min(1, editDistance / dictatedText.length); + this._telemetryService.publicLog2('chatSpeechToText.accuracy', { + backend, + surface, + submitted, + dictatedLength: dictatedText.length, + editDistance, + editRate, + edited: editDistance > 0, + }); + } + + /** Voice websocket endpoint used by the MAI backend (shared with Voice Mode). */ + private _voiceWsUrl(): string { + const configured = this._configurationService.getValue('agents.voice.backendUrl'); + const url = typeof configured === 'string' ? configured.trim() : ''; + return url || this._productService.voiceWsUrl || ''; + } + private _updateConfiguredContextKey(): void { this._configuredContextKey.set(this.isConfigured); } @@ -298,27 +441,24 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return; } const durationMs = Date.now() - this._sessionStartMs; + const timeToFirstTranscriptMs = this._firstAudioMs && this._firstTranscriptMs + ? Math.max(0, this._firstTranscriptMs - this._firstAudioMs) + : -1; this._telemetryService.publicLog2('chatSpeechToText.session', { outcome, + backend: this._activeBackend, surface: this._sessionSurface, - mode: this._getDictationMode(), durationMs, segments: this._sessionSegments, + partialUpdates: this._sessionPartialUpdates, transcriptLength: this._transcript.length, + timeToFirstTranscriptMs, + finalizeMs: this._finalizeMs, errorCode: this._sessionErrorCode, }); this._sessionStartMs = 0; } - /** - * Read the configured dictation shortcut mode for telemetry, normalizing any - * unexpected value to the `auto` default so the event stays low-cardinality. - */ - private _getDictationMode(): string { - const value = this._configurationService.getValue(MODE_SETTING); - return value === 'toggle' || value === 'pushToTalk' ? value : 'auto'; - } - /** * Emit the model-preparation telemetry event once, when the on-device model * reaches a terminal state (ready or error). `_prepareStartMs` guards against @@ -361,18 +501,37 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return; } - if (!this._localTranscription.isSupported) { + const backend = this._getBackend(); + this._activeBackend = backend; + + if (backend === 'nemo' && !this._localTranscription.isSupported) { this._notificationService.notify({ severity: Severity.Warning, message: localize('chatStt.notSupported', "On-device speech-to-text is not available on this platform."), }); return; } + if (backend === 'mai' && !this._voiceWsUrl()) { + this._notificationService.notify({ + severity: Severity.Warning, + message: localize('chatStt.maiNotConfigured', "Cloud speech-to-text is not available: no voice service is configured."), + }); + return; + } this._sessionStartMs = Date.now(); this._sessionSegments = 0; + this._sessionPartialUpdates = 0; this._sessionErrorCode = ''; this._sessionSurface = surface; + this._firstAudioMs = 0; + this._firstTranscriptMs = 0; + this._finalizeMs = -1; + // Defensively clear any transcript left over from a previous session so a + // new dictation never starts by re-emitting the prior transcript (teardown + // already clears these, but a start without a clean teardown must not leak). + this._finalizedText = ''; + this._deltaText = ''; let stream: MediaStream; try { @@ -388,7 +547,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._mediaStream = stream; try { - await this._startLocalSession(); + await this._startBackendSession(window); } catch (err) { this._teardown(); this._sessionErrorCode = this._sessionErrorCode || 'connect'; @@ -402,9 +561,9 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo await this._startCapture(window, stream); } catch (err) { // Capture setup (AudioContext/nodes) can fail after the mic and the - // utility-process session are already live; make sure both are torn + // transcription session are already live; make sure both are torn // down instead of leaking an active recording in the Idle state. - this._localTranscription.cancel(); + this._cancelBackend(); this._teardown(); this._sessionErrorCode = this._sessionErrorCode || 'capture'; this._logSessionTelemetry('error'); @@ -422,6 +581,197 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } } + /** Start the transcription session for the active backend. */ + private async _startBackendSession(window: Window & typeof globalThis): Promise { + if (this._activeBackend === 'mai') { + return this._startMaiSession(window); + } + return this._startLocalSession(); + } + + /** + * Record a transcript update on the shared cumulative surface and accumulate + * the latency/stability telemetry, regardless of backend. `text` is the full + * cumulative transcript; `finalizedText` is its committed (non-shimmering) + * prefix; `isFinal` marks the terminal update after the session stops. + */ + private _emitTranscript(text: string, finalizedText: string, isFinal: boolean): void { + this._finalizedText = text; + this._deltaText = ''; + if (!isFinal) { + this._sessionSegments++; + this._sessionPartialUpdates++; + } + if (this._firstTranscriptMs === 0 && this._transcript.length > 0) { + this._firstTranscriptMs = Date.now(); + } + this._onDidUpdateTranscript.fire({ text: this._transcript, finalizedText }); + } + + /** + * Begin a cloud transcription session over the shared Voice Mode websocket: + * connect, then open a single push-to-talk turn whose streamed audio the + * backend transcribes. Interim/final `transcription` events are piped onto + * the shared cumulative-transcript surface. + * + * The websocket is a single connection shared with Voice Mode. We refuse to + * start when it is already connected (another owner holds it) and only tear + * down a connection we ourselves established, so dictation and Voice Mode + * cannot disconnect each other. + */ + private async _startMaiSession(window: Window & typeof globalThis): Promise { + if (this._voiceClientService.isConnected) { + throw new Error(localize('chatStt.maiBusy', "Cloud dictation is unavailable while Voice Mode is connected.")); + } + const authToken = await this._getGitHubToken(); + if (!authToken) { + throw new Error(localize('chatStt.maiSignIn', "Sign in to GitHub to use cloud dictation.")); + } + + this._maiTurnId = generateUuid(); + this._maiRevision = -1; + this._maiSessionDisposables.add(this._voiceClientService.onTranscription(e => this._handleMaiTranscription(e))); + // A terminal close (e.g. code 4008 when another window takes over the + // single voice session) stops reconnection; without this the mic would + // stay open in Recording while audio is silently dropped. + this._maiSessionDisposables.add(this._voiceClientService.onFatalDisconnect(() => + this._failMaiSession(localize('chatStt.maiDisconnected', "Cloud dictation was disconnected.")))); + this._maiSessionDisposables.add(this._voiceClientService.onError(msg => + this._logService.warn(`[chat-stt] voice service error during dictation: ${msg}`))); + + // We are initiating the connection; mark ownership before connecting so a + // failed/partial connect is still torn down by our teardown path. + this._maiOwnsConnection = true; + // Connecting to the cloud voice service and opening the session takes a + // moment on the first dictation; surface the same spinner affordance the + // on-device path uses while its model prepares. Cleared once the session + // is established (below) or by teardown on failure. + this._setPreparingModel(true); + await this._voiceClientService.connect(window, authToken); + await this._awaitVoiceConnected(); + + // The backend drops PTT audio until a session is opened, so establish a + // minimal (session-less) dictation session and wait for the backend to + // acknowledge it before streaming audio. The websocket preserves order, + // but the ack guarantees the session exists server-side first. + // + // Dictation is one continuous turn: the user taps to start, speaks + // several phrases with pauses in between, and taps to stop. Disable the + // backend's automatic turn endpointing (VAD silence / stop phrases) so a + // pause between phrases does not end the turn — otherwise everything + // after the first pause lands in a new (dropped) turn and is lost. + const context: IVoiceSessionContext = { sessions: [], display_locale: '' }; + const turnConfig: IVoiceTurnConfig = { auto_end_mode: 'off', silence_ms: 0, stop_phrases: [], vad_gate_asr: false }; + this._voiceClientService.sendStartSession(context, this._telemetryService.machineId, undefined, turnConfig); + await this._awaitSessionInit(); + + // Session is live; drop the connecting spinner so the mic reads as + // recording when start() transitions to the Recording state. + this._setPreparingModel(false); + this._voiceClientService.sendPttStart(this._maiTurnId); + } + + /** + * Wait for the backend to acknowledge the opened session (`onSessionInit`), + * resolving on a timeout so a missing ack cannot wedge dictation: the + * websocket preserves order, so `ptt_start` still follows `start_session`. + */ + private async _awaitSessionInit(): Promise { + await new Promise(resolve => { + const store = new DisposableStore(); + this._maiSessionDisposables.add(store); + const timer = setTimeout(() => { + store.dispose(); + resolve(); + }, MAI_SESSION_INIT_TIMEOUT_MS); + store.add(toDisposable(() => clearTimeout(timer))); + store.add(this._voiceClientService.onSessionInit(() => { + store.dispose(); + resolve(); + })); + }); + } + + /** + * Handle a transcription event from the shared voice socket. Events for a + * different (non-empty) turn are dropped so a stale/foreign frame — e.g. a + * replay from a previous session on the shared backend — cannot resurrect + * the prior transcript; a frame without a turnId is accepted since the + * conversational socket does not always tag transcription frames. Within our + * turn, a stale (non-increasing) revision is dropped so a late event cannot + * overwrite newer text or resolve the final waiter early. `text` is the full + * cumulative transcript for the turn. + */ + private _handleMaiTranscription(e: IVoiceTranscription): void { + if (e.turnId !== undefined && this._maiTurnId && e.turnId !== this._maiTurnId) { + this._logService.trace(`[chat-stt] mai transcription dropped (turn ${e.turnId} != ${this._maiTurnId})`); + return; + } + if (e.revision !== undefined) { + if (e.revision <= this._maiRevision) { + this._logService.trace(`[chat-stt] mai transcription dropped (revision ${e.revision} <= ${this._maiRevision})`); + return; + } + this._maiRevision = e.revision; + } + this._logService.trace(`[chat-stt] mai transcription status=${e.status ?? 'none'} revision=${e.revision ?? 'none'} len=${e.text.length}`); + this._emitTranscript(e.text, e.committed ?? '', e.status === 'final'); + if (e.status === 'final') { + this._maiFinalTranscript?.complete(); + } + } + + /** + * Abort an in-progress MAI dictation after a terminal disconnect: log the + * failure, release the final waiter so `stopAndTranscribe` does not hang, + * tear down the mic/session, and surface an actionable message. + */ + private _failMaiSession(message: string): void { + if (this._activeBackend !== 'mai' || this._state === ChatSpeechToTextState.Idle) { + return; + } + this._sessionErrorCode = this._sessionErrorCode || 'disconnect'; + this._logSessionTelemetry('error'); + this._maiFinalTranscript?.complete(); + this._cancelBackend(); + this._teardown(); + this._setState(ChatSpeechToTextState.Idle); + this._notificationService.error(message); + } + + /** Resolve the GitHub access token used to authenticate the voice websocket. */ + private async _getGitHubToken(): Promise { + try { + const sessions = await this._authenticationService.getSessions('github'); + return sessions[0]?.accessToken; + } catch (err) { + this._logService.warn('[chat-stt] could not resolve a GitHub session for cloud dictation', err); + return undefined; + } + } + + /** Wait for the voice websocket to report connected, or reject on timeout. */ + private async _awaitVoiceConnected(): Promise { + if (this._voiceClientService.isConnected) { + return; + } + await new Promise((resolve, reject) => { + const store = new DisposableStore(); + this._maiSessionDisposables.add(store); + const timer = setTimeout(() => { + store.dispose(); + reject(new Error('Timed out connecting to the voice service.')); + }, MAI_CONNECT_TIMEOUT_MS); + store.add(toDisposable(() => clearTimeout(timer))); + store.add(this._voiceClientService.onDidChangeConnectionState(connected => { + if (connected) { + store.dispose(); + resolve(); + } + })); + }); + } + /** * Begin an on-device transcription session in the utility process and pipe * its interim/final results onto the shared cumulative-transcript surface. @@ -430,12 +780,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo const local = this._localTranscription; this._localSessionDisposables.add(local.onDidTranscribe(result => { // The local service returns the full cumulative transcript each time. - this._finalizedText = result.text; - this._deltaText = ''; - if (!result.isFinal) { - this._sessionSegments++; - } - this._onDidUpdateTranscript.fire({ text: this._transcript, finalizedText: result.finalizedText ?? '' }); + this._emitTranscript(result.text, result.finalizedText ?? '', result.isFinal); })); const cacheDir = joinPath(this._environmentService.cacheHome, 'chatDictationModels').fsPath; const model = this._getModelId(); @@ -602,7 +947,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } this._sessionErrorCode = this._sessionErrorCode || errorCode; this._logSessionTelemetry('error'); - this._localTranscription.cancel(); + this._cancelBackend(); this._teardown(); this._setState(ChatSpeechToTextState.Idle); this._notificationService.error(message); @@ -630,26 +975,46 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._stopCapture(); this._accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStopped); + const stopMs = Date.now(); let text = this._transcript; try { - const finalText = await this._localTranscription.stop(); + const finalText = await this._finishBackend(); if (finalText) { text = finalText; } } catch (err) { this._sessionErrorCode = this._sessionErrorCode || 'transcribe'; - this._logService.error('[chat-stt] on-device final transcription failed', err); + this._logService.error('[chat-stt] final transcription failed', err); } + this._finalizeMs = Date.now() - stopMs; this._logSessionTelemetry(this._sessionErrorCode ? 'error' : 'completed'); this._teardown(); this._setState(ChatSpeechToTextState.Idle); return text || undefined; } + /** + * Finish the active backend's turn and resolve with its final transcript: + * the on-device service's `stop()`, or — for MAI — a `ptt_end` followed by a + * short wait for the backend's final `transcription`. + */ + private async _finishBackend(): Promise { + if (this._activeBackend === 'mai') { + this._maiFinalTranscript = new DeferredPromise(); + this._voiceClientService.sendPttEnd(); + await Promise.race([ + this._maiFinalTranscript.p, + new Promise(resolve => setTimeout(resolve, MAI_FINAL_TIMEOUT_MS)), + ]); + return this._transcript; + } + return this._localTranscription.stop(); + } + cancel(): void { const wasRecording = this._state === ChatSpeechToTextState.Recording; this._logSessionTelemetry('cancelled'); - this._localTranscription.cancel(); + this._cancelBackend(); this._teardown(); this._setState(ChatSpeechToTextState.Idle); if (wasRecording) { @@ -657,6 +1022,19 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } } + /** Abort the active backend's session, discarding any transcript in flight. */ + private _cancelBackend(): void { + if (this._activeBackend === 'mai') { + // Only tear down a connection we established (never Voice Mode's). + if (this._maiOwnsConnection) { + this._voiceClientService.disconnect(); + this._maiOwnsConnection = false; + } + return; + } + this._localTranscription.cancel(); + } + private async _startCapture(window: Window & typeof globalThis, stream: MediaStream): Promise { const ctx = new window.AudioContext({ sampleRate: SAMPLE_RATE }); this._audioContext = ctx; @@ -671,7 +1049,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // is deprecated and its `onaudioprocess` callback is throttled/stops on the // main thread; the worklet runs on the audio thread and streams PCM reliably. const node = await createPcmCaptureNode(window, ctx, PCM_CAPTURE_CHUNK_SIZE, samples => { - this._localTranscription.pushAudio(encodeRawPcm16Buffer(samples)).catch(err => this._onAudioPushError(err)); + this._pushAudio(samples, window); }); // The session may have been torn down while the module was loading. @@ -685,6 +1063,22 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo node.connect(ctx.destination); } + /** + * Stream one captured PCM16 chunk to the active backend, recording the + * first-chunk timestamp used for transcription-latency telemetry. + */ + private _pushAudio(samples: Float32Array, window: Window & typeof globalThis): void { + if (this._firstAudioMs === 0) { + this._firstAudioMs = Date.now(); + } + const buffer = encodeRawPcm16Buffer(samples); + if (this._activeBackend === 'mai') { + this._voiceClientService.sendPttAudioChunk(encodeBase64(buffer)); + return; + } + this._localTranscription.pushAudio(buffer).catch(err => this._onAudioPushError(err)); + } + private _stopCapture(): void { if (this._workletNode) { this._workletNode.port.onmessage = null; @@ -707,6 +1101,18 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo // model reached a terminal state does not emit a model-prepare event. this._prepareStartMs = 0; this._localSessionDisposables.clear(); + // Release the cloud voice session and its listeners (idempotent if the + // backend was already cancelled/disconnected). + this._maiSessionDisposables.clear(); + this._maiFinalTranscript = undefined; + this._maiTurnId = ''; + this._maiRevision = -1; + // Release the shared voice connection only if this dictation owns it, so + // tearing down never disconnects a session Voice Mode established. + if (this._activeBackend === 'mai' && this._maiOwnsConnection) { + this._voiceClientService.disconnect(); + this._maiOwnsConnection = false; + } // Do not retain transcript text beyond the session that produced it. this._finalizedText = ''; this._deltaText = ''; diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts index 208760015f5fa0..60efb1955652f9 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { IManagedHoverContent } from '../../../../../base/browser/ui/hover/hover.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; @@ -48,7 +50,18 @@ export class DictationDownloadActionViewItem extends MenuEntryActionViewItem { super.render(container); container.classList.add('dictation-download-item'); - this._register(new DictationDownloadRing(container, this._speechToTextService)); + // The on-device backend downloads a model, so show a determinate progress + // ring around the download icon. The cloud backend only connects (no + // download), so its icon is swapped for a plain spinner instead. + if (this._speechToTextService.currentBackend !== 'mai') { + this._register(new DictationDownloadRing(container, this._speechToTextService)); + } + + // super.render() applies the action's cloud-download glyph via + // _updateItemClass() directly (not through updateClass()), so apply the + // cloud-backend spinner swap here too — otherwise the mic/cloud glyph is + // what renders on first paint in the OSS toolbar. + this._applyMaiSpinner(); // Keep the mic context menu available while the model prepares so the // affordance doesn't lose Select Microphone / Disable Dictation during @@ -60,7 +73,29 @@ export class DictationDownloadActionViewItem extends MenuEntryActionViewItem { )); } + protected override updateClass(): void { + super.updateClass(); + this._applyMaiSpinner(); + } + + /** + * For the cloud backend, replace the action's cloud-download glyph with a + * loading spinner so the mic reads as "connecting" rather than downloading. + * The base class re-adds the cloud-download classes on every render/update, so + * this must run after both super.render() and super.updateClass(). Uses a + * dedicated marker class (not codicon-modifier-spin) so only the glyph spins, + * regardless of the surrounding toolbar, rather than the whole button. + */ + private _applyMaiSpinner(): void { + if (this._speechToTextService.currentBackend !== 'mai' || !this.label) { + return; + } + const cloudClasses = ThemeIcon.asClassNameArray(Codicon.cloudDownload); + this.label.classList.remove(...cloudClasses); + this.label.classList.add('codicon', 'codicon-loading', 'dictation-connecting-spinner'); + } + protected override getHoverContents(): IManagedHoverContent { - return getDictationDownloadHoverContent(); + return getDictationDownloadHoverContent(this._speechToTextService); } } diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts index 35ce1871a7e236..4a3340d46e0030 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts @@ -75,12 +75,18 @@ export class DictationDownloadRing extends Disposable { } /** - * Static hover explaining that the on-device dictation model is downloading. - * The ring itself conveys the live progress, so the hover stays fixed to avoid - * churning the tooltip on every progress tick. + * Static hover explaining what the mic is doing while it prepares. The on-device + * backend downloads a model; the cloud backend connects. The ring conveys live + * progress/activity, so the hover stays fixed to avoid churning on every tick. */ -export function getDictationDownloadHoverContent(): IManagedHoverContent { +export function getDictationDownloadHoverContent(service: IChatSpeechToTextService): IManagedHoverContent { const markdown = new MarkdownString('', { supportThemeIcons: true }); + if (service.currentBackend === 'mai') { + markdown.appendMarkdown(localize('chatStt.hover.connectingTitle', "**Connecting to dictation service**")); + markdown.appendMarkdown('\n\n'); + markdown.appendMarkdown(localize('chatStt.hover.connecting', "Establishing a connection. This happens each time you start cloud dictation.")); + return { markdown, markdownNotSupportedFallback: markdown.value }; + } markdown.appendMarkdown(localize('chatStt.hover.title', "**Downloading speech-to-text model**")); markdown.appendMarkdown('\n\n'); markdown.appendMarkdown(localize('chatStt.hover.preparing', "Preparing the on-device model. This happens only the first time you dictate.")); @@ -94,6 +100,11 @@ export function getDictationDownloadHoverContent(): IManagedHoverContent { * message (indeterminate download or loading into memory). */ export function getDictationPreparingLabel(service: IChatSpeechToTextService): string { + // The cloud backend connects rather than downloading a model, so describe it + // as connecting; there is no percentage to report. + if (service.currentBackend === 'mai') { + return localize('chatStt.preparing.connecting', "Connecting to dictation service…"); + } const progress = service.modelDownloadProgress; if (typeof progress === 'number') { const percent = Math.max(0, Math.min(100, Math.round(progress * 100))); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index 9fc8906670847a..84240e10ac0825 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -9,6 +9,7 @@ import { DisposableStore, MutableDisposable, toDisposable } from '../../../../.. import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; import { IEditorDecorationsCollection } from '../../../../../editor/common/editorCommon.js'; +import { TrackedRangeStickiness } from '../../../../../editor/common/model.js'; import { Position } from '../../../../../editor/common/core/position.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { Selection } from '../../../../../editor/common/core/selection.js'; @@ -248,6 +249,22 @@ class LiveTranscriptInserter { this._shimmerDecorations?.clear(); } + /** + * Range covering the finalized transcript text this inserter wrote, + * excluding any leading space it prepended, so its content equals the + * transcript exactly. `undefined` before anything is inserted. Used to track + * the dictated span for accuracy telemetry after the session ends. + */ + finalizedRange(): Range | undefined { + if (!this._anchor || !this._end) { + return undefined; + } + const start = this._needsLeadingSpace + ? new Position(this._anchor.lineNumber, this._anchor.column + 1) + : this._anchor; + return Range.fromPositions(start, this._end); + } + /** * Remove everything this inserter has written (including any leading space it * added) and restore the caret to where dictation began. Used when dictation @@ -277,6 +294,7 @@ interface IActiveDictation { readonly inserter: LiveTranscriptInserter; readonly disposables: DisposableStore; readonly logService: ILogService; + readonly surface: ChatDictationSurface; } /** @@ -379,7 +397,7 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // composer is closed); cancel dictation instead of leaving the microphone // and local transcription running against a dead editor. disposables.add(editor.onDidDispose(() => cancelDictation())); - _active = { service, editor, inserter, disposables, logService }; + _active = { service, editor, inserter, disposables, logService, surface }; try { await service.start(window, surface); } catch { @@ -409,6 +427,9 @@ export async function stopDictation(): Promise { if (text !== undefined) { // Final transcript: render it solid (no shimmer). active.inserter.update(text, false); + // Track how much of this dictated text the user edits before sending, + // as an accuracy signal comparing the backends. + trackDictationAccuracy(active, text); } else { // No final transcript to apply; make sure the shimmer does not linger // over the last interim text. @@ -437,3 +458,87 @@ export function cancelDictation(): void { active.disposables.dispose(); active.service.cancel(); } + +/** + * After a dictation finishes, watch the dictated span until its text leaves the + * input and then report how much it was edited in the meantime as an accuracy + * signal. Preferably triggered by an actual submit (see + * {@link notifyDictationSubmitted}); otherwise falls back to the input being + * cleared or the editor being torn down. + * + * The dictated region is followed with a tracked decoration so it stays aligned + * as the user edits around it; edits typed at its edges are excluded so + * unrelated text appended after the dictation is not counted. Only aggregate + * character metrics are logged — never the transcript text. Runs independently + * of the (already-disposed) dictation session and cleans itself up on measure. + */ +interface IDictationAccuracyTracker { + readonly editor: ICodeEditor; + measure(submitted: boolean): void; +} + +/** + * Live accuracy trackers awaiting their dictated text to leave the input. Keyed + * at module scope (mirroring {@link _active}) so a submit handler can resolve + * the tracker(s) for its editor via {@link notifyDictationSubmitted}. + */ +const _accuracyTrackers = new Set(); + +/** + * Called by an input's submit path to measure any pending dictation accuracy + * against the text actually being sent, before the input is cleared. This is + * the precise signal; without it a tracker falls back to the clear/teardown + * heuristic and reports `submitted: false`. + */ +export function notifyDictationSubmitted(editor: ICodeEditor): void { + for (const tracker of [..._accuracyTrackers]) { + if (tracker.editor === editor) { + tracker.measure(true); + } + } +} + +function trackDictationAccuracy(active: IActiveDictation, dictatedText: string): void { + const { editor, inserter, service, surface } = active; + const model = editor.getModel(); + const range = inserter.finalizedRange(); + if (!model || !range || !dictatedText) { + return; + } + const backend = service.currentBackend; + const collection = editor.createDecorationsCollection([{ + range, + options: { + description: 'chatSpeechToText-accuracy', + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }, + }]); + const store = new DisposableStore(); + let measured = false; + const tracker: IDictationAccuracyTracker = { + editor, + measure(submitted: boolean) { + if (measured) { + return; + } + measured = true; + const current = collection.getRange(0); + const submittedText = current ? model.getValueInRange(current) : ''; + service.logDictationAccuracy({ dictatedText, submittedText, backend, surface, submitted }); + collection.clear(); + store.dispose(); + _accuracyTrackers.delete(tracker); + }, + }; + // Fallbacks when no submit signal arrives: submitting the chat input clears + // the editor to empty (also covers a manual clear-all), and the editor can + // be torn down with dictated text still in it. + store.add(model.onDidChangeContent(() => { + if (model.getValueLength() === 0) { + tracker.measure(false); + } + })); + store.add(model.onWillDispose(() => tracker.measure(false))); + store.add(editor.onDidDispose(() => tracker.measure(false))); + _accuracyTrackers.add(tracker); +} diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index 1d143a27dfac18..581d5c8b7bd8f4 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -23,7 +23,7 @@ const VOICE_DISCONNECT_COMMAND = 'agentsVoice.disconnect'; /** Command that opens the Voice Mode settings; the affordance that used to live behind the toolbar gear. */ const VOICE_OPEN_SETTINGS_COMMAND = 'agentsVoice.openSettings'; /** Setting that enables dictation; toggled off by "Disable Dictation". */ -const DICTATION_ENABLED_SETTING = 'chat.speechToText.enabled'; +const DICTATION_ENABLED_SETTING = 'dictation.enabled'; /** Setting that enables Voice Mode; toggled off by "Disable Voice Mode". */ const VOICE_ENABLED_SETTING = 'agents.voice.enabled'; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index fe363bf6f3ff63..7480c17e3482a2 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -742,13 +742,13 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic * can answer recall questions across reconnects without backend * persistence. See ``IVoicePriorTimelineEntry``. */ - sendStartSession(context: IVoiceSessionContext, machineId: string, priorTimeline?: readonly IVoicePriorTimelineEntry[]): void { + sendStartSession(context: IVoiceSessionContext, machineId: string, priorTimeline?: readonly IVoicePriorTimelineEntry[], turnConfigOverride?: IVoiceTurnConfig): void { if (this._ws?.readyState === WebSocket.OPEN) { const sessionContext = { ...context, display_locale: this._getLanguage() }; this._seedTracking(sessionContext); // This client drives narration itself via `requestNarration`, so opt out // of the backend's default context-delta auto-narration to avoid double narration. - const payload: Record = { type: 'start_session', session_context: sessionContext, machine_id: machineId, turn_config: this._getTurnConfig(), voice: this._getVoice(), auto_narrate: false }; + const payload: Record = { type: 'start_session', session_context: sessionContext, machine_id: machineId, turn_config: turnConfigOverride ?? this._getTurnConfig(), voice: this._getVoice(), auto_narrate: false }; if (priorTimeline && priorTimeline.length > 0) { payload.prior_timeline = priorTimeline; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts index 51e50087a1d037..375932083c15da 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts @@ -444,14 +444,8 @@ export class ChatInputModelSelectionController extends Disposable { this._selectionReason = ModelSelectionReason.FirstAvailable; this._applyModel(replacement); this.ensureCurrentModelSupported(); - if (this._runtime.resolveModelIdentifier(intent.modelId).kind !== 'pending') { - this._intent = undefined; - } return true; } - if (this._runtime.resolveModelIdentifier(intent.modelId).kind !== 'pending') { - this._intent = undefined; - } return false; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index caaf830294cc6a..be047cadc37fa8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -55,6 +55,7 @@ import { SuggestController } from '../../../../../../editor/contrib/suggest/brow import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { MenuWorkbenchButtonBar } from '../../../../../../platform/actions/browser/buttonbar.js'; +import { MenuEntryActionViewItem } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -106,10 +107,11 @@ import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chat import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; -import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatEditingSessionSubmitAction, ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; import { ChatSpeechToTextPreparingAction, ToggleChatSpeechToTextAction } from '../../actions/chatSpeechToTextActions.js'; import { DictationActionViewItem } from '../../speechToText/dictationActionViewItem.js'; import { DictationDownloadActionViewItem } from '../../speechToText/dictationDownloadActionViewItem.js'; +import { notifyDictationSubmitted } from '../../speechToText/dictationSession.js'; import { VoiceModeActionViewItem } from '../../voiceClient/voiceModeActionViewItem.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; @@ -2163,6 +2165,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Clear attached context, fire event to clear input state, and clear the input editor + // Measure any pending dictation accuracy against the text actually being + // sent, before the editor is cleared below. + notifyDictationSubmitted(this._inputEditor); logChangesToStateModel(this._inputModel, `[ACCEPT] acceptInput -> attachmentModel.clear() in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); this.attachmentModel.clear(); this._onDidLoadInputState.fire(); @@ -3223,6 +3228,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge hoverDelegate, hiddenItemStrategy: HiddenItemStrategy.NoHide, actionViewItemProvider: (action, options) => { + if ((action.id === ChatSubmitAction.ID || action.id === ChatEditingSessionSubmitAction.ID) && action instanceof MenuItemAction) { + return this.instantiationService.createInstance(class extends MenuEntryActionViewItem { + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-submit-button'); + } + }, action, options); + } if (action.id === ChatSpeechToTextPreparingAction.ID && action instanceof MenuItemAction) { return this.instantiationService.createInstance(DictationDownloadActionViewItem, action, options); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index f3a62e94509dcd..a4576132a75266 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1183,15 +1183,15 @@ have to be updated for changes to the rules above, or to support more deeply nes order: 2; } -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button { position: relative; - border-radius: var(--vscode-cornerRadius-circle); - /* Separate the round primary send button from the preceding mic / voice-mode + border-radius: var(--vscode-cornerRadius-small); + /* Separate the primary send button from the preceding mic / voice-mode toggle icons so it doesn't visually crowd them (see #326932). */ margin-left: 4px; } -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button > .action-label { box-sizing: border-box; width: 22px; height: 22px; @@ -1199,39 +1199,34 @@ have to be updated for changes to the rules above, or to support more deeply nes } /* Nudge icon position to ensure it looks optically aligned within the button */ -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item > .action-label.codicon-arrow-up::before { - display: inline-block; - transform: translateX(-0.5px); -} - -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item > .action-label.codicon-newline::before { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button > .action-label.codicon-newline::before { display: inline-block; transform: translateY(0.5px); } /* Focus indicator drawn on the action-item wrapper so it sits cleanly around the button surface with a small offset. */ -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled):has(> .action-label:focus-visible) { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; - border-radius: var(--vscode-cornerRadius-circle); + border-radius: var(--vscode-cornerRadius-small); } -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus, -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus-visible { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button > .action-label:focus, +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button > .action-label:focus-visible { outline: none; } /* Idle: solid primary button background. */ -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled) > .action-label { background: var(--vscode-button-background); color: var(--vscode-button-foreground) !important; - border-radius: var(--vscode-cornerRadius-circle); + border-radius: var(--vscode-cornerRadius-small); transition: background-color 120ms ease; } -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover, -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible { +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled) > .action-label:hover, +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled) > .action-label:focus-visible { background: var(--vscode-button-hoverBackground); } @@ -4592,6 +4587,21 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } +/* Cloud dictation connect: the cloud backend has no model download, so its + preparing affordance swaps the glyph for a loading spinner (see + DictationDownloadActionViewItem._applyMaiSpinner). Spin only the glyph + (::before), not the whole button, and independently of the surrounding + toolbar so it reads as a clean spinner rather than a rotating mic/button. */ +.monaco-action-bar .action-item.dictation-download-item .action-label.dictation-connecting-spinner { + animation: none; +} + +.monaco-action-bar .action-item.dictation-download-item .action-label.dictation-connecting-spinner::before { + display: inline-block; + transform-origin: center center; + animation: codicon-spin 1.5s steps(30) infinite; +} + .monaco-action-bar .action-item.dictation-download-item > .dictation-download-ring { position: absolute; top: 50%; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 734f9ee9a89830..e3e15f9a51516d 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -271,8 +271,8 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements protected async _refreshPlugins(): Promise { const version = ++this._discoverVersion; - const plugins = await this._discoverAndBuildPlugins(); - if (version !== this._discoverVersion || this._store.isDisposed) { + const plugins = await this._discoverAndBuildPlugins(version); + if (!this._isCurrentRefresh(version)) { return; } @@ -282,8 +282,12 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements /** Subclasses return plugin sources to discover. */ protected abstract _discoverPluginSources(): Promise; - private async _discoverAndBuildPlugins(): Promise { + private async _discoverAndBuildPlugins(version: number): Promise { const sources = await this._discoverPluginSources(); + if (!this._isCurrentRefresh(version)) { + return []; + } + const plugins: IAgentPlugin[] = []; const seenPluginUris = new Set(); const attemptedPluginUris = new Set(); @@ -294,7 +298,10 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements attemptedPluginUris.add(key); try { const format = await detectPluginFormat(source.uri, this._fileService); - const plugin = await this._toPlugin(source.uri, format, source.fromMarketplace, source.repositoryUri, source.remove); + if (!this._isCurrentRefresh(version)) { + return []; + } + const plugin = await this._toPlugin(source.uri, format, source.fromMarketplace, source.repositoryUri, source.remove, version); seenPluginUris.add(key); plugins.push(plugin); } catch (error) { @@ -303,12 +310,18 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - this._disposePluginEntriesExcept(seenPluginUris); + if (this._isCurrentRefresh(version)) { + this._disposePluginEntriesExcept(seenPluginUris); + } plugins.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())); return plugins; } + private _isCurrentRefresh(version: number): boolean { + return version === this._discoverVersion && !this._store.isDisposed; + } + protected async _pathExists(resource: URI): Promise { try { await this._fileService.resolve(resource); @@ -318,14 +331,18 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => void) | undefined): Promise { + private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => void) | undefined, version: number): Promise { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { + if (!this._isCurrentRefresh(version)) { + return existing.plugin; + } if (existing.format.format !== format.format) { existing.store.dispose(); this._pluginEntries.delete(key); } else { + existing.plugin.remove = removeCallback; return existing.plugin; } } @@ -470,7 +487,11 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements fromMarketplace, }; - this._pluginEntries.set(key, { store, plugin, format }); + if (this._isCurrentRefresh(version)) { + this._pluginEntries.set(key, { store, plugin, format }); + } else { + store.dispose(); + } return plugin; } diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index 8290593d1f9fb0..448eb2bc42260d 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -252,7 +252,7 @@ export interface IVoiceClientService { */ sendSessionStateChange(sessionId: string, newState: string, label: string, detail?: string, lastResponseSummary?: string): void; stopSpeaking(): void; - sendStartSession(context: IVoiceSessionContext, machineId: string, priorTimeline?: readonly IVoicePriorTimelineEntry[]): void; + sendStartSession(context: IVoiceSessionContext, machineId: string, priorTimeline?: readonly IVoicePriorTimelineEntry[], turnConfigOverride?: IVoiceTurnConfig): void; sendResumeSession(context: IVoiceSessionContext, machineId: string): void; // --- Feedback --- diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 0767580e187239..8a093806d0dee3 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -38,36 +38,82 @@ import { OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, OPEN_AGENTS_WINDOW_PRECONDI import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { AgentsWindowOpenSource, isAgentsWindowOpenSource } from '../../../../../platform/window/common/window.js'; + +const OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE = localize2('openWorkspaceInAgentsWindow', "Open in Agents"); +const OPEN_WORKSPACE_IN_AGENTS_WINDOW_CHAT_TITLE_COMMAND_ID = 'workbench.action.chat.openWorkspaceInAgentsWindow.chatTitle'; +const OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE_BAR_COMMAND_ID = 'workbench.action.chat.openWorkspaceInAgentsWindow.titleBar'; + +async function openCurrentWorkspaceInAgentsWindow(accessor: ServicesAccessor, source: AgentsWindowOpenSource): Promise { + const nativeHostService = accessor.get(INativeHostService); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; + await nativeHostService.openAgentsWindow({ folderUri: folderUri?.scheme === Schemas.file ? folderUri : undefined, source }); +} + +function isOpenChatSessionInAgentsWindowOptions(value: unknown): value is { readonly agentsWindowOpenSource: AgentsWindowOpenSource } { + return !!value + && typeof value === 'object' + && isAgentsWindowOpenSource((value as { readonly agentsWindowOpenSource?: unknown }).agentsWindowOpenSource); +} export class OpenWorkspaceInAgentsWindowAction extends Action2 { constructor() { super({ id: OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, - title: localize2('openWorkspaceInAgentsWindow', "Open in Agents"), + title: OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE, category: CHAT_CATEGORY, precondition: OPEN_AGENTS_WINDOW_PRECONDITION, f1: true, - menu: [{ + }); + } + + async run(accessor: ServicesAccessor, options?: { readonly source?: AgentsWindowOpenSource }): Promise { + await openCurrentWorkspaceInAgentsWindow(accessor, options?.source ?? AgentsWindowOpenSource.CommandPalette); + } +} + +export class OpenWorkspaceInAgentsWindowChatTitleAction extends Action2 { + constructor() { + super({ + id: OPEN_WORKSPACE_IN_AGENTS_WINDOW_CHAT_TITLE_COMMAND_ID, + title: OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE, + precondition: OPEN_AGENTS_WINDOW_PRECONDITION, + f1: false, + menu: { id: MenuId.ChatTitleBarMenu, group: 'c_sessions', order: 1, when: OPEN_AGENTS_WINDOW_PRECONDITION, - }, { + }, + }); + } + + async run(accessor: ServicesAccessor): Promise { + await accessor.get(ICommandService).executeCommand(OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, { source: AgentsWindowOpenSource.ChatTitleBar }); + } +} + +export class OpenWorkspaceInAgentsWindowTitleBarAction extends Action2 { + constructor() { + super({ + id: OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE_BAR_COMMAND_ID, + title: OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE, + precondition: OPEN_AGENTS_WINDOW_PRECONDITION, + f1: false, + menu: { id: MenuId.TitleBarAdjacentCenter, order: -1000, when: ContextKeyExpr.and( OPEN_AGENTS_WINDOW_PRECONDITION, ContextKeyExpr.notEquals(`config.${ChatConfiguration.TitleBarOpenInAgentsWindowEnabled}`, false), ), - }] + }, }); } - async run(accessor: ServicesAccessor) { - const nativeHostService = accessor.get(INativeHostService); - const workspaceContextService = accessor.get(IWorkspaceContextService); - const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; - await nativeHostService.openAgentsWindow({ folderUri: folderUri?.scheme === Schemas.file ? folderUri : undefined }); + async run(accessor: ServicesAccessor): Promise { + await accessor.get(ICommandService).executeCommand(OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, { source: AgentsWindowOpenSource.TitleBar }); } } @@ -95,19 +141,21 @@ export class OpenAgentsWindowAction extends Action2 { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, weight: KeybindingWeight.WorkbenchContrib, when: ContextKeyExpr.and(IsSessionsWindowContext.toNegated(), CONTEXT_ACCESSIBILITY_MODE_ENABLED.toNegated()), + args: { source: AgentsWindowOpenSource.KeyboardShortcut }, }, { // In screen reader mode, Cmd/Ctrl+Shift+A conflicts with many screen reader keybindings, // so require an additional Alt modifier. primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyMod.Alt | KeyCode.KeyA, weight: KeybindingWeight.WorkbenchContrib, when: ContextKeyExpr.and(IsSessionsWindowContext.toNegated(), CONTEXT_ACCESSIBILITY_MODE_ENABLED), + args: { source: AgentsWindowOpenSource.KeyboardShortcut }, }], }); } - async run(accessor: ServicesAccessor, args?: { folderUri?: UriComponents; sessionResource?: UriComponents }) { + async run(accessor: ServicesAccessor, args?: { folderUri?: UriComponents; sessionResource?: UriComponents; source?: AgentsWindowOpenSource }) { const nativeHostService = accessor.get(INativeHostService); - await nativeHostService.openAgentsWindow(args); + await nativeHostService.openAgentsWindow({ ...args, source: args?.source ?? AgentsWindowOpenSource.CommandPalette }); } } @@ -147,8 +195,11 @@ export class OpenChatSessionInAgentsWindowAction extends Action2 { const nativeHostService = accessor.get(INativeHostService); const workspaceContextService = accessor.get(IWorkspaceContextService); + const commandOptions = isOpenChatSessionInAgentsWindowOptions(rest[0]) ? rest[0] : undefined; + const source = commandOptions?.agentsWindowOpenSource ?? AgentsWindowOpenSource.ChatTitleBar; + const args = commandOptions ? rest.slice(1) : rest; let sessionResource: URI | undefined; - const arg = rest[0]; + const arg = args[0]; if (URI.isUri(arg)) { sessionResource = arg; } else if (arg && typeof arg === 'object') { @@ -170,6 +221,7 @@ export class OpenChatSessionInAgentsWindowAction extends Action2 { await nativeHostService.openAgentsWindow({ folderUri: !hasRealSession && folderUri?.scheme === Schemas.file ? folderUri.toJSON() : undefined, sessionResource: hasRealSession ? sessionResource?.toJSON() : undefined, + source, }); } } @@ -219,7 +271,7 @@ export class OpenWorkspaceInAgentsContribution extends Disposable implements IWo @IProductService productService: IProductService, ) { super(); - this._register(actionViewItemService.register(MenuId.TitleBarAdjacentCenter, OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, (action, options) => { + this._register(actionViewItemService.register(MenuId.TitleBarAdjacentCenter, OPEN_WORKSPACE_IN_AGENTS_WINDOW_TITLE_BAR_COMMAND_ID, (action, options) => { return instantiationService.createInstance(OpenWorkspaceInAgentsTitleBarWidget, action, options); }, undefined)); } @@ -312,7 +364,7 @@ export class AgentsHandoffInputTipContribution extends Disposable implements IWo this._logTipAction('open'); // Opening the tip counts as handling it: don't show it again this window. this._dismissForWindow(); - return accessor.get(ICommandService).executeCommand(OpenChatSessionInAgentsWindowAction.ID, ...args); + return accessor.get(ICommandService).executeCommand(OpenChatSessionInAgentsWindowAction.ID, { agentsWindowOpenSource: AgentsWindowOpenSource.ChatHandoff }, ...args); })); this._register(CommandsRegistry.registerCommand(AgentsHandoffInputTipContribution.TIP_MUTE_COMMAND_ID, () => { diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 84d8e1bdbdbaf8..3a03117fa86ba1 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -56,7 +56,7 @@ import { registerChatExportZipAction } from './actions/chatExportZip.js'; import { registerExportAgentTracesDbAction } from './actions/exportAgentTracesDb.js'; import { shouldWarnForSessionShutdown } from './chatLifecycle.js'; import { HoldToVoiceChatInChatViewAction, InlineVoiceChatAction, KeywordActivationContribution, QuickVoiceChatAction, ReadChatResponseAloud, StartVoiceChatAction, StopListeningAction, StopListeningAndSubmitAction, StopReadAloud, StopReadChatItemAloud, VoiceChatInChatViewAction } from './actions/voiceChatActions.js'; -import { OpenWorkspaceInAgentsWindowAction, OpenWorkspaceInAgentsContribution, OpenAgentsWindowAction, OpenChatSessionInAgentsWindowAction, AgentsHandoffInputTipContribution, ToggleOpenInAgentsWindowTitleBarAction } from './agentSessions/agentSessionsActions.js'; +import { OpenWorkspaceInAgentsWindowAction, OpenWorkspaceInAgentsContribution, OpenAgentsWindowAction, OpenChatSessionInAgentsWindowAction, AgentsHandoffInputTipContribution, ToggleOpenInAgentsWindowTitleBarAction, OpenWorkspaceInAgentsWindowChatTitleAction, OpenWorkspaceInAgentsWindowTitleBarAction } from './agentSessions/agentSessionsActions.js'; import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; import { NativePluginGitCommandService } from './pluginGitCommandService.js'; @@ -241,6 +241,8 @@ class ChatLifecycleHandler extends Disposable { } registerAction2(OpenWorkspaceInAgentsWindowAction); +registerAction2(OpenWorkspaceInAgentsWindowChatTitleAction); +registerAction2(OpenWorkspaceInAgentsWindowTitleBarAction); registerAction2(ToggleOpenInAgentsWindowTitleBarAction); registerAction2(OpenAgentsWindowAction); registerAction2(OpenChatSessionInAgentsWindowAction); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts new file mode 100644 index 00000000000000..7465b94c85cddd --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentPluginActions.test.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { createUninstallPluginAction } from '../../browser/agentPluginActions.js'; +import { ContributionEnablementState } from '../../common/enablement.js'; +import { IAgentPlugin } from '../../common/plugins/agentPluginService.js'; + +suite('AgentPluginActions', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createPlugin(remove?: () => void): IAgentPlugin { + return { + uri: URI.file('/plugins/local-plugin'), + format: PluginFormat.Copilot, + label: 'Local Plugin', + enablement: observableValue('enablement', ContributionEnablementState.EnabledProfile), + remove, + hooks: observableValue('hooks', []), + commands: observableValue('commands', []), + skills: observableValue('skills', []), + agents: observableValue('agents', []), + instructions: observableValue('instructions', []), + mcpServerDefinitions: observableValue('mcpServerDefinitions', []), + }; + } + + test('creates uninstall action for a removable local plugin', async () => { + let removeCount = 0; + const action = createUninstallPluginAction(createPlugin(() => removeCount++)); + + assert.ok(action); + store.add(action); + await action.run(); + + assert.strictEqual(removeCount, 1); + }); + + test('does not create uninstall action for a non-removable plugin', () => { + assert.strictEqual(createUninstallPluginAction(createPlugin()), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts index ae013e1e457786..3e742af7226925 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -389,6 +389,7 @@ suite('resolveMcpServerAuthentication', () => { getAccountPreference: () => undefined, }); instantiationService.stub(IAuthenticationMcpUsageService, {}); + instantiationService.stub(IDynamicAuthenticationProviderStorageService, {}); instantiationService.stub(ILogService, new NullLogService()); const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { @@ -427,6 +428,7 @@ suite('resolveMcpServerAuthentication', () => { getAccountPreference: () => undefined, }); instantiationService.stub(IAuthenticationMcpUsageService, {}); + instantiationService.stub(IDynamicAuthenticationProviderStorageService, {}); instantiationService.stub(ILogService, new NullLogService()); const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { @@ -466,6 +468,7 @@ suite('resolveMcpServerAuthentication', () => { getAccountPreference: () => undefined, }); instantiationService.stub(IAuthenticationMcpUsageService, {}); + instantiationService.stub(IDynamicAuthenticationProviderStorageService, {}); instantiationService.stub(ILogService, new NullLogService()); const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { @@ -489,25 +492,35 @@ suite('resolveMcpServerAuthentication', () => { }); }); - test('does not attempt dynamic provider creation without user interaction', async () => { + test('does not create a dynamic provider silently without a persisted registration', async () => { const warnings: string[] = []; + const providerCreations: string[] = []; + const metadataRequests: string[] = []; const logService = new class extends NullLogService { override warn(message: string): void { warnings.push(message); } }(); const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(IAuthenticationService, createMockAuthService({})); + instantiationService.stub(IAuthenticationService, createMockAuthService({ + createDynamicAuthenticationProvider: async authorizationServer => { + providerCreations.push(authorizationServer.toString(true)); + return undefined; + }, + })); instantiationService.stub(IAuthenticationMcpAccessService, {}); instantiationService.stub(IAuthenticationMcpService, { getAccountPreference: () => undefined, }); instantiationService.stub(IAuthenticationMcpUsageService, {}); + instantiationService.stub(IDynamicAuthenticationProviderStorageService, { + getClientRegistration: () => Promise.resolve(undefined), + }); instantiationService.stub(ILogService, logService); const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { resource: 'https://mcp.example.com', - authorization_servers: ['not-a-valid-authorization-server'], + authorization_servers: ['https://auth.example.com'], }, { allowInteraction: false, logPrefix: '[AgentHost]', @@ -515,12 +528,195 @@ suite('resolveMcpServerAuthentication', () => { mcpServerName: 'Example', mcpServerUrl: 'https://mcp.example.com', scopes: [], + authorizationServerMetadataFetcher: async authorizationServer => { + metadataRequests.push(authorizationServer); + throw new Error('Unexpected metadata request'); + }, authenticate: async () => { }, }); - assert.deepStrictEqual({ result, warnings }, { + assert.deepStrictEqual({ result, warnings, metadataRequests, providerCreations }, { result: false, warnings: [], + metadataRequests: [], + providerCreations: [], + }); + }); + + test('restores a persisted dynamically registered provider without user interaction', async () => { + const dynamicProviderId = 'https://mcp.notion.com/ https://mcp.notion.com/mcp'; + const providerCreations: { clientId: string | undefined; clientSecret: string | undefined }[] = []; + const sessionRequests: { silent: boolean | undefined }[] = []; + const authenticateRequests: { resource: string; scopes?: readonly string[]; token: string }[] = []; + const authService = createMockAuthService({ + createDynamicAuthenticationProvider: async (_authorizationServer, _metadata, _resource, clientId, clientSecret) => { + providerCreations.push({ clientId, clientSecret }); + return { id: dynamicProviderId }; + }, + getSessions: (_providerId, _scopes, options) => { + sessionRequests.push({ silent: options.silent }); + return Promise.resolve([{ + id: 'notion-session', + scopes: [], + accessToken: 'notion-token', + account: { id: 'account-id', label: 'Notion Account' }, + }]); + }, + }); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IAuthenticationService, authService); + instantiationService.stub(IAuthenticationMcpAccessService, { + isAccessAllowedForUrl: () => true, + }); + instantiationService.stub(IAuthenticationMcpService, { + getAccountPreference: () => 'Notion Account', + }); + instantiationService.stub(IAuthenticationMcpUsageService, { + addAccountUsage: () => { }, + }); + instantiationService.stub(IDynamicAuthenticationProviderStorageService, { + getClientRegistration: () => Promise.resolve({ clientId: 'notion-client-id', clientSecret: 'notion-client-secret' }), + }); + instantiationService.stub(ILogService, new NullLogService()); + + const result = await instantiationService.invokeFunction(resolveMcpServerAuthentication, { + resource: 'https://mcp.notion.com/mcp', + authorization_servers: ['https://mcp.notion.com'], + }, { + allowInteraction: false, + logPrefix: '[AgentHost]', + mcpServerId: 'notion', + mcpServerName: 'notion', + mcpServerUrl: 'https://mcp.notion.com/mcp', + scopes: [], + authorizationServerMetadataFetcher: async authorizationServer => ({ + metadata: { + issuer: authorizationServer, + response_types_supported: ['code'], + }, + discoveryUrl: `${authorizationServer}/.well-known/oauth-authorization-server`, + errors: [], + }), + authenticate: async request => { + authenticateRequests.push(request); + }, + }); + + assert.deepStrictEqual({ result, providerCreations, sessionRequests, authenticateRequests }, { + result: true, + providerCreations: [{ clientId: 'notion-client-id', clientSecret: 'notion-client-secret' }], + sessionRequests: [{ silent: true }], + authenticateRequests: [{ + resource: 'https://mcp.notion.com/mcp', + scopes: [], + token: 'notion-token', + }], + }); + }); + + test('serializes authentication transactions for different configured clients', async () => { + const dynamicProviderId = 'https://mcp.example.com/ https://mcp.example.com/resource'; + const firstSessionStarted = new DeferredPromise(); + const firstSessionGate = new DeferredPromise(); + const providerCreations: string[] = []; + const sessionRequests: string[] = []; + const authenticateRequests: string[] = []; + let activeClient: string | undefined; + let providerActive = false; + const authService = createMockAuthService({ + isDynamicAuthenticationProvider: providerId => providerId === dynamicProviderId && providerActive, + createDynamicAuthenticationProvider: async (_authorizationServer, _metadata, _resource, clientId) => { + activeClient = clientId; + providerActive = true; + providerCreations.push(clientId ?? ''); + return { id: dynamicProviderId }; + }, + unregisterAuthenticationProvider: () => { + providerActive = false; + }, + getSessions: async () => { + const clientId = activeClient ?? ''; + sessionRequests.push(clientId); + if (clientId === 'first-client') { + firstSessionStarted.complete(); + await firstSessionGate.p; + } + return [{ + id: `${clientId}-session`, + scopes: [], + accessToken: `${clientId}-token`, + account: { id: 'account-id', label: 'MCP Account' }, + }]; + }, + }); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IAuthenticationService, authService); + instantiationService.stub(IAuthenticationMcpAccessService, { + isAccessAllowedForUrl: () => true, + }); + instantiationService.stub(IAuthenticationMcpService, { + getAccountPreference: () => 'MCP Account', + }); + instantiationService.stub(IAuthenticationMcpUsageService, { + addAccountUsage: () => { }, + }); + instantiationService.stub(IDynamicAuthenticationProviderStorageService, { + getClientRegistration: () => Promise.resolve(activeClient ? { clientId: activeClient } : undefined), + removeDynamicProvider: async () => { + activeClient = undefined; + }, + }); + instantiationService.stub(ILogService, new NullLogService()); + const protectedResource = { + resource: 'https://mcp.example.com/resource', + authorization_servers: ['https://mcp.example.com'], + }; + const options = (clientId: string) => ({ + allowInteraction: true, + logPrefix: '[AgentHost]', + mcpServerId: 'example', + mcpServerName: 'Example', + mcpServerUrl: 'https://mcp.example.com/resource', + oauthClient: { clientId }, + scopes: [], + authorizationServerMetadataFetcher: async (authorizationServer: string) => ({ + metadata: { + issuer: authorizationServer, + response_types_supported: ['code'], + }, + discoveryUrl: `${authorizationServer}/.well-known/oauth-authorization-server`, + errors: [], + }), + authenticate: async (request: { token: string }) => { + authenticateRequests.push(request.token); + }, + }); + + const first = instantiationService.invokeFunction(resolveMcpServerAuthentication, protectedResource, options('first-client')); + const second = instantiationService.invokeFunction(resolveMcpServerAuthentication, protectedResource, options('second-client')); + await firstSessionStarted.p; + const beforeResolution = { + providerCreations: [...providerCreations], + sessionRequests: [...sessionRequests], + }; + firstSessionGate.complete(); + const results = await Promise.all([first, second]); + + assert.deepStrictEqual({ + beforeResolution, + results, + providerCreations, + sessionRequests, + authenticateRequests, + }, { + beforeResolution: { + providerCreations: ['first-client'], + sessionRequests: ['first-client'], + }, + results: [true, true], + providerCreations: ['first-client', 'second-client'], + sessionRequests: ['first-client', 'second-client'], + authenticateRequests: ['first-client-token', 'second-client-token'], }); }); 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 dcad5457becd86..603d0301357bd9 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 @@ -6435,6 +6435,26 @@ suite('AgentHostChatContribution', () => { ]); })); + test('browser implicit context is not forwarded as an attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-browser' }); + const browserUri = URI.from({ scheme: 'vscode-browser', path: '/browser-id' }); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'browser', isSelection: false, uri: browserUri, value: browserUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'help with this page', + sessionResource, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + test('active editor implicit context is not duplicated when also attached explicitly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-dedup' }); 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 50683ea4d6b3df..fe2ba9b521f4fe 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 @@ -9,6 +9,7 @@ import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { constObservable, observableValue, autorun } from '../../../../../../base/common/observable.js'; @@ -17,6 +18,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/toolSearchConstants.js'; import { isChatAction, isSessionAction, type ActionEnvelope, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { buildDefaultChatUri, buildSubagentChatUri, createChatState, createDefaultChatSummary, MessageKind, SessionLifecycle, SessionStatus, createSessionState, StateComponents, parseDefaultChatUri, type ChatState, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { chatReducer, sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; @@ -189,6 +191,17 @@ suite('AgentHostClientTools', () => { assert.strictEqual(proto.pastTenseMessage, 'Ran myTool'); }); + test('preserves markdown tool result messages', () => { + const result: IToolResult = { + content: [], + toolResultMessage: new MarkdownString('Opened [Browser](vscode-browser:/page-1?vscodeLinkType=browser)'), + }; + + assert.deepStrictEqual(toolResultToProtocol(result, 'open_browser_page').pastTenseMessage, { + markdown: 'Opened [Browser](vscode-browser:/page-1?vscodeLinkType=browser)', + }); + }); + test('converts text and data content parts', () => { const binaryData = VSBuffer.fromString('hello binary'); const result: IToolResult = { @@ -612,6 +625,15 @@ suite('AgentHostClientTools', () => { source: ToolDataSource.Internal, }; + const testToolSearchTool: IToolData = { + id: 'vscode.toolSearch', + toolReferenceName: CLIENT_TOOL_SEARCH_REFERENCE_NAME, + displayName: 'Search Tools', + modelDescription: 'Searches for tools', + source: ToolDataSource.Internal, + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }; + async function provideSessionWithReadyRunTaskTool(handler: AgentHostSessionHandler, connection: MockAgentHostConnection): Promise { const sessionResource = URI.parse('agent-host-copilot:/session-1'); const backendSession = AgentSession.uri('copilot', 'session-1').toString(); @@ -744,6 +766,111 @@ suite('AgentHostClientTools', () => { && entry.action.toolCallId === 'tool-call-1')); }); + test('tool-search completion drops candidates while preserving unknown metadata', async () => { + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testToolSearchTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + const chatURI = URI.parse(buildDefaultChatUri(backendSession)); + + connection.applySessionAction(chatURI, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'find a calculator', origin: { kind: MessageKind.User } }, + } as ChatAction); + connection.applySessionAction(chatURI, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-search-call-1', + toolName: RUNTIME_TOOL_SEARCH_TOOL_NAME, + displayName: 'Search Tools', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + } as ChatAction); + connection.applySessionAction(chatURI, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tool-search-call-1', + invocationMessage: 'Search Tools', + toolInput: '{"query":"calculator"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { + toolSearchCandidates: [{ name: 'calculator', description: 'Adds numbers' }], + futureMetadata: { preserve: true }, + }, + } as ChatAction); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + await timeout(0); + await timeout(0); + + const completion = connection.dispatchedActions.find(entry => isChatAction(entry.action) + && entry.action.type === ActionType.ChatToolCallComplete + && entry.action.toolCallId === 'tool-search-call-1'); + assert.ok(completion && isChatAction(completion.action) && completion.action.type === ActionType.ChatToolCallComplete); + assert.deepStrictEqual({ + parameters: toolsService.invokedToolCalls[0]?.parameters, + meta: completion.action._meta, + }, { + parameters: { + query: 'calculator', + candidateTools: [{ name: 'calculator', description: 'Adds numbers' }], + }, + meta: { futureMetadata: { preserve: true } }, + }); + }); + + test('invalid tool-search input drops candidates while preserving unknown metadata', async () => { + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testToolSearchTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + const chatURI = URI.parse(buildDefaultChatUri(backendSession)); + + connection.applySessionAction(chatURI, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'find a calculator', origin: { kind: MessageKind.User } }, + } as ChatAction); + connection.applySessionAction(chatURI, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-search-call-invalid', + toolName: RUNTIME_TOOL_SEARCH_TOOL_NAME, + displayName: 'Search Tools', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + } as ChatAction); + connection.applySessionAction(chatURI, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tool-search-call-invalid', + invocationMessage: 'Search Tools', + toolInput: '{invalid', + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: { + toolSearchCandidates: [{ name: 'calculator', description: 'Adds numbers' }], + futureMetadata: { preserve: true }, + }, + } as ChatAction); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + await timeout(0); + await timeout(0); + + const completion = connection.dispatchedActions.find(entry => isChatAction(entry.action) + && entry.action.type === ActionType.ChatToolCallComplete + && entry.action.toolCallId === 'tool-search-call-invalid'); + assert.ok(completion && isChatAction(completion.action) && completion.action.type === ActionType.ChatToolCallComplete); + assert.deepStrictEqual({ + invokedToolCalls: toolsService.invokedToolCalls.length, + success: completion.action.result.success, + meta: completion.action._meta, + }, { + invokedToolCalls: 0, + success: false, + meta: { futureMetadata: { preserve: true } }, + }); + }); + test('shows another client tool as cancellable progress without invoking or confirming it', async () => { const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool]); const sessionResource = URI.parse('agent-host-copilot:/session-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index d1997ed3ec5301..108689f7fefb42 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -13,7 +13,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostCopilotSdkLogLevelSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, CopilotCliConfigKey } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { AgentHostCopilotSdkLogLevelSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostToolSearchEnabledSettingId, CopilotCliConfigKey } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ClientAnnotationsAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ConfigPropertySchema, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -72,6 +72,7 @@ function makeRootStateWithSchema(properties: Record = { [CopilotCliConfigKey.CopilotSdkLogLevel]: { type: 'string', title: 'Copilot SDK Log Level' }, [CopilotCliConfigKey.Opus48Prompt]: { type: 'boolean', title: 'Opus 4.8 Agent Prompt' }, + [CopilotCliConfigKey.ToolSearchEnabled]: { type: 'boolean', title: 'Agent Host Tool Search' }, [CopilotCliConfigKey.ReasoningEffortOverride]: { type: 'string', title: 'Reasoning Effort Override' }, [CopilotCliConfigKey.ModelCapabilityOverrides]: { type: 'object', title: 'Model Capability Overrides' }, }; @@ -105,6 +106,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { const { agentHostService } = setup(disposables, { [AgentHostCopilotSdkLogLevelSettingId]: 'trace', [AgentHostOpus48PromptEnabledSettingId]: true, + [AgentHostToolSearchEnabledSettingId]: true, [AgentHostReasoningEffortOverrideSettingId]: 'xhigh', [AgentHostModelCapabilityOverridesSettingId]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, }); @@ -113,11 +115,12 @@ suite('AgentHostCopilotCliSettingsContribution', () => { // The shared forwarder dispatches one RootConfigChanged per key; merge them // and assert the full forwarded set (order-independent). - assert.strictEqual(agentHostService.dispatchedActions.length, 4); + assert.strictEqual(agentHostService.dispatchedActions.length, 5); const merged = Object.assign({}, ...agentHostService.dispatchedActions.map(a => (a.action as IRootConfigChangedAction).config)); assert.deepStrictEqual(merged, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', [CopilotCliConfigKey.Opus48Prompt]: true, + [CopilotCliConfigKey.ToolSearchEnabled]: true, [CopilotCliConfigKey.ReasoningEffortOverride]: 'xhigh', [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, }); @@ -161,6 +164,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { agentHostService.setRootState(makeRootStateWithSchema(fullSchema, { [CopilotCliConfigKey.CopilotSdkLogLevel]: 'trace', [CopilotCliConfigKey.Opus48Prompt]: true, + [CopilotCliConfigKey.ToolSearchEnabled]: false, [CopilotCliConfigKey.ReasoningEffortOverride]: 'xhigh', [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, })); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index 3cee2e893a10ea..ed18616dd87432 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -54,6 +54,36 @@ suite('AgentHostLanguageModelProvider', () => { assert.strictEqual(auto?.metadata.detail, undefined, 'discountPercent 0 → no detail'); }); + test('carries picker category, price category, and promo from model metadata', async () => { + const provider = createProvider(); + provider.updateModels([makeModel('claude-sonnet', { + category: 'powerful', + priceCategory: 'medium', + promo: { + id: 'summer-sale', + discountPercent: 25, + endsAt: '2026-08-01T00:00:00Z', + message: 'Save on Claude Sonnet', + }, + })]); + + const metadata = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None))[0].metadata; + assert.deepStrictEqual({ + category: metadata.category, + priceCategory: metadata.priceCategory, + promo: metadata.promo, + }, { + category: 'powerful', + priceCategory: 'medium', + promo: { + id: 'summer-sale', + discountPercent: 25, + endsAt: '2026-08-01T00:00:00Z', + message: 'Save on Claude Sonnet', + }, + }); + }); + test('derives the picker group from the model-id prefix, not the harness provider', async () => { const provider = createProvider(); // The agent host reports every model under the harness provider (`copilotcli`); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts index e3db862f9af9ea..9581f4af15ec2f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts @@ -9,7 +9,7 @@ import { ISettableObservable, observableValue } from '../../../../../../base/com import { URI } from '../../../../../../base/common/uri.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../browser/agentSessions/agentSessionApprovalModel.js'; +import { agentSessionApprovalId, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../browser/agentSessions/agentSessionApprovalModel.js'; import { MockChatModel } from '../../common/model/mockChatModel.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { IChatToolInvocation, IChatTerminalToolInvocationData, ToolConfirmKind, ConfirmedReason } from '../../../common/chatService/chatService.js'; @@ -20,6 +20,7 @@ function makeToolInvocationPart(options: { state: IChatToolInvocation.State; toolSpecificData?: IChatToolInvocation['toolSpecificData']; invocationMessage?: string | MarkdownString; + toolCallId?: string; }): IChatToolInvocation { return { kind: 'toolInvocation', @@ -29,7 +30,7 @@ function makeToolInvocationPart(options: { pastTenseMessage: undefined, source: undefined!, toolId: 'test-tool', - toolCallId: 'call-1', + toolCallId: options.toolCallId ?? 'call-1', state: observableValue('toolState', options.state), toolSpecificData: options.toolSpecificData, toolSpecificDataKind: observableValue('test', options.toolSpecificData?.kind), @@ -420,6 +421,22 @@ suite('AgentSessionApprovalModel', () => { assert.strictEqual(getApproval(approvalModel, chatModel), undefined); }); + test('keeps approval identity stable when a chat model reloads', () => { + const approvalModel = createModel(); + const uri = URI.parse('test://session/reloaded'); + const firstModel = addChatModel(uri); + mockModelWithResponse(firstModel, [makeToolInvocationPart({ state: makeWaitingState(), toolCallId: 'stable-call' })]); + firstModel.requestNeedsInput.set({ title: 'Test' }, undefined); + const firstId = agentSessionApprovalId(getApproval(approvalModel, firstModel)!); + + chatModelsObs.set([], undefined); + const restoredModel = addChatModel(uri); + mockModelWithResponse(restoredModel, [makeToolInvocationPart({ state: makeWaitingState(), toolCallId: 'stable-call' })]); + restoredModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.deepStrictEqual([firstId, agentSessionApprovalId(getApproval(approvalModel, restoredModel)!)], ['stable-call', 'stable-call']); + }); + test('picks the first WaitingForConfirmation part when multiple parts exist', () => { const approvalModel = createModel(); const chatModel = addChatModel(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 66271d19680bc7..82485f1c651edb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -304,6 +304,48 @@ suite('stateToProgressAdapter', () => { assert.deepStrictEqual(details.output, [{ type: 'embed', value: 'request timed out', isText: true, mimeType: 'text/plain' }]); }); + test('failed MCP App tool call in history remains confirmed', () => { + const turn = createTurn({ + responseParts: [{ + kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall({ + toolName: 'GitHub-create_pull_request', + toolInput: '{"owner":"microsoft","repo":"vscode"}', + success: false, + error: { message: 'The pull request form is awaiting submission.' }, + contributor: { kind: ToolCallContributorKind.MCP, customizationId: 'github-customization' }, + _meta: { + ui: { + resourceUri: 'ui://github-mcp-server/pr-write', + channel: 'mcp://copilot/session/GitHub', + }, + }, + }) + } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.deepStrictEqual({ + isConfirmed: serialized.isConfirmed, + toolSpecificData: serialized.toolSpecificData, + }, { + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + toolSpecificData: { + kind: 'input', + rawInput: { owner: 'microsoft', repo: 'vscode' }, + mcpAppData: { + kind: 'agentHost', + resourceUri: 'ui://github-mcp-server/pr-write', + serverId: 'github-customization', + channel: 'mcp://copilot/session/GitHub', + }, + }, + }); + }); + test('generic completed tool call maps embedded resources and resource refs', () => { const turn = createTurn({ responseParts: [{ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts index 5487a385d8f5e5..7ea398bc6948f2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts @@ -184,6 +184,56 @@ suite('ChatInputModelSelectionController', () => { }); }); + test('restores a remembered model after split same-vendor catalog publication', () => { + const first = model('test/first'); + const remembered = model('test/remembered'); + const modelChanges = disposables.add(new Emitter()); + let models: ILanguageModelChatMetadataAndIdentifier[] = []; + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => undefined, + isEmpty: () => true, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => false, + getConfiguredModelValue: () => undefined, + resolveModelIdentifier: identifier => resolveModelIdentifierFromCatalog(models, identifier, { + hasLiveModels: vendor => models.some(model => model.metadata.vendor === vendor), + hasResolved: () => true, + }), + subscribeToModelChanges: listener => modelChanges.event(listener), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => applied.push(selected.identifier), + }; + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); + + controller.initialize(remembered.identifier, () => { }); + models = [first]; + modelChanges.fire('partial'); + const resolutionAfterPartial = runtime.resolveModelIdentifier(remembered.identifier).kind; + const pendingAfterPartial = controller.hasPendingIntent(); + models = [first, remembered]; + modelChanges.fire('complete'); + + assert.deepStrictEqual({ + resolutionAfterPartial, + pendingAfterPartial, + pendingAfterComplete: controller.hasPendingIntent(), + applied, + current: controller.currentModel.get()?.identifier, + }, { + resolutionAfterPartial: 'unavailable', + pendingAfterPartial: true, + pendingAfterComplete: false, + applied: [first.identifier, remembered.identifier], + current: remembered.identifier, + }); + }); + test('explicit selection cancels an eventual remembered-model restore', () => { const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); @@ -313,7 +363,7 @@ suite('ChatInputModelSelectionController', () => { }); }); - test('location default improves the fallback and settles conclusively absent remembered intent', () => { + test('location default improves the fallback without canceling remembered intent', () => { const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const remembered = model('test/remembered'); @@ -339,14 +389,14 @@ suite('ChatInputModelSelectionController', () => { applied, current: controller.currentModel.get()?.identifier, }, { - pendingAfterDefault: false, + pendingAfterDefault: true, pendingAfterLoad: false, - applied: [fallback.identifier, locationDefault.identifier], - current: locationDefault.identifier, + applied: [fallback.identifier, locationDefault.identifier, remembered.identifier], + current: remembered.identifier, }); }); - test('repairs a removed fallback and settles conclusively absent remembered intent', () => { + test('repairs a removed fallback without canceling remembered intent', () => { const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const replacement = model('test/replacement'); @@ -368,10 +418,10 @@ suite('ChatInputModelSelectionController', () => { applied, current: controller.currentModel.get()?.identifier, }, { - pendingAfterRepair: false, + pendingAfterRepair: true, pendingAfterLoad: false, - applied: [fallback.identifier, replacement.identifier], - current: replacement.identifier, + applied: [fallback.identifier, replacement.identifier, remembered.identifier], + current: remembered.identifier, }); }); @@ -966,7 +1016,7 @@ suite('ChatInputModelSelectionController', () => { }); }); - test('initialize settles remembered intent after a conclusively empty catalog update', () => { + test('initialize keeps remembered intent through empty catalog updates', () => { const sessionType = 'test-session'; const remembered = targetedModel('test:remembered', sessionType); const modelChanges = disposables.add(new Emitter()); @@ -998,7 +1048,7 @@ suite('ChatInputModelSelectionController', () => { // An intermediate empty re-resolution must not end the wait or apply a default. modelChanges.fire('still-empty'); const pendingAfterEmpty = controller.hasPendingIntent(); - // The agent-host pool finally publishes its models. + // The remembered model finally appears. models = [remembered]; modelChanges.fire('loaded'); @@ -1012,7 +1062,7 @@ suite('ChatInputModelSelectionController', () => { }, { pendingAfterInit: true, appliedAfterInit: [], - pendingAfterEmpty: false, + pendingAfterEmpty: true, pendingAfterLoad: false, applied: [remembered.identifier], current: remembered.identifier, @@ -1139,13 +1189,8 @@ suite('ChatInputModelSelectionController', () => { }); }); - test('initialize waits for a conclusively-absent remembered model and swaps it in when it appears', () => { - // Grace-independent restore. With the simple (conclusive) resolver the remembered model is - // `unavailable` at cold start, so `initialize` applies a provisional fallback. The refactor - // watches the catalog from the provisional-fallback path too (not only the `pending` path), - // so when the remembered model shows up on a later change it is swapped in — instead of being - // lost. Reverting the `initialize` restructure (arming the wait only for `pending`) leaves no - // wait armed here, so `pendingAfterInit` is false and the remembered model is never applied. + test('initialize restores a remembered model after a non-empty initial catalog', () => { + // The initial fallback remains provisional even when the catalog reports the remembered model unavailable. const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const remembered = model('test/remembered'); @@ -1189,54 +1234,6 @@ suite('ChatInputModelSelectionController', () => { }); }); - test('initialize stops waiting when the pool loads without the remembered model', () => { - // Termination guard: the wait must not linger. Once the remembered model is conclusively - // absent (the pool loaded with other models but not it), settle on the already-applied - // fallback and tear the subscription down — without re-applying the fallback. - const modelChanges = disposables.add(new Emitter()); - const fallback = model('test/fallback'); - const other = model('test/other'); - const remembered = model('test/remembered'); - let models = [fallback]; - const applied: string[] = []; - const runtime: IChatInputModelSelectionRuntime = { - location: ChatAgentLocation.Chat, - getCurrentModeKind: () => ChatModeKind.Ask, - getCurrentSessionType: () => undefined, - isEmpty: () => true, - getModels: () => models, - getAllModels: () => models, - requiresCustomModels: () => false, - getConfiguredModelValue: () => undefined, - resolveModelIdentifier: identifier => resolveModelIdentifier(models, identifier, true), - subscribeToModelChanges: listener => modelChanges.event(listener), - getBoundConversationKey: () => 'chat:one', - getVisibleConversationKey: () => 'chat:one', - restoreModelConfiguration: () => { }, - applyModel: selected => { - applied.push(selected.identifier); - }, - }; - const controller = disposables.add(new ChatInputModelSelectionController(runtime)); - - controller.initialize(remembered.identifier, () => { }); - const pendingAfterInit = controller.hasPendingIntent(); - models = [fallback, other]; - modelChanges.fire('loaded-without-remembered'); - - assert.deepStrictEqual({ - pendingAfterInit, - pendingAfterLoad: controller.hasPendingIntent(), - applied, - current: controller.currentModel.get()?.identifier, - }, { - pendingAfterInit: true, - pendingAfterLoad: false, - applied: [fallback.identifier], - current: fallback.identifier, - }); - }); - test('initialize does not arm a restore wait when there is nothing to wait for', () => { // Guard against over-arming: no remembered model, or a remembered model that is already // available, must not leave a catalog subscription armed. diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts index c524bbb25a9006..306674012c0b0c 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { waitForState } from '../../../../../../base/common/observable.js'; @@ -32,6 +33,8 @@ import { PluginFormat } from '../../../../../../platform/agentPlugins/common/plu */ class TestPluginDiscovery extends AbstractAgentPluginDiscovery { private _sources: URI[] = []; + private _remove: (() => void) | undefined = () => { }; + private _nextDiscoveryBarrier: Promise | undefined; constructor( fileService: IFileService, @@ -52,12 +55,29 @@ class TestPluginDiscovery extends AbstractAgentPluginDiscovery { await this._refreshPlugins(); } + async setRemoveAndRefresh(uri: URI, remove: (() => void) | undefined): Promise { + this._sources = [uri]; + this._remove = remove; + await this._refreshPlugins(); + } + + async setRemoveAndRefreshAfter(uri: URI, remove: (() => void) | undefined, barrier: Promise): Promise { + this._sources = [uri]; + this._remove = remove; + this._nextDiscoveryBarrier = barrier; + await this._refreshPlugins(); + } + protected override async _discoverPluginSources() { - return this._sources.map(uri => ({ + const sources = this._sources.map(uri => ({ uri, fromMarketplace: undefined, - remove: () => { }, + remove: this._remove, })); + const barrier = this._nextDiscoveryBarrier; + this._nextDiscoveryBarrier = undefined; + await barrier; + return sources; } } @@ -122,6 +142,65 @@ suite('AgentPlugin format detection', () => { assert.strictEqual(discovery.plugins.get(), undefined); }); + test('refreshes removability for cached plugin entries', async () => { + const uri = pluginUri('/plugins/removability'); + await writeFile('/plugins/removability/plugin.json', JSON.stringify({ name: 'removability' })); + + const removeCounts = [0, 0]; + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setRemoveAndRefresh(uri, () => removeCounts[0]++); + const initialPlugin = getDiscoveredPlugins(discovery)[0]; + initialPlugin.remove?.(); + + await discovery.setRemoveAndRefresh(uri, undefined); + const managedPlugin = getDiscoveredPlugins(discovery)[0]; + const managedRemove = managedPlugin.remove; + + await discovery.setRemoveAndRefresh(uri, () => removeCounts[1]++); + const removablePlugin = getDiscoveredPlugins(discovery)[0]; + removablePlugin.remove?.(); + + assert.deepStrictEqual({ + reusedManagedPlugin: managedPlugin === initialPlugin, + managedRemove, + reusedRemovablePlugin: removablePlugin === initialPlugin, + removeCounts, + }, { + reusedManagedPlugin: true, + managedRemove: undefined, + reusedRemovablePlugin: true, + removeCounts: [1, 1], + }); + }); + + test('stale refresh does not overwrite removability of published cached plugin', async () => { + const uri = pluginUri('/plugins/removability-race'); + await writeFile('/plugins/removability-race/plugin.json', JSON.stringify({ name: 'removability-race' })); + + let removeCount = 0; + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setRemoveAndRefresh(uri, () => { }); + + const staleDiscoveryBarrier = new DeferredPromise(); + const staleRefresh = discovery.setRemoveAndRefreshAfter(uri, undefined, staleDiscoveryBarrier.p); + await discovery.setRemoveAndRefresh(uri, () => removeCount++); + staleDiscoveryBarrier.complete(); + await staleRefresh; + + const plugin = getDiscoveredPlugins(discovery)[0]; + plugin.remove?.(); + + assert.deepStrictEqual({ + hasRemove: plugin.remove !== undefined, + removeCount, + }, { + hasRemove: true, + removeCount: 1, + }); + }); + test('detects Open Plugin format when .plugin/plugin.json exists', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const uri = pluginUri('/plugins/my-open-plugin'); await writeFile('/plugins/my-open-plugin/.plugin/plugin.json', JSON.stringify({ name: 'my-open-plugin' })); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 14a3f544289283..427a09b083ceba 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -724,7 +724,18 @@ export class McpServer extends Disposable implements IMcpServer { return { name: this.definition.label, url: launch.uri.toString(true) }; } if (launch?.type === McpServerTransportType.Stdio) { - return { name: this.definition.label, command: [launch.command, ...launch.args] }; + // `launch.command`/`launch.args` are typed as non-nullable but can be `undefined` at + // runtime when they originate from user/discovery configuration that omitted the field. + // When `command` is present, build the full command line (defaulting `args` to an empty + // array and dropping any non-string entries); the produced `IMcpServerIdentity.command` + // then never contains a non-string entry, which would otherwise break policy matching and + // the unresolved-variable check. Use a string check so a valid-but-empty command string is + // preserved while malformed non-string command values are dropped. When `command` is absent + // the full command line is unknown, so omit the field entirely rather than matching on args + // alone (which could collide with unrelated servers). + return typeof launch.command === 'string' + ? { name: this.definition.label, command: [launch.command, ...(launch.args ?? []).filter(arg => typeof arg === 'string')] } + : { name: this.definition.label }; } return { name: this.definition.label }; } diff --git a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css index b706b7f3414da1..a9a0777f544e56 100644 --- a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css +++ b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css @@ -13,6 +13,11 @@ z-index: 2600; } +/* Expanded pickers and mobile sheets remain above the spotlight blocker. */ +.spotlight-overlay.target-overlay-visible { + z-index: 1999; +} + /* Full-screen click blocker. Transparent; swallows interaction outside the hole. */ .spotlight-blocker { position: fixed; diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts index 7d354756bb161e..5b03a4e606c565 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts @@ -17,20 +17,40 @@ export const ONBOARDING_TARGET_ATTR = 'data-onboarding-id'; export const ONBOARDING_TARGET_PULSE_CLASS = 'onboarding-target-pulse'; +export interface IOnboardingTargetOptions { + /** Opens or expands the target before its spotlight step is shown. */ + readonly open?: () => Promise | void; +} + +interface IOnboardingTargetRegistration { + readonly id: string; + readonly options: IOnboardingTargetOptions; +} + +const onboardingTargetRegistrations = new WeakMap(); + /** * Marks `element` as the onboarding target identified by `id`. * * @returns A disposable that removes the attribute again. */ -export function markOnboardingTarget(element: HTMLElement, id: string): IDisposable { +export function markOnboardingTarget(element: HTMLElement, id: string, options: IOnboardingTargetOptions = {}): IDisposable { + const registration = { id, options }; element.setAttribute(ONBOARDING_TARGET_ATTR, id); + onboardingTargetRegistrations.set(element, registration); return toDisposable(() => { - if (element.getAttribute(ONBOARDING_TARGET_ATTR) === id) { + if (onboardingTargetRegistrations.get(element) === registration) { + onboardingTargetRegistrations.delete(element); element.removeAttribute(ONBOARDING_TARGET_ATTR); } }); } +/** Opens or expands a target through the behavior registered by its owner. */ +export function openOnboardingTarget(element: HTMLElement): Promise | void { + return onboardingTargetRegistrations.get(element)?.options.open?.(); +} + /** * Applies the standard onboarding pulse treatment to `element`. * diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts index 06012632e191fa..31132bb42a33aa 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts @@ -50,6 +50,8 @@ export interface ISpotlightShowOptions { readonly placement?: SpotlightPlacement; readonly allowTargetInteraction?: boolean; readonly padding?: number; + readonly hideNext?: boolean; + readonly targetOverlayVisible?: boolean; /** * When set, the step advances (fires `onDidClickNext`) when the user clicks * the spotlighted target itself. The "Next" button is hidden and the target @@ -95,6 +97,7 @@ export class SpotlightOverlay extends Disposable { private _target: HTMLElement | undefined; private _options: ISpotlightShowOptions = {}; + private _hasShown = false; private _previousFocus: HTMLElement | undefined; private _scheduledLayout: IDisposable | undefined; @@ -162,14 +165,17 @@ export class SpotlightOverlay extends Disposable { /** Show `content` spotlighting `target`. */ show(target: HTMLElement, content: ISpotlightContent, options: ISpotlightShowOptions = {}): void { - const isFirstShow = this._root.style.display === 'none'; - if (isFirstShow) { + if (!this._hasShown) { + this._hasShown = true; this._previousFocus = isHTMLElement(getActiveElement()) ? getActiveElement() as HTMLElement : undefined; } this._target = target; this._options = options; this._renderContent(content); + const externalUiParticipates = !!options.targetOverlayVisible || !!options.allowTargetInteraction || !!options.advanceOnTargetClick || !!options.hideNext; + this._root.classList.toggle('target-overlay-visible', externalUiParticipates); + this._callout.setAttribute('aria-modal', externalUiParticipates ? 'false' : 'true'); this._root.style.display = ''; @@ -199,9 +205,12 @@ export class SpotlightOverlay extends Disposable { // and we route Tab/Esc from it through the same handler, so keyboard-only // users can focus the spotlighted control and activate it to advance. const advanceOnTargetClick = !!options.advanceOnTargetClick; - this._nextButton.element.style.display = advanceOnTargetClick ? 'none' : ''; + const hideNext = advanceOnTargetClick || !!options.hideNext; + this._nextButton.element.style.display = hideNext ? 'none' : ''; if (advanceOnTargetClick) { this._stepListeners.add(addDisposableListener(target, EventType.CLICK, () => this._onDidClickNext.fire('target'))); + } + if (options.allowTargetInteraction || advanceOnTargetClick || options.hideNext) { this._stepListeners.add(addDisposableListener(target, EventType.KEY_DOWN, e => this._onKeyDown(e))); } @@ -209,7 +218,16 @@ export class SpotlightOverlay extends Disposable { // Move focus to the spotlighted control (so keyboard users can activate it // to advance) or, otherwise, into the callout's primary action. - (advanceOnTargetClick ? target : this._nextButton.element).focus(); + (hideNext ? target : this._nextButton.element).focus(); + } + + /** Hide the current step while another target is being resolved. */ + hide(): void { + this._stepListeners.clear(); + this._root.style.display = 'none'; + this._root.classList.remove('target-overlay-visible'); + this._target = undefined; + this._options = {}; } /** Recompute the hole and callout positions for the current target. */ @@ -410,14 +428,17 @@ export class SpotlightOverlay extends Disposable { /** * The focusable elements participating in the focus trap, in DOM order: the - * spotlighted target (when the step advances by pressing it), then any + * spotlighted target (when it is interactive or the Next button is hidden), then any * interactive content in the (possibly markdown) description, then the visible * action buttons. Including the target keeps the spotlighted control * keyboard-reachable, and querying the description keeps markdown links * reachable despite `aria-modal`. */ private _collectFocusable(): HTMLElement[] { - const target = (this._options.advanceOnTargetClick && this._target) ? [this._target] : []; + const targetFocusables = (this._options.allowTargetInteraction || this._options.advanceOnTargetClick || this._options.hideNext) && this._target + // eslint-disable-next-line no-restricted-syntax -- querying the spotlight target subtree for focusable controls + ? [this._target, ...this._target.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')] + : []; const descriptionFocusables = Array.from( // eslint-disable-next-line no-restricted-syntax -- querying our own callout description subtree for focusable markdown content (e.g. links) this._description.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])') @@ -425,7 +446,15 @@ export class SpotlightOverlay extends Disposable { const buttons = [this._skipButton, this._backButton, this._nextButton] .filter(button => button.element.style.display !== 'none') .map(button => button.element); - return [...target, ...descriptionFocusables, ...buttons]; + return [...targetFocusables, ...descriptionFocusables, ...buttons].filter(element => this._isTabbable(element)); + } + + private _isTabbable(element: HTMLElement): boolean { + if (!element.isConnected || element.getAttribute('aria-hidden') === 'true' || element.tabIndex === -1 || element.hasAttribute('disabled')) { + return false; + } + const style = getWindow(this._container).getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; } scheduleLayout(): void { diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts index ed6adbbc34f7a2..aceb220168f9b8 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { timeout } from '../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -11,9 +12,9 @@ import { IWorkbenchLayoutService } from '../../../../services/layout/browser/lay import { IHostService } from '../../../../services/host/browser/host.js'; import { IOnboardingPresentation, IOnboardingRunContext } from '../../common/onboardingPresentation.js'; import { IOnboardingRunResult, IOnboardingScenario, OnboardingDismissReason, OnboardingOutcome } from '../../common/onboardingScenario.js'; -import { findOnboardingTarget } from './onboardingTarget.js'; +import { findOnboardingTarget, openOnboardingTarget } from './onboardingTarget.js'; import { ISpotlightContent, SpotlightOverlay } from './spotlightOverlay.js'; -import { ISpotlightPayload, ISpotlightStep, SPOTLIGHT_PRESENTATION_KIND } from './spotlightTypes.js'; +import { ISpotlightPayload, ISpotlightStep, SpotlightMissingTargetBehavior, SPOTLIGHT_PRESENTATION_KIND } from './spotlightTypes.js'; /** How long to wait for a step's target element to appear before skipping it. */ const TARGET_RESOLVE_TIMEOUT = 2000; @@ -22,7 +23,7 @@ const TARGET_ANIMATION_SETTLE_TIMEOUT = 600; /** The terminal action of a single step, carrying the data needed for telemetry. */ type StepEnd = - | { readonly action: 'next'; readonly via: 'button' | 'target' } + | { readonly action: 'next'; readonly via: 'button' | 'target' | 'condition' } | { readonly action: 'back' } | { readonly action: 'skip'; readonly reason: OnboardingDismissReason.SkipButton | OnboardingDismissReason.EscapeKey } | { readonly action: 'abort' }; @@ -59,6 +60,7 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres // Whether at least one step was actually rendered. Stays `false` if every step is // skipped (missing target / unsatisfied `when`) so nothing was ever displayed. let shown = false; + const skippedStepIndexes = new Set(); const store = new DisposableStore(); try { @@ -70,7 +72,11 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres store.add(toDisposable(() => this.hostService.setWindowDimmed(context.targetWindow, false))); let aborted = false; - store.add(context.onAbort(() => { aborted = true; })); + const targetResolutionCancellation = store.add(new CancellationTokenSource()); + store.add(context.onAbort(() => { + aborted = true; + targetResolutionCancellation.cancel(); + })); // Keep the callout glued to the target as the workbench re-layouts. // Schedule the measurement so it runs after the layout event's DOM work @@ -84,6 +90,7 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres const step = steps[index]; if (step.when && !this.contextKeyService.contextMatchesRules(step.when)) { + skippedStepIndexes.add(index); index += direction; continue; } @@ -97,14 +104,16 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres break; } - const target = await this._resolveTarget(context.targetWindow, step.targetId); + const target = await this._resolveTarget(context.targetWindow, step.targetId, targetResolutionCancellation.token, step.missingTarget); if (aborted) { break; } if (!target) { + skippedStepIndexes.add(index); index += direction; continue; } + skippedStepIndexes.delete(index); await this._waitForTargetReady(context.targetWindow, target); if (aborted) { @@ -114,7 +123,11 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres lastStepIndex = Math.max(lastStepIndex, index); shown = true; - const end = await this._runStep(overlay, context, step, target, index, stepCount); + const skippedBefore = Array.from(skippedStepIndexes).filter(skippedIndex => skippedIndex < index).length; + const displayStepIndex = index - skippedBefore; + const displayStepCount = stepCount - skippedStepIndexes.size; + const end = await this._runStep(overlay, context, step, target, displayStepIndex, displayStepCount); + overlay.hide(); switch (end.action) { case 'next': if (index === stepCount - 1) { @@ -144,11 +157,25 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres } } - private async _resolveTarget(targetWindow: Window, targetId: string): Promise { - const deadline = Date.now() + TARGET_RESOLVE_TIMEOUT; + private async _resolveTarget(targetWindow: Window, targetId: string, cancellationToken: CancellationToken, behavior?: SpotlightMissingTargetBehavior): Promise { + if (cancellationToken.isCancellationRequested) { + return undefined; + } let element = findOnboardingTarget(targetWindow, targetId); - while (!element && Date.now() < deadline) { - await timeout(TARGET_POLL_INTERVAL); + if (element || behavior?.kind === 'skip') { + return element; + } + const timeoutMs = behavior?.kind === 'wait' ? Math.max(0, behavior.timeoutMs) : TARGET_RESOLVE_TIMEOUT; + const deadline = Date.now() + timeoutMs; + while (!element && Date.now() < deadline && !cancellationToken.isCancellationRequested) { + try { + await timeout(TARGET_POLL_INTERVAL, cancellationToken); + } catch (error) { + if (cancellationToken.isCancellationRequested) { + return undefined; + } + throw error; + } element = findOnboardingTarget(targetWindow, targetId); } return element; @@ -177,34 +204,66 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres return animations; } - private _runStep(overlay: SpotlightOverlay, context: IOnboardingRunContext, step: ISpotlightStep, target: HTMLElement, index: number, stepCount: number): Promise { - return new Promise(resolve => { - const stepStore = new DisposableStore(); - const done = (end: StepEnd) => { - stepStore.dispose(); - resolve(end); - }; + private async _runStep(overlay: SpotlightOverlay, context: IOnboardingRunContext, step: ISpotlightStep, target: HTMLElement, index: number, stepCount: number): Promise { + const stepStore = new DisposableStore(); + let ended = false; + let resolveStep: (end: StepEnd) => void; + const result = new Promise(resolve => resolveStep = resolve); + const done = (end: StepEnd) => { + if (ended) { + return; + } + ended = true; + stepStore.dispose(); + resolveStep(end); + }; - stepStore.add(overlay.onDidClickNext(via => done({ action: 'next', via }))); - stepStore.add(overlay.onDidClickPrevious(() => done({ action: 'back' }))); - stepStore.add(overlay.onDidSkip(reason => done({ action: 'skip', reason }))); - stepStore.add(context.onAbort(() => done({ action: 'abort' }))); - - const content: ISpotlightContent = { - title: step.title, - description: step.description, - stepIndex: index, - stepCount, - canGoBack: index > 0, - isLastStep: index === stepCount - 1, - }; + stepStore.add(overlay.onDidClickNext(via => done({ action: 'next', via }))); + stepStore.add(overlay.onDidClickPrevious(() => done({ action: 'back' }))); + stepStore.add(overlay.onDidSkip(reason => done({ action: 'skip', reason }))); + stepStore.add(context.onAbort(() => done({ action: 'abort' }))); - overlay.show(target, content, { - placement: step.placement, - allowTargetInteraction: step.allowTargetInteraction, - advanceOnTargetClick: step.advanceOnTargetClick, - padding: step.padding, - }); + const content: ISpotlightContent = { + title: step.title, + description: step.description, + stepIndex: index, + stepCount, + canGoBack: index > 0, + isLastStep: index === stepCount - 1, + }; + + overlay.show(target, content, { + placement: step.placement, + allowTargetInteraction: step.allowTargetInteraction, + advanceOnTargetClick: step.advanceOnTargetClick, + hideNext: !!step.advanceWhen, + targetOverlayVisible: step.openTarget, + padding: step.padding, }); + + if (step.advanceWhen) { + const keys = new Set(step.advanceWhen.keys()); + const advanceIfSatisfied = () => { + if (this.contextKeyService.contextMatchesRules(step.advanceWhen)) { + done({ action: 'next', via: 'condition' }); + } + }; + stepStore.add(this.contextKeyService.onDidChangeContext(event => { + if (event.affectsSome(keys)) { + advanceIfSatisfied(); + } + })); + advanceIfSatisfied(); + } + + if (step.openTarget && !ended) { + try { + await openOnboardingTarget(target); + } catch (error) { + onUnexpectedError(error); + } + } + + return result; } } diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts index 9c271f4aff5ba9..30a68289291cb7 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts @@ -12,6 +12,11 @@ export const SPOTLIGHT_PRESENTATION_KIND = 'spotlight'; /** Preferred placement of the callout relative to the spotlighted target. */ export type SpotlightPlacement = 'above' | 'below' | 'left' | 'right' | 'auto'; +/** Behavior when a spotlight target is not rendered when its step is reached. */ +export type SpotlightMissingTargetBehavior = + | { readonly kind: 'skip' } + | { readonly kind: 'wait'; readonly timeoutMs: number }; + /** * A single step in a spotlight tour. Steps are pure data; the spotlight * presentation turns them into the dim overlay, the cut-out highlight and the @@ -40,6 +45,12 @@ export interface ISpotlightStep { /** When present and unsatisfied, the step is skipped. */ readonly when?: ContextKeyExpression; + /** Missing-target behavior. Defaults to waiting two seconds before skipping. */ + readonly missingTarget?: SpotlightMissingTargetBehavior; + + /** Opens or expands the target through its owner before the step begins. */ + readonly openTarget?: boolean; + /** Allow the spotlighted element to remain interactive. Defaults to `false`. */ readonly allowTargetInteraction?: boolean; @@ -49,6 +60,9 @@ export interface ISpotlightStep { */ readonly advanceOnTargetClick?: boolean; + /** Hides Next and advances once this context expression becomes satisfied. */ + readonly advanceWhen?: ContextKeyExpression; + /** Extra padding (px) around the target when cutting the highlight hole. */ readonly padding?: number; diff --git a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts index c93f4d453a9db6..0f95d3eabfc98c 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts @@ -181,13 +181,59 @@ suite('SpotlightOverlay', () => { const container = createContainer(); const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); const target = createTarget(container, 100, 100, 80, 30); + const targetButton = $('button'); + target.appendChild(targetButton); const blockers = () => Array.from(container.getElementsByClassName('spotlight-blocker')) as HTMLElement[]; + const callout = container.getElementsByClassName('spotlight-callout')[0] as HTMLElement; overlay.show(target, content(), { allowTargetInteraction: false }); - assert.deepStrictEqual(blockers().map(blocker => blocker.style.display), ['', 'none', 'none', 'none']); + assert.deepStrictEqual({ blockers: blockers().map(blocker => blocker.style.display), ariaModal: callout.getAttribute('aria-modal') }, { + blockers: ['', 'none', 'none', 'none'], + ariaModal: 'true', + }); overlay.show(target, content(), { allowTargetInteraction: true }); - assert.deepStrictEqual(blockers().map(blocker => blocker.style.display), ['', '', '', '']); + const [, , next] = getButtons(container); + const root = container.getElementsByClassName('spotlight-overlay')[0] as HTMLElement; + next.focus(); + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => 9 /* Tab */ }); + next.dispatchEvent(event); + + assert.deepStrictEqual({ + blockers: blockers().map(blocker => blocker.style.display), + targetOverlayVisible: root.classList.contains('target-overlay-visible'), + ariaModal: callout.getAttribute('aria-modal'), + activeElement: mainWindow.document.activeElement, + }, { + blockers: ['', '', '', ''], + targetOverlayVisible: true, + ariaModal: 'false', + activeElement: targetButton, + }); + }); + + test('hideNext routes target keyboard events through the focus trap', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); + const target = createTarget(container, 100, 100, 80, 30); + target.tabIndex = 0; + + overlay.show(target, content(), { hideNext: true }); + const [skip, , next] = getButtons(container); + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => 9 /* Tab */ }); + target.dispatchEvent(event); + + assert.deepStrictEqual({ + nextHidden: next.style.display === 'none', + ariaModal: container.getElementsByClassName('spotlight-callout')[0].getAttribute('aria-modal'), + activeElement: mainWindow.document.activeElement, + }, { + nextHidden: true, + ariaModal: 'false', + activeElement: skip, + }); }); test('observes the target and container for re-layout', () => { diff --git a/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts new file mode 100644 index 00000000000000..02e09e645a8153 --- /dev/null +++ b/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { $ } from '../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { TestHostService, TestLayoutService } from '../../../../test/browser/workbenchTestServices.js'; +import { SpotlightPresentation } from '../../browser/spotlight/spotlightPresentation.js'; +import { IOnboardingTargetOptions, markOnboardingTarget } from '../../browser/spotlight/onboardingTarget.js'; +import { ISpotlightPayload, ISpotlightStep, SPOTLIGHT_PRESENTATION_KIND } from '../../browser/spotlight/spotlightTypes.js'; +import { IOnboardingScenario, OnboardingDismissReason, OnboardingOutcome } from '../../common/onboardingScenario.js'; + +class SpotlightTestLayoutService extends TestLayoutService { + constructor(private readonly _container: HTMLElement) { + super(); + } + + override getContainer(): HTMLElement { + return this._container; + } +} + +suite('SpotlightPresentation', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContainer(): HTMLElement { + const container = $('.spotlight-presentation-test'); + mainWindow.document.body.appendChild(container); + disposables.add({ dispose: () => container.remove() }); + return container; + } + + function createTarget(container: HTMLElement, id: string, options?: IOnboardingTargetOptions): HTMLElement { + const target = $('button'); + target.style.position = 'fixed'; + target.style.left = '100px'; + target.style.top = '100px'; + target.style.width = '100px'; + target.style.height = '30px'; + container.appendChild(target); + disposables.add(markOnboardingTarget(target, id, options)); + return target; + } + + function createScenario(id: string, ...steps: ISpotlightStep[]): IOnboardingScenario { + return { + id, + trigger: { kind: 'auto' }, + presentation: { + kind: SPOTLIGHT_PRESENTATION_KIND, + payload: { steps }, + }, + }; + } + + test('waits for a late target and skips a missing target immediately', async () => { + const container = createContainer(); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeyService)); + + const lateTargetId = 'test.spotlight.lateTarget'; + const lateScenario = createScenario('test.spotlight.wait', { + id: 'late', + targetId: lateTargetId, + title: 'Late target', + description: 'Late target description', + missingTarget: { kind: 'wait', timeoutMs: 500 }, + advanceOnTargetClick: true, + openTarget: true, + onBeforeShow: () => { + disposables.add(disposableTimeout(() => { + const target = createTarget(container, lateTargetId, { open: () => target.click() }); + }, 100)); + }, + }); + const lateResult = await presentation.run(lateScenario, { targetWindow: mainWindow, onAbort: Event.None }); + + const missingScenario = createScenario('test.spotlight.skip', { + id: 'missing', + targetId: 'test.spotlight.missingTarget', + title: 'Missing target', + description: 'Missing target description', + missingTarget: { kind: 'skip' }, + }); + const missingResult = await presentation.run(missingScenario, { targetWindow: mainWindow, onAbort: Event.None }); + + assert.deepStrictEqual({ lateResult, missingResult }, { + lateResult: { + outcome: OnboardingOutcome.Completed, + shown: true, + dismissReason: OnboardingDismissReason.TargetClick, + lastStepIndex: 0, + stepCount: 1, + }, + missingResult: { + outcome: OnboardingOutcome.Completed, + shown: false, + dismissReason: OnboardingDismissReason.Completed, + lastStepIndex: 0, + stepCount: 1, + }, + }); + }); + + test('excludes skipped steps from displayed progress', async () => { + const container = createContainer(); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeyService)); + const progress: { readonly counter: string | null; readonly backHidden: boolean; readonly nextLabel: string | null }[] = []; + + const createAdvancingTarget = (id: string): HTMLElement => { + const target = createTarget(container, id, { + open: () => { + const buttons = Array.from(container.getElementsByClassName('monaco-button')) as HTMLElement[]; + progress.push({ + counter: container.getElementsByClassName('spotlight-callout-counter')[0].textContent, + backHidden: buttons[1].style.display === 'none', + nextLabel: buttons[2].textContent, + }); + target.click(); + }, + }); + return target; + }; + + createAdvancingTarget('test.spotlight.second'); + createAdvancingTarget('test.spotlight.third'); + const result = await presentation.run(createScenario('test.spotlight.skippedProgress', + { + id: 'first', + targetId: 'test.spotlight.first', + title: 'First', + description: 'Skipped first step', + when: ContextKeyExpr.equals('testSpotlightShowFirst', true), + }, + { + id: 'second', + targetId: 'test.spotlight.second', + title: 'Second', + description: 'First visible step', + openTarget: true, + advanceOnTargetClick: true, + }, + { + id: 'third', + targetId: 'test.spotlight.third', + title: 'Third', + description: 'Second visible step', + openTarget: true, + advanceOnTargetClick: true, + }, + ), { targetWindow: mainWindow, onAbort: Event.None }); + + assert.deepStrictEqual({ progress, result }, { + progress: [ + { counter: '1 of 2', backHidden: true, nextLabel: 'Next' }, + { counter: '2 of 2', backHidden: false, nextLabel: 'Done' }, + ], + result: { + outcome: OnboardingOutcome.Completed, + shown: true, + dismissReason: OnboardingDismissReason.TargetClick, + lastStepIndex: 2, + stepCount: 3, + }, + }); + }); + + test('hides the previous step while waiting for the next target', async () => { + const container = createContainer(); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeyService)); + const firstTarget = createTarget(container, 'test.spotlight.firstVisible', { open: () => firstTarget.click() }); + let hiddenWhileWaiting = false; + + const result = await presentation.run(createScenario('test.spotlight.hiddenWhileWaiting', + { + id: 'first', + targetId: 'test.spotlight.firstVisible', + title: 'First', + description: 'First step', + openTarget: true, + advanceOnTargetClick: true, + }, + { + id: 'second', + targetId: 'test.spotlight.secondLate', + title: 'Second', + description: 'Late second step', + missingTarget: { kind: 'wait', timeoutMs: 500 }, + openTarget: true, + advanceOnTargetClick: true, + onBeforeShow: () => { + const overlay = container.getElementsByClassName('spotlight-overlay')[0] as HTMLElement; + hiddenWhileWaiting = overlay.style.display === 'none'; + disposables.add(disposableTimeout(() => { + const target = createTarget(container, 'test.spotlight.secondLate', { open: () => target.click() }); + }, 100)); + }, + }, + ), { targetWindow: mainWindow, onAbort: Event.None }); + + assert.deepStrictEqual({ hiddenWhileWaiting, result }, { + hiddenWhileWaiting: true, + result: { + outcome: OnboardingOutcome.Completed, + shown: true, + dismissReason: OnboardingDismissReason.TargetClick, + lastStepIndex: 1, + stepCount: 2, + }, + }); + }); + + test('aborts immediately while waiting for a target', async () => { + const container = createContainer(); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeyService)); + const abort = disposables.add(new Emitter()); + + const result = await presentation.run(createScenario('test.spotlight.abortWait', { + id: 'missing', + targetId: 'test.spotlight.abortMissing', + title: 'Missing', + description: 'Missing target', + missingTarget: { kind: 'wait', timeoutMs: 60_000 }, + onBeforeShow: () => { + disposables.add(disposableTimeout(() => abort.fire(), 0)); + }, + }), { targetWindow: mainWindow, onAbort: abort.event }); + + assert.deepStrictEqual(result, { + outcome: OnboardingOutcome.Aborted, + shown: false, + dismissReason: OnboardingDismissReason.Aborted, + lastStepIndex: 0, + stepCount: 1, + }); + }); + + test('opens the target and advances when its context condition becomes true', async () => { + const container = createContainer(); + const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); + const workspaceSelected = contextKeyService.createKey('testSpotlightWorkspaceSelected', false); + const target = createTarget(container, 'test.spotlight.workspace'); + let stateAtOpen: { readonly nextHidden: boolean; readonly targetOverlayVisible: boolean } | undefined; + disposables.add(markOnboardingTarget(target, 'test.spotlight.workspace', { + open: () => { + const overlay = container.getElementsByClassName('spotlight-overlay')[0] as HTMLElement; + const buttons = Array.from(container.getElementsByClassName('monaco-button')) as HTMLElement[]; + stateAtOpen = { + nextHidden: buttons.at(-1)?.style.display === 'none', + targetOverlayVisible: overlay.classList.contains('target-overlay-visible'), + }; + workspaceSelected.set(true); + }, + })); + + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeyService)); + const result = await presentation.run(createScenario('test.spotlight.advanceWhen', { + id: 'workspace', + targetId: 'test.spotlight.workspace', + title: 'Workspace', + description: 'Choose a workspace', + openTarget: true, + allowTargetInteraction: true, + advanceWhen: ContextKeyExpr.equals('testSpotlightWorkspaceSelected', true), + }), { targetWindow: mainWindow, onAbort: Event.None }); + + assert.deepStrictEqual({ stateAtOpen, result }, { + stateAtOpen: { nextHidden: true, targetOverlayVisible: true }, + result: { + outcome: OnboardingOutcome.Completed, + shown: true, + dismissReason: OnboardingDismissReason.Completed, + lastStepIndex: 0, + stepCount: 1, + }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index 4dec33086ae67e..ac058698165f15 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -13,7 +13,7 @@ * (quick input, hovers, suggest, context menus, toasts, dialogs, find, * rename, color picker, ...). * - Inner radius (6px): non-control containers that sit within the UI - * (notification center surfaces, sticky scroll, ...). + * (notification center surfaces, ...). * - Controls (4px): all interactable UI controls (text inputs, selects, * list/tree rows, ...). * @@ -137,6 +137,11 @@ border-radius: var(--vscode-cornerRadius-small) !important; } +/* Match the minimap viewport slider to the adjacent editor scrollbar. */ +.style-override .monaco-editor .minimap-slider .minimap-slider-horizontal { + border-radius: var(--vscode-cornerRadius-small); +} + /* * Diff editor "fake united slider": when the overview ruler is shown, the diff * editor draws its own combined viewport slider (`.diffViewport`, spanning both @@ -164,16 +169,17 @@ width: calc(100% - 1px) !important; } -/* ============================================================================= - * Inner (6px) — non-control containers that live within the UI. - * ========================================================================== */ - /* Editor sticky scroll container */ .style-override .monaco-editor .sticky-widget { - border-radius: var(--vscode-cornerRadius-medium) !important; + border-radius: 0 !important; overflow: hidden; + width: 100% !important; } +/* ============================================================================= + * Inner (6px) — non-control containers that live within the UI. + * ========================================================================== */ + /* * Workbench parts (side bar, panel, auxiliary bar). The editor part is framed * separately by the "Editor Border" module; round the remaining parts to the diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index e484e7b06f4a43..7ca8479ee0e654 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -39,6 +39,14 @@ --tab-border-top-color: transparent !important; } +.style-override .part.editor .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { + margin-right: calc(var(--last-tab-margin-right) + var(--vscode-spacing-size40)) !important; +} + +.style-override .part.editor .tabs-and-actions-container.wrapping .tabs-container { + row-gap: var(--vscode-spacing-size40); +} + .style-override .part.editor .tabs-container > .tab > .tab-label > .monaco-icon-label-container { font-weight: var(--vscode-fontWeight-semiBold); } diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index a90c044f02bd92..ac3207098565ea 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -69,6 +69,7 @@ const MAX_WALKTHROUGHS = 10; const WELCOME_CHAT_INPUT_LAYOUT_HEIGHT = 150; const WELCOME_CHAT_INPUT_RESERVED_LIST_HEIGHT = 50; const WELCOME_CHAT_INPUT_RESERVED_CHROME_HEIGHT = 72; +const WELCOME_COMPACT_HEIGHT = 800; // Mirror ChatWidget's compact-surface sizing so the hidden list reservation and input chrome do not collapse the editor. const WELCOME_CHAT_INPUT_MAX_HEIGHT_OVERRIDE = WELCOME_CHAT_INPUT_LAYOUT_HEIGHT + WELCOME_CHAT_INPUT_RESERVED_LIST_HEIGHT + WELCOME_CHAT_INPUT_RESERVED_CHROME_HEIGHT; @@ -804,6 +805,7 @@ export class AgentSessionsWelcomePage extends EditorPane { this.lastDimension = dimension; this.container.style.height = `${dimension.height}px`; this.container.style.width = `${dimension.width}px`; + this.container.classList.toggle('height-constrained', dimension.height <= WELCOME_COMPACT_HEIGHT); // Layout chat widget this.layoutChatWidget(); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css index e238e6f13644b4..558751b57be473 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -14,7 +14,6 @@ .agentSessionsWelcome-scrollable { height: 100%; display: flex !important; - align-items: center; justify-content: center; } @@ -22,12 +21,24 @@ display: flex; flex-direction: column; align-items: center; + justify-content: safe center; + height: 100%; padding: 40px; max-width: 900px; width: 100%; box-sizing: border-box; } +.agentSessionsWelcome.height-constrained .agentSessionsWelcome-content { + padding-block: 20px; +} + +.agentSessionsWelcome.height-constrained .agentSessionsWelcome-chatSection, +.agentSessionsWelcome.height-constrained .agentSessionsWelcome-sessionsSection, +.agentSessionsWelcome.height-constrained .agentSessionsWelcome-footer { + margin-top: 16px; +} + /* Header */ .agentSessionsWelcome-header { display: flex; diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css index d2310e2c2e722d..c2622705835868 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css @@ -157,6 +157,10 @@ margin-bottom: 32px; } +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories > .gettingStartedCategoriesContainer > .categories-column > .recently-opened { + margin-bottom: var(--vscode-spacing-size80); +} + .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories > .gettingStartedCategoriesContainer > .categories-column-left { grid-area: left-column; } @@ -942,7 +946,6 @@ cursor: pointer; font-family: inherit; font-size: 13px; - margin-bottom: 20px; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories > .gettingStartedCategoriesContainer .index-list.start-container { diff --git a/src/vs/workbench/services/authentication/common/authentication.ts b/src/vs/workbench/services/authentication/common/authentication.ts index 1b57dd1ef64d8f..d940213756fe27 100644 --- a/src/vs/workbench/services/authentication/common/authentication.ts +++ b/src/vs/workbench/services/authentication/common/authentication.ts @@ -118,6 +118,10 @@ export interface IAuthenticationConstraint { * Options for getting authentication sessions via the service. */ export interface IAuthenticationGetSessionsOptions { + /** + * Whether the provider must avoid user interaction while resolving existing sessions. + */ + silent?: boolean; /** * The account that is being asked about. If this is passed in, the provider should * attempt to return the sessions that are only related to this account. @@ -408,6 +412,10 @@ export interface IAuthenticationExtensionsService { * Options passed to the authentication provider when asking for sessions. */ export interface IAuthenticationProviderSessionOptions { + /** + * Whether the provider must avoid user interaction while resolving existing sessions. + */ + silent?: boolean; /** * The account that is being asked about. If this is passed in, the provider should * attempt to return the sessions that are only related to this account. diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index 1475c885ff1ba5..7feca965db8b10 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -22,6 +22,8 @@ import { EditorService } from '../../browser/editorService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; import { Memento } from '../../../../common/memento.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { EditorPartModalVisibleContext } from '../../../../common/contextkeys.js'; suite('Modal Editor Group', () => { @@ -416,17 +418,36 @@ suite('Modal Editor Group', () => { instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); const parts = await createEditorParts(instantiationService, disposables); instantiationService.stub(IEditorGroupsService, parts); + const contextKeyService = instantiationService.invokeFunction(accessor => accessor.get(IContextKeyService)); // No modal initially - assert.strictEqual(parts.activeModalEditorPart, undefined); + assert.deepStrictEqual({ + activeModalEditorPart: parts.activeModalEditorPart, + modalEditorVisible: EditorPartModalVisibleContext.getValue(contextKeyService), + }, { + activeModalEditorPart: undefined, + modalEditorVisible: false, + }); // Create modal const modalPart = await parts.createModalEditorPart(); - assert.strictEqual(parts.activeModalEditorPart, modalPart); + assert.deepStrictEqual({ + activeModalEditorPart: parts.activeModalEditorPart, + modalEditorVisible: EditorPartModalVisibleContext.getValue(contextKeyService), + }, { + activeModalEditorPart: modalPart, + modalEditorVisible: true, + }); // Close modal await modalPart.close(); - assert.strictEqual(parts.activeModalEditorPart, undefined); + assert.deepStrictEqual({ + activeModalEditorPart: parts.activeModalEditorPart, + modalEditorVisible: EditorPartModalVisibleContext.getValue(contextKeyService), + }, { + activeModalEditorPart: undefined, + modalEditorVisible: false, + }); }); test('findGroup returns main part group when modal is active and preferredGroup is not MODAL_GROUP', async () => { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts index 0c6ac862bce108..17b57f2598c899 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts @@ -608,6 +608,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-json'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Other, label: '{ "action": "deleteFile", "path": "/src/old-module.ts" }', languageId: 'json', @@ -633,6 +634,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-bash'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'npm install --save express@latest', languageId: 'sh', @@ -658,6 +660,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-powershell'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'Start-Job -ScriptBlock { Set-Location \'c:\\some\\path\'; npm install } | Out-Null', languageId: 'pwsh', @@ -683,6 +686,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-long'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'rm -rf node_modules && npm cache clean --force && npm install --legacy-peer-deps --ignore-scripts', languageId: 'sh', @@ -710,6 +714,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-1line'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'npm install --save express@latest', languageId: 'sh', @@ -735,6 +740,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-2lines'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install', languageId: 'sh', @@ -760,6 +766,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3lines'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install\nnpm run build', languageId: 'sh', @@ -785,6 +792,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-4lines'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install\nnpm run build\nnpm run test -- --coverage', languageId: 'sh', @@ -810,6 +818,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3longlines'); const approvalModel = createMockApprovalModel(resource, { + approvalId: resource.toString(), kind: AgentSessionApprovalKind.Terminal, label: 'RUSTFLAGS="-C target-cpu=native -C opt-level=3" cargo build --release --target x86_64-unknown-linux-gnu\nfind ./target/release -name "*.so" -exec strip --strip-unneeded {} \\; && tar czf release-bundle.tar.gz -C target/release .\ncurl -X POST https://deploy.internal.example.com/api/v2/artifacts/upload --header "Authorization: Bearer $DEPLOY_TOKEN" --form "bundle=@release-bundle.tar.gz"', languageId: 'sh', diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts index 859818c853f98c..67763b43882921 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts @@ -28,6 +28,8 @@ import { ISessionsProvidersService } from '../../../../../sessions/services/sess // eslint-disable-next-line local/code-import-patterns import { BlockedSessionsList, registerBlockedSessionsItemActions } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsList.js'; // eslint-disable-next-line local/code-import-patterns +import { registerBlockedSessionsHeaderActions, registerBlockedSessionsHeaderCommands } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; +// eslint-disable-next-line local/code-import-patterns import { ISessionCIFixModel, ISessionCIFixState } from '../../../../../sessions/contrib/sessions/browser/views/sessionsList.js'; import { IVoicePlaybackService } from '../../../../contrib/chat/common/voicePlaybackService.js'; import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; @@ -104,6 +106,7 @@ function createBlockedSession(options: IBlockedSessionOptions, approvals?: Map { }, onIgnoreSession: () => { }, + onShowAllSessions: () => { }, + onIgnoreAllSessions: () => { }, + onClose: () => { }, approvalModel, ciFixModel, })); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index 4a5ffb7377dfdc..1748c2b79272f4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -66,6 +66,7 @@ function buildBlocked(specs: readonly IBlockedSpec[]): { blocked: IBlockedSessio const chatResource = URI.parse(`session-chat:/blocked/${i}`); if (spec.approvalKind) { approvals.set(chatResource.toString(), { + approvalId: chatResource.toString(), kind: spec.approvalKind, label: 'npm run build', languageId: undefined,