diff --git a/build/azure-pipelines/win32/steps/product-build-win32-test.yml b/build/azure-pipelines/win32/steps/product-build-win32-test.yml index 403f02e5d90b2..0d9dcb8c7f968 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-test.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-test.yml @@ -159,3 +159,19 @@ steps: testResultsFiles: "*-results.xml" searchFolder: "$(Build.ArtifactStagingDirectory)/test-results" condition: succeededOrFailed() + + # Force-kill any lingering test processes that still hold smoke-test log + # files open and would otherwise break the 1ES "Publish Log Files" output. + - powershell: | + $ErrorActionPreference = "Continue" + $testRoot = "$(agent.builddirectory)\test" + Get-CimInstance Win32_Process -Filter "ExecutablePath IS NOT NULL" -ErrorAction SilentlyContinue | + Where-Object { $_.ExecutablePath -like "$testRoot\*" } | + ForEach-Object { + Write-Host "Killing lingering test process: pid=$($_.ProcessId), name=$($_.Name), path=$($_.ExecutablePath)" + try { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } + catch { Write-Host " failed: $($_.Exception.Message)" } + } + displayName: Kill lingering test processes + continueOnError: true + condition: succeededOrFailed() diff --git a/extensions/copilot/.eslintplugin/index.ts b/extensions/copilot/.eslintplugin/index.ts index fc9d2fbf3275b..f02f0cc170de2 100644 --- a/extensions/copilot/.eslintplugin/index.ts +++ b/extensions/copilot/.eslintplugin/index.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import type { LooseRuleDefinition } from '@typescript-eslint/utils/ts-eslint'; -import * as glob from 'glob'; +import fs from 'fs'; import path from 'path'; // Re-export all .ts files as rules const rules: Record = {}; await Promise.all( - glob.sync('*.ts', { cwd: import.meta.dirname }) - .filter(file => !file.endsWith('index.ts') && !file.endsWith('utils.ts')) + fs.readdirSync(import.meta.dirname) + .filter(file => file.endsWith('.ts') && !file.endsWith('index.ts') && !file.endsWith('utils.ts')) .map(async file => { rules[path.basename(file, '.ts')] = (await import('./' + file)).default; }) diff --git a/extensions/copilot/.eslintplugin/no-unlayered-files.ts b/extensions/copilot/.eslintplugin/no-unlayered-files.ts index 31df3eab2d4f5..a5890a63ce106 100644 --- a/extensions/copilot/.eslintplugin/no-unlayered-files.ts +++ b/extensions/copilot/.eslintplugin/no-unlayered-files.ts @@ -19,7 +19,15 @@ export default new class NoUnlayeredFiles implements eslint.Rule.RuleModule { create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - const filenameParts = context.filename.split(path.sep); + // Use only the path relative to extensions/copilot/ to avoid false positives + // from the repo directory name (e.g., "vscode" is both a layer name and the + // checkout directory, so absolute paths always contain it). + const copilotPrefix = `extensions${path.sep}copilot${path.sep}`; + const idx = context.filename.indexOf(copilotPrefix); + const relativePath = idx >= 0 + ? context.filename.slice(idx + copilotPrefix.length) + : context.filename; + const filenameParts = relativePath.split(path.sep); if (!filenameParts.find(part => layers.has(part))) { context.report({ diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index d147cb1ae67d5..bc413df0967f6 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4094,6 +4094,16 @@ "advanced" ] }, + "github.copilot.chat.anthropic.promptCaching.extendedTtl": { + "type": "boolean", + "default": false, + "tags": [ + "advanced", + "experimental", + "onExp" + ], + "description": "%github.copilot.config.anthropic.promptCaching.extendedTtl%" + }, "github.copilot.chat.installExtensionSkill.enabled": { "type": "boolean", "default": false, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index ac6600346ab24..ebb03a7f9d338 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -339,6 +339,7 @@ "copilot.toolSet.web.description": "Fetch information from the web", "github.copilot.config.useMessagesApi": "Use the Messages API instead of the Chat Completions API when supported.", "github.copilot.config.anthropic.contextEditing.mode": "Select the context editing mode for Anthropic models. Automatically manages conversation context as it grows, helping optimize costs and stay within context window limits.\n\n- `off`: Context editing is disabled.\n- `clear-thinking`: Clears thinking blocks while preserving tool uses.\n- `clear-tooluse`: Clears tool uses while preserving thinking blocks.\n- `clear-both`: Clears both thinking blocks and tool uses.\n\n**Note**: This is an experimental feature. Context editing may cause additional cache rewrites. Enable with caution.", + "github.copilot.config.anthropic.promptCaching.extendedTtl": "Use the extended (1 hour) prompt cache TTL on tools and system blocks for the Anthropic Messages API. Only applied to 1M context Claude variants; other models keep the default 5 minute TTL even when this setting is enabled.\n\n**Note**: This is an experimental feature. Only the main agent conversation is eligible — inline chat, terminal chat, notebook chat, and subagent requests are excluded.", "github.copilot.config.useResponsesApi": "Use the Responses API instead of the Chat Completions API when supported. Enables reasoning and reasoning summaries.\n\n**Note**: This is an experimental feature that is not yet activated for all users.\n\n**Important**: URL API path resolution for custom OpenAI-compatible and Azure models is independent of this setting and fully determined by `url` property of `#github.copilot.chat.customOAIModels#` or `#github.copilot.chat.azureModels#` respectively.", "github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index b13c7451bd222..4d7180a7988de 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -23,7 +23,6 @@ import { ResourceSet } from '../../../../util/vs/base/common/map'; import { basename } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; -import { getCopilotLogger } from './logger'; import { ensureNodePtyShim } from './nodePtyShim'; import { ensureRipgrepShim } from './ripgrepShim'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; @@ -307,7 +306,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; constructor( @IPromptsService private readonly promptsService: IPromptsService, - @ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK, @IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext, @ILogService private readonly logService: ILogService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @@ -386,7 +384,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { async getAgentsImpl(): Promise { const merged = new Map(); const knownAgents = new ResourceSet(); - const [sdkAgents, customAgents] = await Promise.all([this.getSDKAgents(), this.promptsService.getCustomAgents(CancellationToken.None)]); + const customAgents = await this.promptsService.getCustomAgents(CancellationToken.None); const hiddenOrInvalidAgentUris = new ResourceSet(); const validCustomAgents = customAgents.filter(customAgent => { if (!customAgent.enabled || !isEnabledForCopilotCLI(customAgent)) { @@ -402,17 +400,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { return true; }); - for (const agent of sdkAgents) { - const sourceUri = agent.path ? URI.file(agent.path) : URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` }); - if (hiddenOrInvalidAgentUris.has(sourceUri)) { - continue; - } - knownAgents.add(sourceUri); - merged.set(agent.name.toLowerCase(), { - agent: this.cloneAgent(agent), - sourceUri, - }); - } for (const customAgent of validCustomAgents) { if (knownAgents.has(customAgent.uri)) { continue; @@ -427,18 +414,6 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents { return [...merged.values()]; } - private async getSDKAgents(): Promise[]> { - const workspaceFolders = this.workspaceService.getWorkspaceFolders(); - if (workspaceFolders.length === 0) { - return []; - } - - const [auth, { getCustomAgents }] = await Promise.all([this.copilotCLISDK.getAuthInfo(), this.copilotCLISDK.getPackage()]); - const workingDirectory = workspaceFolders[0]; - const agents = await getCustomAgents(auth, workingDirectory.fsPath, undefined, getCopilotLogger(this.logService)); - return agents.map(agent => this.cloneAgent(agent)); - } - private toCustomAgent(customAgent: vscode.ChatCustomAgent): CLIAgentInfo | undefined { const agentName = getAgentFileNameFromFilePath(customAgent.uri); const headerName = customAgent.name; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 8bc81fab21191..6689b74e05567 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, LocalSession, SendOptions, Session, SessionOptions } from '@github/copilot/sdk'; +import type { Attachment, LocalSession, SendOptions, Session, SessionOptions, ToolExecutionCompleteEvent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as cp from 'child_process'; import * as crypto from 'crypto'; @@ -18,6 +18,7 @@ import { GenAiMetrics } from '../../../../platform/otel/common/genAiMetrics'; import { CopilotChatAttr, GenAiAttr, GenAiOperationName, GenAiProviderName, IOTelService, ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel, resolveWorkspaceOTelMetadata, workspaceMetadataToOTelAttributes } from '../../../../platform/otel/common/index'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { IRequestLogger, LoggedRequestKind } from '../../../../platform/requestLogger/common/requestLogger'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; import { PromptTokenCategory, PromptTokenLabel } from '../../../../platform/tokenizer/node/promptTokenDetails'; import { IGitService } from '../../../../platform/git/common/gitService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; @@ -815,6 +816,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes @IGitService private readonly _gitService: IGitService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IChatQuotaService private readonly _chatQuotaService: IChatQuotaService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this.sessionId = _sdkSession.sessionId; @@ -955,7 +957,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } private async _handleRequestImpl( - request: { id: string; toolInvocationToken: ChatParticipantToolToken }, + request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, @@ -1010,7 +1012,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private async _handleRequestImplInner( invokeAgentSpan: ISpanHandle, - request: { id: string; toolInvocationToken: ChatParticipantToolToken }, + request: { id: string; toolInvocationToken: ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], modelId: string | undefined, @@ -1062,6 +1064,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes const editToolIds = new Set(); const toolCalls = new Map(); + const toolStartTimes = new Map(); const editTracker = new ExternalEditTracker(); let sdkRequestId: string | undefined; let isQuotaError = false; @@ -1332,6 +1335,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes }))); disposables.add(toDisposable(this._sdkSession.on('tool.execution_start', (event) => { toolCalls.set(event.data.toolCallId, event.data as unknown as ToolCall); + toolStartTimes.set(event.data.toolCallId, Date.now()); if (isCopilotCliEditToolCall(event.data)) { flushPendingInvocationMessages(); @@ -1359,7 +1363,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes } }))); disposables.add(toDisposable(this._sdkSession.on('tool.execution_complete', (event) => { - const toolName = toolCalls.get(event.data.toolCallId)?.toolName || ''; + const toolCall = toolCalls.get(event.data.toolCallId); + const toolName = toolCall?.toolName || ''; if (toolName.endsWith('create_pull_request') && event.data.success) { const pullRequestUrl = extractPullRequestUrlFromToolResult(event.data.result); if (pullRequestUrl) { @@ -1367,10 +1372,15 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes GenAiMetrics.incrementPullRequestCount(this._otelService); } } + // Emit `languageModelToolInvoked` to mirror the workbench LanguageModelToolsService event + // for the Copilot CLI agent. CLI tools execute inside the SDK and never reach + // LanguageModelToolsService, so the workbench-side emission does not fire for them. + this._sendToolInvokedTelemetry(event, toolCall, toolStartTimes, request.sessionResource); + // Log tool call to request logger const eventError = event.data.error ? { ...event.data.error, code: event.data.error.code || '' } : undefined; const eventData = { ...event.data, error: eventError }; - this._logToolCall(event.data.toolCallId, toolName, toolCalls.get(event.data.toolCallId)?.arguments, eventData); + this._logToolCall(event.data.toolCallId, toolName, toolCall?.arguments, eventData); // Mark the end of the edit if this was an edit tool. toolIdEditMap.set(event.data.toolCallId, editTracker.completeEdit(event.data.toolCallId)); @@ -1392,9 +1402,8 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes // When a sql tool execution completes that modifies the todos table, // query the session database and update the todo list widget. if (toolName === 'sql' && event.data.success) { - const toolCallData = toolCalls.get(event.data.toolCallId); try { - const query = (toolCallData?.arguments as { query?: string } | undefined)?.query ?? ''; + const query = (toolCall?.arguments as { query?: string } | undefined)?.query ?? ''; if (isTodoRelatedSqlQuery(query)) { const sessionDir = getCopilotCLISessionDir(this.sessionId); this._todoSqlQuery.queryTodos(sessionDir).then(items => { @@ -2614,6 +2623,53 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes isConversationRequest: true }); } + + private _sendToolInvokedTelemetry( + event: ToolExecutionCompleteEvent, + toolCall: ToolCall | undefined, + toolStartTimes: Map, + sessionResource: vscode.Uri | undefined, + ): void { + const { toolCallId, success, error } = event.data; + const eventToolName = 'toolName' in event.data && typeof event.data.toolName === 'string' ? event.data.toolName : undefined; + const toolName = toolCall?.toolName ?? eventToolName ?? ''; + const startTime = toolStartTimes.get(toolCallId); + toolStartTimes.delete(toolCallId); + const invocationTimeMs = startTime !== undefined ? Date.now() - startTime : undefined; + + let result: 'success' | 'error' | 'userCancelled'; + if (success) { + result = 'success'; + } else if (error?.code === 'rejected' || error?.code === 'denied' || error?.code === 'cancelled') { + // `rejected`/`denied` come from the user denying a permission prompt; `cancelled` comes + // from request cancellation. + result = 'userCancelled'; + } else { + result = 'error'; + } + + const toolSourceKind = toolCall?.mcpServerName ? 'mcp' : 'copilotCli'; + + /* __GDPR__ + "languageModelToolInvoked" : { + "owner": "zhichli", + "comment": "Provides insight into the usage of language model tools (Copilot CLI agent).", + "result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "success | error | userCancelled" }, + "chatSessionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chat session resource id." }, + "toolId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The CLI/SDK tool name (e.g. bash, str_replace_editor, apply_patch)." }, + "toolExtensionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Always undefined for CLI." }, + "toolSourceKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "copilotCli | mcp" }, + "invocationTimeMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Time between tool.execution_start and tool.execution_complete (includes any permission wait)." } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('languageModelToolInvoked', { + result, + chatSessionId: sessionResource?.toString(), + toolId: toolName, + toolExtensionId: undefined, + toolSourceKind, + }, invocationTimeMs !== undefined ? { invocationTimeMs } : undefined); + } } function extractPullRequestUrlFromToolResult(result: unknown): string | undefined { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 4086fe58d8c59..efd5a11e591fb 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -1048,13 +1048,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS const detailsByCopilotId = new Map(); const defaultModeInstructions = agentId ? await this.resolveAgentModeInstructions(agentId) : undefined; - await Promise.all(storedDetails.map(async d => { + for (const d of storedDetails) { if (d.copilotRequestId) { - const turnAgentId = d.modeInstructions?.uri || d.agentId; - const modeInstructions = (d.modeInstructions ?? (turnAgentId ? await this.resolveAgentModeInstructions(turnAgentId) : defaultModeInstructions)) ?? defaultModeInstructions; + // Agents from older requests isn't useful, hence to save time. + // Re-use the same custom agent from last request for all previous requests. + const modeInstructions = defaultModeInstructions; detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions, responseModelId: d.responseModelId, creditsUsed: d.creditsUsed }); } - })); + } const getVSCodeRequestId = (sdkRequestId: string) => { const stored = detailsByCopilotId.get(sdkRequestId); if (stored) { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAgents.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAgents.spec.ts index e8246df28fa1d..12bd895a8bd15 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAgents.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAgents.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { SweCustomAgent } from '@github/copilot/sdk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext'; import { ILogService } from '../../../../../platform/log/common/logService'; import { PromptFileParser } from '../../../../../platform/promptFiles/common/promptsService'; @@ -13,7 +13,7 @@ import { Event } from '../../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../../util/vs/base/common/uri'; import { createExtensionUnitTestingServices } from '../../../../test/node/services'; -import { CopilotCLIAgents, type ICopilotCLISDK } from '../copilotCli'; +import { CopilotCLIAgents } from '../copilotCli'; import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService'; import type { ChatCustomAgent } from 'vscode'; @@ -45,23 +45,6 @@ function mockPromptFile(fileName: string, content: string): PromptFileInfo { return { uri: URI.file(`/workspace/.github/agents/${fileName}`), content }; } -function createMockSDK(agentsByCall: ReadonlyArray>): ICopilotCLISDK { - let index = 0; - const getCustomAgents = vi.fn(async () => { - const result = agentsByCall[Math.min(index, agentsByCall.length - 1)] ?? []; - index += 1; - return result; - }); - - return { - _serviceBrand: undefined, - getPackage: vi.fn(async () => ({ getCustomAgents })), - getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })), - getRequestId: vi.fn(() => undefined), - setRequestId: vi.fn(), - } as unknown as ICopilotCLISDK; -} - function createWorkspaceService(): IWorkspaceService { return { _serviceBrand: undefined, @@ -98,7 +81,7 @@ describe('CopilotCLIAgents', () => { }; } - function createAgents(options: { sdkAgentsByCall: ReadonlyArray>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService; sdk: ICopilotCLISDK } { + function createAgents(options: { sdkAgentsByCall: ReadonlyArray>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService } { const promptsService = disposables.add(new MockPromptsService()); if (options.customAgents) { const customAgents = []; @@ -108,16 +91,14 @@ describe('CopilotCLIAgents', () => { } promptsService.setCustomAgents(customAgents); } - const sdk = createMockSDK(options.sdkAgentsByCall); const agents = new CopilotCLIAgents( promptsService, - sdk, createMockExtensionContext(), logService, createWorkspaceService(), ); disposables.add(agents); - return { agents, promptsService, sdk }; + return { agents, promptsService }; } it('prefers prompt-derived agents over SDK agents with the same name', async () => { @@ -173,7 +154,7 @@ Body`)] }); it('refreshes cached agents when custom agents change', async () => { - const { agents, promptsService, sdk } = createAgents({ + const { agents, promptsService } = createAgents({ sdkAgentsByCall: [[], []], customAgents: [mockPromptFile('first.agent.md', `--- name: First @@ -192,7 +173,6 @@ Second body`))]); expect(first.map(a => a.agent.name)).toEqual(['First']); expect(second.map(a => a.agent.name)).toEqual(['Second']); - expect(sdk.getPackage).toHaveBeenCalled(); }); it('filters out legacy .chatmode.md files', async () => { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index fee54ab64efae..75a31ffd9eaa9 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -21,6 +21,7 @@ import { NullMcpService } from '../../../../../platform/mcp/common/mcpService'; import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index'; import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService'; import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/nullTelemetryService'; import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; import { mock } from '../../../../../util/common/test/simpleMock'; import { DisposableStore, IReference, toDisposable } from '../../../../../util/vs/base/common/lifecycle'; @@ -175,7 +176,7 @@ describe('CopilotCLISessionService', () => { deleteSession: vi.fn(async () => { }), }; } - return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any)); + return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService())); } } as unknown as IInstantiationService; const configurationService = accessor.get(IConfigurationService); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index c96a9fa21e977..0f8beea61b7c0 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -30,6 +30,8 @@ import { PermissionRequest } from '../permissionHelpers'; import { IQuestion, IQuestionAnswer, IUserQuestionHandler, UserInputResponse } from '../userInputHelpers'; import { NullICopilotCLIImageSupport } from './testHelpers'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/nullTelemetryService'; +import type { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../../../platform/telemetry/common/telemetry'; vi.mock('../cliHelpers', async (importOriginal) => ({ ...(await importOriginal()), @@ -214,6 +216,7 @@ describe('CopilotCLISession', () => { let chatSessionMetadataStore: MockChatSessionMetadataStore; let authInfo: NonNullable; let userQuestionAnswer: IQuestionAnswer | undefined; + let telemetryService: ITelemetryService; beforeEach(async () => { const services = disposables.add(createExtensionUnitTestingServices()); const accessor = services.createTestingAccessor(); @@ -234,6 +237,7 @@ describe('CopilotCLISession', () => { instaService = services.seal(); toolsService = new FakeToolsService(); userQuestionAnswer = undefined; + telemetryService = new NullTelemetryService(); }); afterEach(() => { @@ -266,7 +270,8 @@ describe('CopilotCLISession', () => { new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any, - { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any + { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, + telemetryService )); } @@ -680,6 +685,60 @@ describe('CopilotCLISession', () => { await requestPromise; }); + it('emits languageModelToolInvoked telemetry for each completed tool invocation', async () => { + class RecordingTelemetryService extends NullTelemetryService { + readonly events: { name: string; properties?: TelemetryEventProperties; measurements?: TelemetryEventMeasurements }[] = []; + override sendMSFTTelemetryEvent(name: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.events.push({ name, properties, measurements }); + } + } + const recorder = new RecordingTelemetryService(); + telemetryService = recorder; + + let resolveSend: () => void; + sdkSession.send = async () => new Promise(r => { resolveSend = r; }); + + const session = await createSession(); + session.attachStream(new MockChatResponseStream()); + const sessionResource = Uri.parse('copilotcli:/test-session'); + const requestPromise = session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never, sessionResource }, { prompt: 'Run tools' }, [], undefined, authInfo, CancellationToken.None); + await new Promise(r => setTimeout(r, 0)); + + // Native CLI tool: success + sdkSession.emit('tool.execution_start', { toolName: 'bash', toolCallId: 't-success', arguments: {} }); + sdkSession.emit('tool.execution_complete', { toolCallId: 't-success', toolName: 'completion_bash', success: true, result: { content: 'ok' } }); + + // MCP tool: failure + sdkSession.emit('tool.execution_start', { toolName: 'mcp_tool', toolCallId: 't-mcp', mcpServerName: 'srv', mcpToolName: 'mt', arguments: {} }); + sdkSession.emit('tool.execution_complete', { toolCallId: 't-mcp', toolName: 'mcp_tool', success: false, error: { code: 'tool_error', message: 'boom' } }); + + // User-denied permission: userCancelled + sdkSession.emit('tool.execution_start', { toolName: 'bash', toolCallId: 't-denied', arguments: {} }); + sdkSession.emit('tool.execution_complete', { toolCallId: 't-denied', toolName: 'bash', success: false, error: { code: 'denied', message: 'no' } }); + + // Completion-only event: fallback to toolName from the completion event + sdkSession.emit('tool.execution_complete', { toolCallId: 't-complete-only', toolName: 'completion_tool', success: true, result: { content: 'ok' } }); + + resolveSend!(); + await requestPromise; + + const invokedEvents = recorder.events.filter(e => e.name === 'languageModelToolInvoked'); + const invokedSnapshot = invokedEvents.map(e => ({ + result: e.properties?.result, + chatSessionId: e.properties?.chatSessionId, + toolId: e.properties?.toolId, + toolExtensionId: e.properties?.toolExtensionId, + toolSourceKind: e.properties?.toolSourceKind, + hasInvocationTimeMs: typeof e.measurements?.invocationTimeMs === 'number', + })); + expect(invokedSnapshot).toEqual([ + { result: 'success', chatSessionId: 'copilotcli:/test-session', toolId: 'bash', toolExtensionId: undefined, toolSourceKind: 'copilotCli', hasInvocationTimeMs: true }, + { result: 'error', chatSessionId: 'copilotcli:/test-session', toolId: 'mcp_tool', toolExtensionId: undefined, toolSourceKind: 'mcp', hasInvocationTimeMs: true }, + { result: 'userCancelled', chatSessionId: 'copilotcli:/test-session', toolId: 'bash', toolExtensionId: undefined, toolSourceKind: 'copilotCli', hasInvocationTimeMs: true }, + { result: 'success', chatSessionId: 'copilotcli:/test-session', toolId: 'completion_tool', toolExtensionId: undefined, toolSourceKind: 'copilotCli', hasInvocationTimeMs: false }, + ]); + }); + it('uses remote permission responses when Mission Control is active', async () => { let permissionResult: unknown; sdkSession.send = async () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 9f419a2debbaf..cb7d04320eba2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -406,7 +406,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { } }(); } - const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any); + const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService()); cliSessions.push(session); return disposables.add(session); } diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts index 7dded8ff7cf15..eba77088070a1 100644 --- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts +++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionSyncStatus.ts @@ -50,7 +50,7 @@ export class SessionSyncStatus extends Disposable { private _updateVisibility(): void { const localEnabled = this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService); - if (!localEnabled) { + if (!localEnabled || vscode.workspace.isAgentSessionsWorkspace) { this._statusItem.hide(); } else { this._statusItem.show(); @@ -73,42 +73,48 @@ export class SessionSyncStatus extends Disposable { // description → shown as badge in collapsed header (icon + message) // detail → shown when expanded - const tipsAction = `[${l10n.t('Get Tips from Sessions')}](command:workbench.action.chat.open?%7B%22query%22%3A%22%2Fchronicle%3Atips%22%7D)`; switch (state.kind) { case 'not-enabled': - this._statusItem.description = `$(circle-slash) ${l10n.t('Not enabled')}`; - this._statusItem.detail = `[${l10n.t('Enable Session Sync')}](command:workbench.action.openSettings?%5B%22chat.sessionSync.enabled%22%5D)`; + this._statusItem.description = l10n.t('Not enabled'); + this._statusItem.detail = `[${l10n.t('Enable?')}](command:workbench.action.openSettings?%5B%22chat.sessionSync.enabled%22%5D)`; + this._statusItem.tooltip = l10n.t('Session sync is not enabled. Your data stays local to this device.'); break; case 'disabled-by-policy': this._statusItem.description = `$(warning) ${l10n.t('Disabled by policy')}`; - this._statusItem.detail = l10n.t('Session sync is disabled by your organization\'s policy.'); + this._statusItem.detail = ''; + this._statusItem.tooltip = l10n.t('Session sync is disabled by your organization\'s policy.'); break; case 'on': - this._statusItem.description = `$(check) ${l10n.t('On')}`; - this._statusItem.detail = tipsAction; - break; - - case 'syncing': - this._statusItem.description = `${l10n.t('Syncing {0} session(s)\u2026', state.sessionCount)} $(loading~spin)`; - this._statusItem.detail = tipsAction; + this._statusItem.description = `$(check) ${l10n.t('Enabled')}`; + this._statusItem.detail = `[${l10n.t('Show insights?')}](command:workbench.action.chat.open?%7B%22query%22%3A%22%2Fchronicle%3Atips%22%7D)`; + this._statusItem.tooltip = l10n.t('Your sessions are being synced and available across devices.'); break; case 'up-to-date': this._statusItem.description = `$(check) ${l10n.t('{0} sessions synced', state.syncedCount)}`; - this._statusItem.detail = tipsAction; + this._statusItem.detail = `[${l10n.t('Show insights?')}](command:workbench.action.chat.open?%7B%22query%22%3A%22%2Fchronicle%3Atips%22%7D)`; + this._statusItem.tooltip = l10n.t('Your sessions are being synced and available across devices.'); + break; + + case 'syncing': + this._statusItem.description = `$(loading~spin) ${l10n.t('Syncing {0} session(s)\u2026', state.sessionCount)}`; + this._statusItem.detail = ''; + this._statusItem.tooltip = l10n.t('Syncing {0} session(s)\u2026', state.sessionCount); break; case 'deleting': - this._statusItem.description = `${l10n.t('Deleting {0} session(s)\u2026', state.sessionCount)} $(loading~spin)`; - this._statusItem.detail = tipsAction; + this._statusItem.description = `$(loading~spin) ${l10n.t('Deleting {0} session(s)\u2026', state.sessionCount)}`; + this._statusItem.detail = ''; + this._statusItem.tooltip = l10n.t('Deleting {0} session(s)\u2026', state.sessionCount); break; case 'error': - this._statusItem.description = `$(warning) ${l10n.t('Sync failed')}`; - this._statusItem.detail = tipsAction; + this._statusItem.description = `$(error) ${l10n.t('Sync error')}`; + this._statusItem.detail = ''; + this._statusItem.tooltip = l10n.t('Something went wrong during the last sync. Try again later.'); break; } } diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index fcb4cf5effbf1..bc5a3920c3acb 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -1123,7 +1123,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { 'OpenAI-Intent': intent, 'X-GitHub-Api-Version': '2025-05-01', 'X-Interaction-Id': this._interactionService.interactionId, - ...(chatEndpointInfo.getExtraHeaders ? chatEndpointInfo.getExtraHeaders(location) : {}), + ...(chatEndpointInfo.getExtraHeaders ? chatEndpointInfo.getExtraHeaders(location, interactionTypeOverride) : {}), }; additionalHeaders['X-Interaction-Type'] = agentInteractionType; additionalHeaders['X-Agent-Task-Id'] = ourRequestId; diff --git a/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts b/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts index a3d6d9080141d..e83c3bdbc085b 100644 --- a/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts +++ b/extensions/copilot/src/extension/workspaceChunkSearch/vscode-node/workspaceIndexingStatus.ts @@ -59,6 +59,7 @@ interface ChatStatusItemState { readonly message: string; readonly busy: boolean; }; + readonly tooltip?: string; } const spinnerCodicon = '$(loading~spin)'; @@ -89,10 +90,11 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { // Write an initial status this._writeStatusItem({ primary: { - message: t`Checking index status`, - busy: true + message: t`Checking...`, + icon: spinnerCodicon, }, - details: undefined + details: undefined, + tooltip: t`Checking the current index status...`, }); // And kick off async update to get the real status @@ -118,19 +120,32 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { case 'initializing': return this._writeStatusItem({ primary: { - message: t('Checking index status'), - busy: true, + message: t`Checking...`, + icon: spinnerCodicon, }, + tooltip: t`Checking the current index status...`, }); case 'loaded': { - // See if any repos are still being checked/resolved - if (state.remoteIndexState.repos.some(repo => repo.status === CodeSearchRepoStatus.CheckingStatus || repo.status === CodeSearchRepoStatus.Resolving)) { + // See if any repos are still being resolved + if (state.remoteIndexState.repos.some(repo => repo.status === CodeSearchRepoStatus.Resolving)) { return this._writeStatusItem({ primary: { - message: t('Checking repo statuses'), - busy: true, + message: t`Resolving...`, + icon: spinnerCodicon, }, + tooltip: t`Resolving repository information...`, + }); + } + + // See if any repos are still being checked + if (state.remoteIndexState.repos.some(repo => repo.status === CodeSearchRepoStatus.CheckingStatus)) { + return this._writeStatusItem({ + primary: { + message: t`Checking...`, + icon: spinnerCodicon, + }, + tooltip: t`Checking the current index status...`, }); } @@ -140,59 +155,73 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { ) { return this._writeStatusItem({ primary: { - message: t('Building Index'), - busy: true, + message: t`Indexing...`, + icon: spinnerCodicon, }, + tooltip: t`Your codebase is currently being indexed. This may take a few minutes.`, }); } - // Check if we have any errors + // Check if we have any authorization errors const readyRepos = state.remoteIndexState.repos.filter(repo => repo.status === CodeSearchRepoStatus.Ready); - const errorRepos = state.remoteIndexState.repos.filter(repo => repo.status === CodeSearchRepoStatus.CouldNotCheckIndexStatus || repo.status === CodeSearchRepoStatus.NotAuthorized); - if (errorRepos.length > 0) { - const inaccessibleRepo = errorRepos[0].remoteInfo; - if (readyRepos.length) { + const notAuthorizedRepos = state.remoteIndexState.repos.filter(repo => repo.status === CodeSearchRepoStatus.NotAuthorized); + if (notAuthorizedRepos.length > 0) { + const inaccessibleRepo = notAuthorizedRepos[0].remoteInfo; + if (readyRepos.length > 0) { + // Some repos are ready, some need re-auth return this._writeStatusItem({ primary: { message: readyRepos.length === 1 - ? t('1 repo with index') - : t('{0} repos with indexes', readyRepos.length), + ? t`1 repo with index` + : t`${readyRepos.length} repos with indexes`, icon: '$(warning)', }, details: { - message: errorRepos.length === 1 - ? t(`[Try re-authenticating for 1 additional repo](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`) - : t(`[Try re-authenticating for {0} additional repos](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`, errorRepos.length), + message: `[${t`Sign in?`}](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`, busy: false, }, + tooltip: notAuthorizedRepos.length === 1 + ? t`1 additional repo needs re-authentication.` + : t`${notAuthorizedRepos.length} additional repos need re-authentication.`, }); } else { return this._writeStatusItem({ primary: { - message: t('Index unavailable'), - icon: '$(error)', + message: t`Not authorized`, + icon: '$(lock)', }, details: { - message: t(`[Try re-authenticating](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`), + message: `[${t`Sign in?`}](${commandUri(reauthenticateCommandId, [inaccessibleRepo])} "${t('Try signing in again to use the codebase index')}")`, busy: false, }, + tooltip: t`You don't have permission to access the index for this repository.`, }); } } + // Check if we have other errors + const errorRepos = state.remoteIndexState.repos.filter(repo => repo.status === CodeSearchRepoStatus.CouldNotCheckIndexStatus); + if (errorRepos.length > 0) { + return this._writeStatusItem({ + primary: { + message: t`Not available`, + icon: '$(warning)', + }, + tooltip: t`This repository can't be indexed. It may be too large or not supported.`, + }); + } + // See if we have any unindexed repos if (state.remoteIndexState.repos.some(repo => repo.status === CodeSearchRepoStatus.NotYetIndexed)) { return this._writeStatusItem({ primary: { - message: state.remoteIndexState.repos.every(repo => repo.status === CodeSearchRepoStatus.NotYetIndexed) - ? t('Index not yet built') - : t('Index not yet built for a repo in the workspace'), - icon: '$(warning)', + message: t`Not indexed`, }, details: { - message: `[${t`Build index`}](command:${buildRemoteIndexCommandId} "${t('Build Codebase Index')}")`, + message: `[${t`Index?`}](command:${buildRemoteIndexCommandId} "${t('Build Codebase Index')}")`, busy: false, - } + }, + tooltip: t`This repository hasn't been indexed yet. Trigger indexing to enable semantic search.`, }); } @@ -210,9 +239,10 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { ) { return this._writeStatusItem({ primary: { - message: t('Index ready'), + message: t`Ready`, icon: '$(check)', }, + tooltip: t`Your index is up to date and being used to improve suggestions.`, }); } @@ -220,13 +250,13 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { if (typeof state.remoteIndexState.externalIngestState !== 'undefined') { return this._writeStatusItem({ primary: { - message: t('Out of date'), - icon: '$(warning)', + message: t`Out of date`, }, details: { - message: `[${t`Update index`}](command:${buildRemoteIndexCommandId} "${t('Update Codebase Index')}")`, + message: `[${t`Update?`}](command:${buildRemoteIndexCommandId} "${t('Update Codebase Index')}")`, busy: false, - } + }, + tooltip: t`Your index is out of date. Recent changes haven't been indexed yet.`, }); } @@ -240,10 +270,11 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { this._writeStatusItem({ primary: { - message: t('Codebase index not available'), - icon: '$(circle-slash)', + message: t`Not available`, + icon: '$(warning)', }, - details: undefined + details: undefined, + tooltip: t`This repository can't be indexed. It may be too large or not supported.`, }); } @@ -277,6 +308,8 @@ export class ChatStatusWorkspaceIndexingStatus extends Disposable { } else { this._statusItem.detail = ''; } + + this._statusItem.tooltip = values.tooltip; } private registerCommands(): IDisposable { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 87b68dcbb3002..3027b1801115e 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -727,6 +727,9 @@ export namespace ConfigKey { /** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */ export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null); + /** Enable extended (1 hour) prompt cache TTL on tools and system blocks for the Anthropic Messages API. Only applied to 1M context Claude variants. */ + export const AnthropicExtendedCacheTtl = defineSetting('chat.anthropic.promptCaching.extendedTtl', ConfigType.ExperimentBased, false); + export const InlineEditsXtabProviderModelConfiguration = (() => { const oldKey = 'chat.advanced.inlineEdits.xtabProvider.modelConfiguration'; const newKey = 'chat.inlineEdits.xtabProvider.modelConfiguration'; diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index bd4296e32f594..554c1a7b6d798 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -16,10 +16,10 @@ import { ChatFetchResponseType, ChatLocation, ChatResponse } from '../../chat/co import { getTextPart } from '../../chat/common/globalStringUtils'; import { CHAT_MODEL, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { ILogService } from '../../log/common/logService'; -import { isAnthropicContextEditingEnabled } from '../../networking/common/anthropic'; +import { isAnthropicContextEditingEnabled, isExtendedCacheTtlEnabled } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, ICopilotToolCall, OptionalChatRequestParams } from '../../networking/common/fetch'; import { IFetcherService, Response } from '../../networking/common/fetcherService'; -import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions } from '../../networking/common/networking'; +import { createCapiRequestBody, IChatEndpoint, IChatEndpointTokenPricing, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions, InteractionTypeOverride } from '../../networking/common/networking'; import { CAPIChatMessage, ChatCompletion, FinishedCompletionReason, RawMessageConversionCallback } from '../../networking/common/openai'; import { prepareChatCompletionForReturn } from '../../networking/node/chatStream'; import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManager'; @@ -210,7 +210,7 @@ export class ChatEndpoint implements IChatEndpoint { // so getExtraHeaders can gate the interleaved-thinking header on whether thinking is actually enabled for the // request, rather than using the location check. Once plumbed, replace isAllowedConversationAgentModel with // an enableThinking check for the thinking header (keep location gate for context management / tool search). - public getExtraHeaders(_location?: ChatLocation): Record { + public getExtraHeaders(location?: ChatLocation, interactionTypeOverride?: InteractionTypeOverride): Record { const headers: Record = { ...this.modelMetadata.requestHeaders }; if (this.useMessagesApi) { @@ -220,12 +220,12 @@ export class ChatEndpoint implements IChatEndpoint { } } - Object.assign(headers, this.getAnthropicBetaHeader()); + Object.assign(headers, this.getAnthropicBetaHeader(location, interactionTypeOverride)); return headers; } - protected getAnthropicBetaHeader(): Record { + protected getAnthropicBetaHeader(location?: ChatLocation, interactionTypeOverride?: InteractionTypeOverride): Record { if (!this.useMessagesApi) { return {}; } @@ -239,6 +239,12 @@ export class ChatEndpoint implements IChatEndpoint { if (isAnthropicContextEditingEnabled(this, this._configurationService, this._expService)) { betas.push('context-management-2025-06-27'); } + // Mirror the body-side gate from messagesApi.ts so the beta header is never sent for + // requests that won't actually emit `ttl: '1h'` (subagents, non-Agent locations, etc.). + const isSubagent = interactionTypeOverride === 'conversation-subagent'; + if (isExtendedCacheTtlEnabled(this, this._configurationService, this._expService, location, isSubagent)) { + betas.push('extended-cache-ttl-2025-04-11'); + } return betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}; } diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index 05f66c9155d9e..e49fa1ba68a7a 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -13,7 +13,7 @@ import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platfo import { ChatLocation } from '../../chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { ILogService } from '../../log/common/logService'; -import { AnthropicMessagesTool, ContextManagementResponse, CUSTOM_TOOL_SEARCH_NAME, getContextManagementFromConfig, isAnthropicContextEditingEnabled } from '../../networking/common/anthropic'; +import { AnthropicMessagesTool, ContextManagementResponse, CUSTOM_TOOL_SEARCH_NAME, getContextManagementFromConfig, isAnthropicContextEditingEnabled, isExtendedCacheTtlEnabled } from '../../networking/common/anthropic'; import { FinishedCallback, getRequestId, IIPCodeCitation, IResponseDelta } from '../../networking/common/fetch'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody } from '../../networking/common/networking'; import { ChatCompletion, FinishedCompletionReason, rawMessageToCAPI } from '../../networking/common/openai'; @@ -179,9 +179,19 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I const validToolNames = finalTools.length > 0 ? new Set(finalTools.map(t => t.name)) : undefined; const messagesResult = rawMessagesToMessagesAPI(options.messages, toolSearchEnabled ? validToolNames : undefined); + // Subagent requests are out of scope for the extended cache TTL — their + // context is short-lived. The three subagent call sites (search loop, + // execution loop, Task-tool-spawned agent) all set + // `interactionTypeOverride: 'conversation-subagent'`, which is also the + // source of truth for the `X-Interaction-Type` wire header. The rolling + // breakpoints on messages always use the default 5m TTL. + const isSubagent = options.interactionTypeOverride === 'conversation-subagent'; + const useExtendedCacheTtl = isExtendedCacheTtlEnabled(endpoint, configurationService, experimentationService, options.location, isSubagent); + const cacheTtl = useExtendedCacheTtl ? '1h' : undefined; + clearAllCacheControl(messagesResult); addMessagesApiCacheControl(messagesResult); - addToolsAndSystemCacheControl(finalTools, messagesResult); + addToolsAndSystemCacheControl(finalTools, messagesResult, cacheTtl); // Guard: The Anthropic Messages API requires the conversation to end with a user message. // A trailing assistant message is treated as a prefill request, which is not supported @@ -491,21 +501,33 @@ export function clearAllCacheControl( } } -/** Marks the last non-deferred tool and the last system block for caching. */ +/** + * Marks the last non-deferred tool and the last system block for caching. + * + * When {@link cacheTtl} is `'1h'`, the breakpoints request the extended cache + * TTL. Sending this requires the `extended-cache-ttl-2025-04-11` Anthropic + * beta header — see {@link IChatEndpoint.getExtraHeaders}. When omitted, the + * default 5 minute TTL is used. + */ export function addToolsAndSystemCacheControl( tools: AnthropicMessagesTool[], messagesResult: { messages: MessageParam[]; system?: TextBlockParam[] }, + cacheTtl?: '5m' | '1h', ): void { + const cacheControl = cacheTtl + ? { type: 'ephemeral' as const, ttl: cacheTtl } + : { type: 'ephemeral' as const }; + for (let i = tools.length - 1; i >= 0; i--) { if (!tools[i].defer_loading) { - tools[i].cache_control = { type: 'ephemeral' }; + tools[i].cache_control = cacheControl; break; } } const lastSystemBlock = messagesResult.system?.at(-1); if (lastSystemBlock && !lastSystemBlock.cache_control) { - lastSystemBlock.cache_control = { type: 'ephemeral' }; + lastSystemBlock.cache_control = cacheControl; } } diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index 1c04d9f8d8442..00bfc7a092622 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -9,7 +9,7 @@ import { beforeEach, describe, expect, suite, test } from 'vitest'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../chat/common/commonTypes'; -import { AnthropicMessagesTool, CUSTOM_TOOL_SEARCH_NAME } from '../../../networking/common/anthropic'; +import { AnthropicMessagesTool, CUSTOM_TOOL_SEARCH_NAME, isExtendedCacheTtlEnabled, modelSupportsExtendedCacheTtl } from '../../../networking/common/anthropic'; import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking'; import { IToolDeferralService } from '../../../networking/common/toolDeferralService'; import { createPlatformServices } from '../../../test/node/services'; @@ -18,6 +18,9 @@ import { HeadersImpl, Response } from '../../../networking/common/fetcherService import { TelemetryData } from '../../../telemetry/common/telemetryData'; import { TestLogService } from '../../../testing/common/testLogService'; import { NullTelemetryService } from '../../../telemetry/common/nullTelemetryService'; +import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; +import { IExperimentationService } from '../../../telemetry/common/nullExperimentationService'; +import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; function assertContentArray(content: MessageParam['content']): ContentBlockParam[] { expect(Array.isArray(content)).toBe(true); @@ -496,6 +499,150 @@ suite('addToolsAndSystemCacheControl', function () { expect(system[0].cache_control).toEqual({ type: 'ephemeral' }); }); + + test('applies extended 1h ttl to tools and system when cacheTtl is "1h"', function () { + const tools = [makeTool('read_file'), makeTool('edit_file'), makeTool('deferred_a', true)]; + const system: TextBlockParam[] = [makeSystemBlock('System A'), makeSystemBlock('System B')]; + const messagesResult = { messages: makeMessages(), system }; + + addToolsAndSystemCacheControl(tools, messagesResult, '1h'); + + expect(tools[0].cache_control).toBeUndefined(); + expect(tools[1].cache_control).toEqual({ type: 'ephemeral', ttl: '1h' }); + expect(tools[2].cache_control).toBeUndefined(); + expect(system[0].cache_control).toBeUndefined(); + expect(system[1].cache_control).toEqual({ type: 'ephemeral', ttl: '1h' }); + }); + + test('omits ttl when cacheTtl is undefined (default 5m)', function () { + const tools = [makeTool('read_file')]; + const system: TextBlockParam[] = [makeSystemBlock('System')]; + const messagesResult = { messages: makeMessages(), system }; + + addToolsAndSystemCacheControl(tools, messagesResult, undefined); + + expect(tools[0].cache_control).toEqual({ type: 'ephemeral' }); + expect(system[0].cache_control).toEqual({ type: 'ephemeral' }); + }); +}); + +suite('modelSupportsExtendedCacheTtl', function () { + + test('matches 1M context Opus variants and rejects everything else', function () { + expect({ + 'claude-opus-4.6-1m': modelSupportsExtendedCacheTtl('claude-opus-4.6-1m'), + 'claude-opus-4-6-1m': modelSupportsExtendedCacheTtl('claude-opus-4-6-1m'), + 'claude-opus-4.7-1m-internal': modelSupportsExtendedCacheTtl('claude-opus-4.7-1m-internal'), + 'claude-opus-4-7-1m-internal': modelSupportsExtendedCacheTtl('claude-opus-4-7-1m-internal'), + 'CLAUDE-OPUS-4.6-1M': modelSupportsExtendedCacheTtl('CLAUDE-OPUS-4.6-1M'), + 'claude-opus-4.6': modelSupportsExtendedCacheTtl('claude-opus-4.6'), + 'claude-opus-4.7': modelSupportsExtendedCacheTtl('claude-opus-4.7'), + 'claude-opus-4.5': modelSupportsExtendedCacheTtl('claude-opus-4.5'), + 'claude-sonnet-4.5': modelSupportsExtendedCacheTtl('claude-sonnet-4.5'), + 'claude-haiku-4-5': modelSupportsExtendedCacheTtl('claude-haiku-4-5'), + 'gpt-5': modelSupportsExtendedCacheTtl('gpt-5'), + }).toEqual({ + 'claude-opus-4.6-1m': true, + 'claude-opus-4-6-1m': true, + 'claude-opus-4.7-1m-internal': true, + 'claude-opus-4-7-1m-internal': true, + 'CLAUDE-OPUS-4.6-1M': true, + 'claude-opus-4.6': false, + 'claude-opus-4.7': false, + 'claude-opus-4.5': false, + 'claude-sonnet-4.5': false, + 'claude-haiku-4-5': false, + 'gpt-5': false, + }); + }); +}); + +suite('isExtendedCacheTtlEnabled', function () { + + const ELIGIBLE_MODEL = 'claude-opus-4-7-1m'; + + let disposables: DisposableStore; + let configurationService: InMemoryConfigurationService; + let experimentationService: IExperimentationService; + + beforeEach(() => { + disposables = new DisposableStore(); + const services = disposables.add(createPlatformServices(disposables)); + const accessor = services.createTestingAccessor(); + // All callers of `isExtendedCacheTtlEnabled` resolve `IConfigurationService` through DI to + // an `InMemoryConfigurationService` instance (see `createPlatformServices`). Re-narrowing it + // here lets the tests use `setConfig` to flip the experiment-based gate without going + // through experimentation infrastructure. + configurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService; + experimentationService = accessor.get(IExperimentationService); + }); + + function enableConfig(): void { + configurationService.setConfig(ConfigKey.Advanced.AnthropicExtendedCacheTtl, true); + } + + test('returns false when the config is disabled, even for eligible models', function () { + expect(isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Agent, false)).toBe(false); + }); + + test('returns true for eligible model + config + Agent location + non-subagent', function () { + enableConfig(); + expect(isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Agent, false)).toBe(true); + }); + + test('returns false when location is undefined', function () { + // The gate requires an explicit `ChatLocation.Agent`. Callers that route through + // subclass overrides which drop the `location` argument (e.g. `super.getExtraHeaders()` + // without arguments — see `OpenRouterEndpoint`, `AzureOpenAIEndpoint`, etc.) are + // correctly excluded so the beta header is never sent for non-Agent paths. + enableConfig(); + expect(isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, undefined, false)).toBe(false); + }); + + test('returns false when isSubagent is true', function () { + enableConfig(); + expect(isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Agent, true)).toBe(false); + }); + + test('returns false for non-1M Claude variants even when all other gates pass', function () { + enableConfig(); + expect({ + 'claude-opus-4-6': isExtendedCacheTtlEnabled('claude-opus-4-6', configurationService, experimentationService, ChatLocation.Agent, false), + 'claude-opus-4.7': isExtendedCacheTtlEnabled('claude-opus-4.7', configurationService, experimentationService, ChatLocation.Agent, false), + 'claude-sonnet-4-5': isExtendedCacheTtlEnabled('claude-sonnet-4-5', configurationService, experimentationService, ChatLocation.Agent, false), + 'gpt-5': isExtendedCacheTtlEnabled('gpt-5', configurationService, experimentationService, ChatLocation.Agent, false), + }).toEqual({ + 'claude-opus-4-6': false, + 'claude-opus-4.7': false, + 'claude-sonnet-4-5': false, + 'gpt-5': false, + }); + }); + + test('returns false for non-Agent chat locations', function () { + // Inline chat, terminal chat, notebook chat, and the Claude CLI proxy passthrough are all + // out of scope for extended cache TTL — only the main agent conversation qualifies. + enableConfig(); + expect({ + Panel: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Panel, false), + Editor: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Editor, false), + Terminal: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Terminal, false), + Notebook: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Notebook, false), + EditingSession: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.EditingSession, false), + Other: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.Other, false), + MessagesProxy: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.MessagesProxy, false), + ResponsesProxy: isExtendedCacheTtlEnabled(ELIGIBLE_MODEL, configurationService, experimentationService, ChatLocation.ResponsesProxy, false), + }).toEqual({ + Panel: false, + Editor: false, + Terminal: false, + Notebook: false, + EditingSession: false, + Other: false, + MessagesProxy: false, + ResponsesProxy: false, + }); + }); }); suite('buildToolInputSchema', function () { diff --git a/extensions/copilot/src/platform/networking/common/anthropic.ts b/extensions/copilot/src/platform/networking/common/anthropic.ts index cb0e72c5674b5..38719786d047c 100644 --- a/extensions/copilot/src/platform/networking/common/anthropic.ts +++ b/extensions/copilot/src/platform/networking/common/anthropic.ts @@ -6,6 +6,7 @@ import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { modelSupportsContextEditing } from '../../endpoint/common/chatModelCapabilities'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; +import { ChatLocation } from '../../chat/common/commonTypes'; import { IChatEndpoint } from './networking'; /** @@ -26,7 +27,7 @@ export interface AnthropicMessagesTool { required?: string[]; }; defer_loading?: boolean; - cache_control?: { type: 'ephemeral' }; + cache_control?: { type: 'ephemeral'; ttl?: '5m' | '1h' }; } /** Name for the custom client-side embeddings-based tool search tool. Must not use copilot_/vscode_ prefix — those are reserved for static package.json declarations and will be rejected by vscode.lm.registerToolDefinition. */ @@ -139,6 +140,61 @@ export function isAnthropicContextEditingEnabled( return mode !== 'off'; } +/** + * The extended (1 hour) prompt cache TTL is only meaningful for the 1M context + * Claude variants. Other models keep the default 5 minute TTL even when the + * experimental setting is enabled. + * + * Currently: + * - Claude Opus 4.6 1M (`claude-opus-4.6-1m`) + * - Claude Opus 4.7 1M (`claude-opus-4.7-1m-internal` and similar variants) + */ +export function modelSupportsExtendedCacheTtl(modelId: string): boolean { + const normalized = modelId.toLowerCase().replace(/\./g, '-'); + return normalized.startsWith('claude-opus-4-6-1m') || + normalized.startsWith('claude-opus-4-7-1m'); +} + +/** + * Returns true when the Anthropic Messages API request should use the extended + * (1 hour) prompt cache TTL on its tools and system breakpoints. Gated on the + * model (only the 1M context variants), the experiment-based setting, the chat + * location (must be exactly {@link ChatLocation.Agent}), and the subagent flag. + * + * {@link ChatLocation.MessagesProxy} is intentionally out of scope — extended + * TTL is only meant for the main agent conversation, not for the Claude CLI + * passthrough. + * + * @param location Must be {@link ChatLocation.Agent}; any other value (including + * `undefined`) fails the gate. Callers that route through subclass overrides + * which drop the `location` argument (e.g. `super.getExtraHeaders()`) are + * correctly excluded by this strict check. + * @param isSubagent Subagent requests are short-lived and would not benefit + * from the 1h TTL. + */ +export function isExtendedCacheTtlEnabled( + endpoint: IChatEndpoint | string, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, + location: ChatLocation | undefined, + isSubagent: boolean | undefined, +): boolean { + const modelId = typeof endpoint === 'string' ? endpoint : endpoint.model; + if (!modelSupportsExtendedCacheTtl(modelId)) { + return false; + } + if (!configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AnthropicExtendedCacheTtl, experimentationService)) { + return false; + } + if (location !== ChatLocation.Agent) { + return false; + } + if (isSubagent) { + return false; + } + return true; +} + export type ContextEditingMode = 'off' | 'clear-thinking' | 'clear-tooluse' | 'clear-both'; /** diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index cae549365c4af..ac28395c1b1c5 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -132,7 +132,7 @@ export interface IEndpointFetchOptions { export interface IEndpoint { readonly urlOrRequestMetadata: string | RequestMetadata; - getExtraHeaders?(location?: ChatLocation): Record; + getExtraHeaders?(location?: ChatLocation, interactionTypeOverride?: InteractionTypeOverride): Record; getEndpointFetchOptions?(): IEndpointFetchOptions; interceptBody?(body: IEndpointBody | undefined): void; acquireTokenizer(): ITokenizer; @@ -440,7 +440,7 @@ function networkRequest( 'OpenAI-Intent': intent, // Tells CAPI who flighted this request. Helps find buggy features 'X-GitHub-Api-Version': '2026-01-09', ...additionalHeaders, - ...(endpoint.getExtraHeaders ? endpoint.getExtraHeaders(location) : {}), + ...(endpoint.getExtraHeaders ? endpoint.getExtraHeaders(location, options.interactionTypeOverride) : {}), }; headers['X-Interaction-Type'] = agentInteractionType; headers['X-Agent-Task-Id'] = requestId; diff --git a/package-lock.json b/package-lock.json index dc35175770d6e..8d2dea8215a6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260429", "@vscode/component-explorer": "^0.2.1-27", "@vscode/component-explorer-cli": "^0.2.1-27", - "@vscode/gulp-electron": "1.41.2", + "@vscode/gulp-electron": "1.41.3", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", @@ -3632,9 +3632,9 @@ } }, "node_modules/@vscode/gulp-electron": { - "version": "1.41.2", - "resolved": "git+ssh://git@github.com/microsoft/vscode-gulp-electron.git#d44aa01b0ac0e0d71b83f1f9d68fea8aff79b7f1", - "integrity": "sha512-1g/8LIKcL6J8q2Rljj3cMqRVlXRntSiEjP6e5nGcbjSLuxRlOZFyCMTkf7ag3q5MxVy/iDC2ihWgBt0O2BwJMQ==", + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.41.3.tgz", + "integrity": "sha512-M+f3LqnZKyIf3k5fxAeKHtz5/0V9PALJqneVh7vDZ32wdbokhFmfVqzP8Z+alBjZViuL80cLe65znjELnsxUBw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2d5eba74cd023..c9883e099b0c4 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260429", "@vscode/component-explorer": "^0.2.1-27", "@vscode/component-explorer-cli": "^0.2.1-27", - "@vscode/gulp-electron": "1.41.2", + "@vscode/gulp-electron": "1.41.3", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", diff --git a/src/vs/platform/hover/browser/hoverService.ts b/src/vs/platform/hover/browser/hoverService.ts index 6e0f7b019e7a2..b9931600efdbf 100644 --- a/src/vs/platform/hover/browser/hoverService.ts +++ b/src/vs/platform/hover/browser/hoverService.ts @@ -140,8 +140,8 @@ export class HoverService extends Disposable implements IHoverService { } if (!this._currentDelayedHover || this._currentDelayedHoverWasShown) { - // Current hover is locked, reject - if (this._currentHover?.isLocked) { + // Current hover is locked, reject — unless this is a nesting scenario + if (this._currentHover?.isLocked && this._getContainingHoverIndex(options.target) < 0) { return undefined; } diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index e40d3ffa07cb8..22051eb255823 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -617,7 +617,6 @@ class TitleBarAccountWidget extends BaseActionViewItem { disableProviderOptions: true, disableCompletionsSnooze: true, disableQuickSettingsCollapsible: true, - disableContributedSectionsCollapsible: true, ...extraOptions, }); diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index c8d5ddd592820..19d92764c3821 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -277,9 +277,9 @@ } /* Flatten contributed sub-sections (e.g. "Codebase Semantic Index") into - the parent Subscription section. The dashboard renders these non-collapsibly - (via `disableContributedSectionsCollapsible`); these rules just align the - header typography with the panel's quota-title style above it. */ + the parent Subscription section. The dashboard renders these non-collapsibly; + these rules just align the header typography with the panel's quota-title + style above it. */ .agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-header.non-collapsible { margin-top: 4px; padding: 0 28px 0 0; @@ -301,20 +301,15 @@ line-height: 15px; } -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-content > .collapsible-inner { - gap: 6px; - margin-top: 0; - margin-bottom: 0; -} - -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .section-description, -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .section-detail { +.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-header.non-collapsible .contributed-detail { font-size: 11px; line-height: 15px; } -.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .section-detail { - padding-bottom: 4px; +.agent-sessions-workbench .sessions-account-titlebar-panel-section .chat-status-bar-entry-tooltip .collapsible-content > .collapsible-inner { + gap: 6px; + margin-top: 0; + margin-bottom: 0; } .agent-sessions-workbench .sessions-account-titlebar-panel-section-title { diff --git a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts index 80143dd55593c..c4799e8bb0cab 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHost/agentHostSessionConfigPicker.ts @@ -530,8 +530,17 @@ class MobileAgentHostSessionConfigPicker extends AgentHostSessionConfigPicker { * then Worktree. Sort the known repo-config properties to that * order; unknown properties fall through to schema-declared order * after the known ones. + * + * On desktop viewports this subclass is also instantiated (see the + * factory in `AgentHostSessionConfigPickersContribution` — it always + * picks the mobile-aware subclass so `_showPicker` can route to the + * bottom sheet on phones), so we must defer to the base ordering + * (Isolation first, Branch second) when not on a phone layout. */ protected override _orderProperties(properties: ReadonlyArray<[string, SessionConfigPropertySchema]>): ReadonlyArray<[string, SessionConfigPropertySchema]> { + if (!isPhoneLayout(this._layoutService)) { + return super._orderProperties(properties); + } const order = new Map([ [SessionConfigKey.Branch, 0], [SessionConfigKey.Isolation, 1], diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 9dbfc27210330..59d9f0b5b38a8 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -16,6 +16,7 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { autorun } from '../../../../base/common/observable.js'; import { ISession, ISessionType } from '../../../services/sessions/common/session.js'; import { Emitter } from '../../../../base/common/event.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; @@ -207,7 +208,14 @@ export class SessionTypePicker extends Disposable { dom.clearNode(this._triggerElement); - if (this._allProviderSessionTypes.length === 0) { + // In web (vscode.dev/agents) the host filter already scopes the + // workbench to a single agent host, so when that host advertises only + // one harness there is nothing to pick — hide the trigger entirely. + // Note: the existing CSS rule on `.session-workspace-picker-with-label` + // uses `:has(+ .sessions-chat-session-type-picker .action-label.hidden)` + // to also hide the "with" connector when the trigger is hidden. + const hideForSingleHarness = isWeb && this._allProviderSessionTypes.length <= 1; + if (this._allProviderSessionTypes.length === 0 || hideForSingleHarness) { this._triggerElement.classList.add('hidden'); return; } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 67b2167aa3dde..2aa032f313963 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -129,7 +129,7 @@ export interface ICopilotChatSession { readonly isolationMode: IObservable; setIsolationMode(mode: IsolationMode): void; - setModelId(modelId: string): void; + setModelId(modelId: string | undefined): void; setMode(chatMode: IChatMode | undefined): void; setOption?(optionId: string, value: IChatSessionProviderOptionItem | string): void; @@ -1182,6 +1182,7 @@ class AgentSessionAdapter implements ICopilotChatSession { private readonly _checkpoints: ReturnType>; readonly checkpoints: IObservable; + private readonly _modelId: ReturnType>; readonly modelId: IObservable; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; readonly loading: IObservable; @@ -1255,7 +1256,8 @@ class AgentSessionAdapter implements ICopilotChatSession { this._checkpoints = observableValueOpts({ owner: this, equalsFn: structuralEquals }, this._extractCheckpoints(session)); this.checkpoints = this._checkpoints; - this.modelId = observableValue(this, undefined); + this._modelId = observableValue(this, undefined); + this.modelId = this._modelId; this.mode = observableValue(this, undefined); this.loading = observableValue(this, false); @@ -1278,8 +1280,8 @@ class AgentSessionAdapter implements ICopilotChatSession { setIsolationMode(mode: IsolationMode): void { throw new Error('Method not implemented.'); } - setModelId(modelId: string): void { - throw new Error('Method not implemented.'); + setModelId(modelId: string | undefined): void { + this._modelId.set(modelId, undefined); } setMode(chatMode: IChatMode | undefined): void { throw new Error('Method not implemented.'); @@ -1802,7 +1804,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions setModel(sessionId: string, modelId: string): void { if (this._currentNewSession?.id === sessionId) { this._currentNewSession.setModelId(modelId); + return; } + + this._ensureSessionCache(); + this._findChatSession(sessionId)?.setModelId(modelId); } // -- Session Actions -- @@ -2528,6 +2534,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); + session.setModelId(chat.modelId.get()); session.setIsolationMode('workspace'); session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1)); const level = this.configurationService.getValue(ChatConfiguration.DefaultPermissionLevel); diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index b3dab1e32bf0a..67e4a9b90063f 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -520,6 +520,24 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); }); + test('setModel applies to existing sessions and their new chats', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model); + const session = provider.getSessions()[0]; + provider.setModel(session.sessionId, 'copilot/gpt-4o'); + + assert.strictEqual(session.modelId.get(), 'copilot/gpt-4o'); + + const chat = provider.addChat(session.sessionId); + try { + assert.strictEqual(chat.modelId.get(), 'copilot/gpt-4o'); + } finally { + await provider.deleteChat(session.sessionId, chat.resource); + } + }); + test('sendAndCreateChat throws for unknown session', async () => { const provider = createProvider(disposables, model); await assert.rejects( diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts index a39769aaa99f4..c1057a3dc6d7e 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/modelPickerDelegate.test.ts @@ -81,6 +81,7 @@ function stubServices( instantiationService.stub(IChatEntitlementService, { quotas: {}, onDidChangeQuotaRemaining: Event.None, + onDidChangeUsageBasedBilling: Event.None, onDidChangeEntitlement: Event.None, } as Partial); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 9dc6d450ca0ab..4435f575672a9 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -506,6 +506,12 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid // -- Session-type sync --------------------------------------------------- protected _formatSessionTypeLabel(agentLabel: string): string { + // In web (vscode.dev/agents) the workbench is already scoped to a + // single host via the host picker, so there's no need to disambiguate + // the session-type label with the host name. + if (this.isWebPlatform) { + return agentLabel; + } return `${agentLabel} [${this.label}]`; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 74a38c8bbd066..8fc8560e3e424 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -294,7 +294,7 @@ suite('RemoteAgentHostSessionsProvider', () => { // ---- Provider identity ------- test('derives id and label from config, and session types from rootState agents', () => { - const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' }); + const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host', isWebPlatform: false }); assert.strictEqual(provider.id, 'agenthost-10.0.0.1__8080'); assert.strictEqual(provider.label, 'My Host'); @@ -304,7 +304,7 @@ suite('RemoteAgentHostSessionsProvider', () => { }); test('session types update when the host advertises additional agents', () => { - const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' }); + const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host', isWebPlatform: false }); assert.deepStrictEqual(provider.sessionTypes.map(t => t.id), [ CopilotCLISessionType.id, ]); @@ -324,6 +324,20 @@ suite('RemoteAgentHostSessionsProvider', () => { ]); }); + test('session-type labels omit host suffix on web', () => { + const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host', isWebPlatform: true }); + + connection.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'openai', displayName: 'OpenAI', description: '', models: [] } as AgentInfo, + ]); + + assert.deepStrictEqual(provider.sessionTypes.map(t => ({ id: t.id, label: t.label })), [ + { id: CopilotCLISessionType.id, label: 'Copilot' }, + { id: 'openai', label: 'OpenAI' }, + ]); + }); + test('falls back to address-based label when no name given', () => { const provider = createProvider(disposables, connection, { connectionName: undefined, address: 'myhost:9999' }); diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 095afedb415c6..60e366768a6bf 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -86,6 +86,7 @@ class MockChatEntitlementService implements IChatEntitlementService { readonly onDidChangeEntitlement = Event.None; readonly onDidChangeQuotaExceeded = Event.None; readonly onDidChangeQuotaRemaining = Event.None; + readonly onDidChangeUsageBasedBilling = Event.None; readonly onDidChangeSentiment = Event.None; readonly onDidChangeAnonymous = Event.None; diff --git a/src/vs/workbench/api/browser/mainThreadChatStatus.ts b/src/vs/workbench/api/browser/mainThreadChatStatus.ts index a551122aadbda..1f6ad0f73c6ef 100644 --- a/src/vs/workbench/api/browser/mainThreadChatStatus.ts +++ b/src/vs/workbench/api/browser/mainThreadChatStatus.ts @@ -24,6 +24,7 @@ export class MainThreadChatStatus extends Disposable implements MainThreadChatSt label: entry.title, description: entry.description, detail: entry.detail, + tooltip: entry.tooltip, }); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0d26fe9292661..05f5ff408eb2c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3711,6 +3711,7 @@ export type ChatStatusItemDto = { title: string | { label: string; link: string; helpText?: string }; description: string; detail: string | undefined; + tooltip: string | undefined; }; export interface MainThreadChatStatusShape { diff --git a/src/vs/workbench/api/common/extHostChatStatus.ts b/src/vs/workbench/api/common/extHostChatStatus.ts index bcd5b0f23f8f1..d3558b3c2ebfb 100644 --- a/src/vs/workbench/api/common/extHostChatStatus.ts +++ b/src/vs/workbench/api/common/extHostChatStatus.ts @@ -30,6 +30,7 @@ export class ExtHostChatStatus { title: '', description: '', detail: '', + tooltip: undefined, }; let disposed = false; @@ -73,6 +74,14 @@ export class ExtHostChatStatus { syncState(); }, + get tooltip(): string | undefined { + return state.tooltip; + }, + set tooltip(value: string | undefined) { + state.tooltip = value; + syncState(); + }, + show: () => { visible = true; syncState(); diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 3b5f1dc6eed00..3204fa581e677 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -1020,6 +1020,7 @@ export class ChatModelsWidget extends Disposable { this.updateAddModelsButton(); this.createTable(); })); + this._register(this.chatEntitlementService.onDidChangeUsageBasedBilling(() => this.createTable())); this._register(this.languageModelsService.onDidChangeLanguageModelVendors(() => this.updateAddModelsButton())); this._register(this.contextKeyService.onDidChangeContext(e => { if (e.affectsSome(new Set(['github.copilot.clientByokEnabled']))) { @@ -1068,7 +1069,7 @@ export class ChatModelsWidget extends Disposable { } ]; - const hasAnyCostFields = this.viewModel.viewModelEntries.some(e => !isLanguageModelProviderEntry(e) && !isLanguageModelGroupEntry(e) && !isStatusEntry(e) && (e.model.metadata.inputCost !== undefined || e.model.metadata.outputCost !== undefined || e.model.metadata.cacheCost !== undefined)); + const isUBB = this.chatEntitlementService.quotas.usageBasedBilling === true; columns.push( { label: localize('tokenLimits', 'Context Size'), @@ -1087,10 +1088,10 @@ export class ChatModelsWidget extends Disposable { project(row: IViewModelEntry): IViewModelEntry { return row; } }, { - label: hasAnyCostFields ? localize('cost', 'Cost (Credits per 1M Tokens)') : localize('pricing', 'Pricing'), + label: isUBB ? localize('cost', 'Cost (Credits per 1M Tokens)') : localize('pricing', 'Pricing'), tooltip: '', - weight: hasAnyCostFields ? 0.24 : 0.15, - minimumWidth: hasAnyCostFields ? 240 : 200, + weight: isUBB ? 0.24 : 0.15, + minimumWidth: isUBB ? 240 : 200, templateId: CombinedCostColumnRenderer.TEMPLATE_ID, project(row: IViewModelEntry): IViewModelEntry { return row; } }, diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index e15fcb27b3c03..017846250ba8a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -21,7 +21,6 @@ import { language } from '../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; -import { stripIcons } from '../../../../../base/common/iconLabels.js'; import { IInlineCompletionsService } from '../../../../../editor/browser/services/inlineCompletionsService.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextResourceConfigurationService } from '../../../../../editor/common/services/textResourceConfiguration.js'; @@ -76,8 +75,7 @@ export interface IChatStatusDashboardOptions { disableCompletionsSnooze?: boolean; /** When true, the Quick Settings region is rendered always-expanded without a collapsible header. */ disableQuickSettingsCollapsible?: boolean; - /** When true, contributed sections are rendered always-expanded without a collapsible header button. */ - disableContributedSectionsCollapsible?: boolean; + /** * When provided, the title header (plan name + manage / CTA actions) is * rendered into this caller-owned container instead of inline at the top @@ -102,7 +100,6 @@ export interface IChatStatusDashboardOptions { export class ChatStatusDashboard extends DomWidget { private static readonly QUICK_SETTINGS_COLLAPSED_KEY = 'chatStatusDashboard.quickSettingsCollapsed'; - private static readonly CONTRIBUTED_COLLAPSED_KEY_PREFIX = 'chatStatusDashboard.contributedCollapsed.'; readonly element = $('div.chat-status-bar-entry-tooltip'); @@ -421,83 +418,69 @@ export class ChatStatusDashboard extends DomWidget { } private renderContributedSections(contributedEntries: ChatStatusEntry[]): void { - const nonCollapsible = !!this.options?.disableContributedSectionsCollapsible; for (const item of contributedEntries) { - const storageKey = ChatStatusDashboard.CONTRIBUTED_COLLAPSED_KEY_PREFIX + item.id; - const collapsed = !nonCollapsible && this.storageService.getBoolean(storageKey, StorageScope.PROFILE, true); - const headerLabel = typeof item.label === 'string' ? item.label : item.label.label; - const headerLink = typeof item.label === 'string' ? undefined : item.label.link; - const linkDescription = typeof item.label === 'string' ? undefined : item.label.helpText; - - const disclosureHeader = this.element.appendChild( - nonCollapsible - ? $('div.collapsible-header.non-collapsible') - : $('button.collapsible-header') - ); - let chevron: HTMLElement | undefined; - disclosureHeader.appendChild($('span.collapsible-label', undefined, headerLabel)); - - if (!nonCollapsible) { - disclosureHeader.setAttribute('aria-expanded', String(!collapsed)); - chevron = disclosureHeader.appendChild($('span.collapsible-chevron')); - chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + let headerLink = typeof item.label === 'string' ? undefined : item.label.link; + let linkDescription = typeof item.label === 'string' ? undefined : item.label.helpText; + + // Single non-collapsible header row + const header = this.element.appendChild($('div.collapsible-header.non-collapsible')); + header.appendChild($('span.collapsible-label', undefined, headerLabel)); + + // Info icon (replaces chevron) — shows helpText in a nested hover + if (linkDescription || headerLink) { + const infoIcon = header.appendChild($('span.contributed-info-icon')); + infoIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); + + this._store.add(this.hoverService.setupDelayedHover(infoIcon, () => { + const hoverContent = new MarkdownString('', { isTrusted: true }); + if (linkDescription) { + hoverContent.appendText(linkDescription); + } + if (headerLink) { + if (linkDescription) { + hoverContent.appendText(' '); + } + hoverContent.appendMarkdown(`[${localize('learnMore', "Learn More")}](${headerLink})`); + } + return { content: hoverContent }; + })); } - // Use renderLabelWithIcons for header status (plain text + icons only, no links inside button) - const statusEl = disclosureHeader.appendChild($('span.collapsible-status')); + // Status text (right-aligned via margin-left: auto) + const statusEl = header.appendChild($('span.collapsible-status')); statusEl.append(...renderLabelWithIcons(item.description)); - statusEl.title = stripIcons(item.description).trim(); - - const collapsibleContent = this.element.appendChild($('div.collapsible-content')); - const collapsibleInner = collapsibleContent.appendChild($('div.collapsible-inner')); - if (collapsed) { - collapsibleContent.classList.add('collapsed'); - collapsibleInner.inert = true; - } - if (!nonCollapsible) { - const toggle = () => { - const isCollapsed = collapsibleContent.classList.toggle('collapsed'); - collapsibleInner.inert = isCollapsed; - disclosureHeader.setAttribute('aria-expanded', String(!isCollapsed)); - chevron!.className = 'collapsible-chevron'; - chevron!.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - this.storageService.store(storageKey, isCollapsed, StorageScope.PROFILE, StorageTarget.USER); - }; - - this._store.add(addDisposableListener(disclosureHeader, EventType.CLICK, () => toggle())); + // Show tooltip on hover of the status text + let currentTooltip = item.tooltip; + if (currentTooltip) { + this._store.add(this.hoverService.setupDelayedHover(statusEl, () => ({ + content: currentTooltip ?? '', + }))); } - // Use a single disposable store for all contributed section content + // Detail (action link) rendered inline const sectionDisposables = this._store.add(new MutableDisposable()); const sectionStore = new DisposableStore(); sectionDisposables.value = sectionStore; - // Description with Learn More (use contributed data, not hardcoded text) - let descriptionEl: HTMLElement | undefined; - if (headerLink) { - descriptionEl = collapsibleInner.appendChild($('div.section-description')); - const descText = linkDescription - ? `${linkDescription} [${localize('learnMore', "Learn More")}](${headerLink})` - : `[${localize('learnMore', "Learn More")}](${headerLink})`; - this.renderTextPlus(descriptionEl, descText, sectionStore); - } - - // Detail content (action links like "Build index", etc.) let detailEl: HTMLElement | undefined; if (item.detail) { - detailEl = collapsibleInner.appendChild($('div.section-detail')); + detailEl = header.appendChild($('span.contributed-detail')); this.renderTextPlus(detailEl, item.detail, sectionStore); } // Listen for updates to re-render status and detail this._store.add(this.chatStatusItemService.onDidChange(e => { if (e.entry.id === item.id) { - // Update status in header (plain text + icons only) + // Update status in header statusEl.textContent = ''; statusEl.append(...renderLabelWithIcons(e.entry.description)); - statusEl.title = stripIcons(e.entry.description).trim(); + currentTooltip = e.entry.tooltip; + + // Update mutable hover content references + headerLink = typeof e.entry.label === 'string' ? undefined : e.entry.label.link; + linkDescription = typeof e.entry.label === 'string' ? undefined : e.entry.label.helpText; // Re-render detail content const newStore = new DisposableStore(); @@ -512,31 +495,9 @@ export class ChatStatusDashboard extends DomWidget { detailEl = undefined; } } else if (e.entry.detail) { - detailEl = collapsibleInner.appendChild($('div.section-detail')); + detailEl = header.appendChild($('span.contributed-detail')); this.renderTextPlus(detailEl, e.entry.detail, newStore); } - - // Re-render Learn More link if needed - const updatedLink = typeof e.entry.label === 'string' ? undefined : e.entry.label.link; - const updatedLinkDesc = typeof e.entry.label === 'string' ? undefined : e.entry.label.helpText; - if (descriptionEl) { - if (updatedLink) { - descriptionEl.textContent = ''; - const descText = updatedLinkDesc - ? `${updatedLinkDesc} [${localize('learnMore', "Learn More")}](${updatedLink})` - : `[${localize('learnMore', "Learn More")}](${updatedLink})`; - this.renderTextPlus(descriptionEl, descText, newStore); - } else { - descriptionEl.remove(); - descriptionEl = undefined; - } - } else if (updatedLink) { - descriptionEl = collapsibleInner.insertBefore($('div.section-description'), detailEl ?? null); - const descText = updatedLinkDesc - ? `${updatedLinkDesc} [${localize('learnMore', "Learn More")}](${updatedLink})` - : `[${localize('learnMore', "Learn More")}](${updatedLink})`; - this.renderTextPlus(descriptionEl, descText, newStore); - } } })); } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts index bc340b9c69d9a..54a136fe08a38 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusItemService.ts @@ -30,6 +30,7 @@ export type ChatStatusEntry = { label: string | { label: string; link: string; helpText?: string }; description: string; detail: string | undefined; + tooltip: string | undefined; }; class ChatStatusItemService implements IChatStatusItemService { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index 19d766ac8732b..89be8967eb4a5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -39,6 +39,10 @@ margin-top: 0; } +.chat-status-bar-entry-tooltip .collapsible-header.non-collapsible { + cursor: default; +} + .chat-status-bar-entry-tooltip .collapsible-header:focus { outline: none; } @@ -57,6 +61,16 @@ margin-top: 2px; } +.chat-status-bar-entry-tooltip .contributed-info-icon { + font-size: 12px; + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0.7; + cursor: help; + color: var(--vscode-descriptionForeground); +} + .chat-status-bar-entry-tooltip .collapsible-label { white-space: nowrap; flex-shrink: 0; @@ -89,6 +103,17 @@ color: var(--vscode-descriptionForeground); } +.chat-status-bar-entry-tooltip .contributed-detail { + font-size: 12px; + font-weight: 400; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex-shrink: 1; +} + .chat-status-bar-entry-tooltip .collapsible-content { display: grid; grid-template-rows: 1fr; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index af82cdf409e0e..8ab5fb0ef64a5 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -48,6 +48,7 @@ import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { chatSessionResourceToId, getChatSessionType } from '../../common/model/chatUri.js'; import { HookType } from '../../common/promptSyntax/hookTypes.js'; +import { CopilotChatSettingId, CopilotToolId } from '../../common/tools/copilotToolIds.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; import { IToolResultCompressor } from '../../common/tools/toolResultCompressor.js'; @@ -151,7 +152,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo })); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.ExtensionToolsEnabled) || e.affectsConfiguration(ChatConfiguration.AgentEnabled)) { + if (e.affectsConfiguration(ChatConfiguration.ExtensionToolsEnabled) || e.affectsConfiguration(ChatConfiguration.AgentEnabled) || e.affectsConfiguration(CopilotChatSettingId.Gpt55ReadFileToolEnabled)) { this._onDidChangeToolsScheduler.schedule(); } })); @@ -212,6 +213,18 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo )); } + private isToolEnabledForModel(toolData: IToolData, model: ILanguageModelChatMetadata | undefined): boolean { + if (!toolMatchesModel(toolData, model)) { + return false; + } + + if (toolData.id === CopilotToolId.ReadFile && model?.family.startsWith('gpt-5.5') && this._configurationService.getValue(CopilotChatSettingId.Gpt55ReadFileToolEnabled) === false) { + return false; + } + + return true; + } + /** * Returns if the given tool or toolset is permitted in the current context. * When agent mode is enabled, all tools are permitted (no restriction) @@ -340,7 +353,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const satisfiesWhenClause = !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when); const satisfiesExternalToolCheck = toolData.source.type !== 'extension' || !!extensionToolsEnabled; const satisfiesPermittedCheck = this.isPermitted(toolData); - const satisfiesModelFilter = toolMatchesModel(toolData, model); + const satisfiesModelFilter = this.isToolEnabledForModel(toolData, model); return satisfiesWhenClause && satisfiesExternalToolCheck && satisfiesPermittedCheck && satisfiesModelFilter; }); } @@ -1436,7 +1449,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } } else { - if (model && !toolMatchesModel(tool, model)) { + if (!this.isToolEnabledForModel(tool, model)) { continue; } @@ -1516,7 +1529,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return this.toolSets.read(reader); } - return Iterable.map(this.toolSets.read(reader), ts => new ToolSetForModel(ts, model)); + return Iterable.map(this.toolSets.read(reader), ts => new ToolSetForModel(ts, model, toolData => this.isToolEnabledForModel(toolData, model))); } getToolSet(id: string): ToolSet | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolResultCompressorService.ts b/src/vs/workbench/contrib/chat/browser/tools/toolResultCompressorService.ts index 41c3dd1aba2df..47482cb72da3f 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolResultCompressorService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolResultCompressorService.ts @@ -10,7 +10,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IToolResult, IToolResultTextPart } from '../../common/tools/languageModelToolsService.js'; -import { formatCompressionBanner, IToolResultCompressor, IToolResultFilter, MIN_COMPRESSIBLE_LENGTH } from '../../common/tools/toolResultCompressor.js'; +import { formatCompressionBanner, IToolResultCache, IToolResultCompressor, IToolResultFilter, isProtectedFromCompression, MIN_COMPRESSIBLE_LENGTH } from '../../common/tools/toolResultCompressor.js'; type ToolResultCompressedClassification = { owner: 'meganrogge'; @@ -19,6 +19,7 @@ type ToolResultCompressedClassification = { filters: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Comma-separated filter ids that fired.' }; beforeChars: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total text part length in UTF-16 code units before compression.' }; afterChars: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total text part length in UTF-16 code units after compression.' }; + cacheHit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'True when the compressed result came from a session-memory cache hit (response dedup) rather than from filters.' }; }; type ToolResultCompressedEvent = { @@ -26,12 +27,14 @@ type ToolResultCompressedEvent = { filters: string; beforeChars: number; afterChars: number; + cacheHit: boolean; }; export class ToolResultCompressorService extends Disposable implements IToolResultCompressor { declare readonly _serviceBrand: undefined; private readonly _filters = new Map(); + private readonly _caches = new Map(); constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -52,18 +55,71 @@ export class ToolResultCompressorService extends Disposable implements IToolResu } } + registerCache(cache: IToolResultCache): void { + for (const id of cache.toolIds) { + let bucket = this._caches.get(id); + if (!bucket) { + bucket = []; + this._caches.set(id, bucket); + } + bucket.push(cache); + } + } + maybeCompress(toolId: string, input: unknown, result: IToolResult): IToolResult | undefined { if (!this._configurationService.getValue(ChatConfiguration.CompressOutputEnabled)) { return undefined; } - const filters = this._filters.get(toolId); - if (!filters || filters.length === 0) { - return undefined; + // Caches run independently of filters. Even if no filters match, a + // cache hit can replace the output with a one-liner. + const caches = this._caches.get(toolId); + if (caches && caches.length > 0) { + for (const c of caches) { + try { c.observe(toolId, input); } catch (err) { + this._logService.warn(`[ToolResultCompressor] cache ${c.id} threw in observe on tool ${toolId}: ${getErrorMessage(err)}`, err); + } + } + for (const c of caches) { + let hit; + try { hit = c.lookup(toolId, input); } catch (err) { + this._logService.warn(`[ToolResultCompressor] cache ${c.id} threw in lookup on tool ${toolId}: ${getErrorMessage(err)}`, err); + continue; + } + if (hit) { + const totalBefore = result.content.reduce((acc, p) => acc + (p.kind === 'text' ? p.value.length : 0), 0); + // Guard: don't replace small outputs or structured data. + if (totalBefore < MIN_COMPRESSIBLE_LENGTH) { + continue; + } + const hasProtectedContent = result.content.some(p => p.kind === 'text' && isProtectedFromCompression(p.value)); + if (hasProtectedContent) { + continue; + } + const cachedResult = this._buildCacheHitResult(result, hit); + const totalAfter = cachedResult.content.reduce((acc, p) => acc + (p.kind === 'text' ? p.value.length : 0), 0); + if (totalAfter >= totalBefore) { + continue; + } + this._sendTelemetry(toolId, [`cache:${c.id}`], totalBefore, totalAfter, true); + return cachedResult; + } + } } - const matchingFilters = filters.filter(f => f.matches(toolId, input)); + const filters = this._filters.get(toolId); + const matchingFilters = filters?.filter(f => { + try { + return f.matches(toolId, input); + } catch (err) { + this._logService.warn(`[ToolResultCompressor] filter ${f.id} threw in matches on tool ${toolId}: ${getErrorMessage(err)}`, err); + return false; + } + }) ?? []; if (matchingFilters.length === 0) { + // No filters matched, but we may still want to record the raw output + // in the caches so the next read-only call can hit. + this._recordInCaches(toolId, input, result, caches); return undefined; } @@ -87,6 +143,13 @@ export class ToolResultCompressorService extends Disposable implements IToolResu totalAfter += original.length; return part; } + // Registry-level "never make it worse" guard: don't pass structured + // data (JSON / TOML / YAML headers) through filters even if they say + // they match. + if (isProtectedFromCompression(original)) { + totalAfter += original.length; + return part; + } let current = original; const partFilterIds: string[] = []; @@ -133,18 +196,59 @@ export class ToolResultCompressorService extends Disposable implements IToolResu }); if (!anyCompressed) { + this._recordInCaches(toolId, input, result, caches); return undefined; } - this._sendTelemetry(toolId, [...usedFilterIds], totalBefore, totalAfter); + this._sendTelemetry(toolId, [...usedFilterIds], totalBefore, totalAfter, false); - return { + const finalResult: IToolResult = { ...result, content: newContent, }; + this._recordInCaches(toolId, input, finalResult, caches); + return finalResult; + } + + private _buildCacheHitResult(original: IToolResult, hit: { text: string; timestamp: number }): IToolResult { + const iso = new Date(hit.timestamp).toISOString(); + const text = `Same output as last run (${iso}). To disable, set ${ChatConfiguration.CompressOutputEnabled} to false.`; + // Preserve the first text part's audience metadata so downstream + // model-routing logic still behaves the same way. + const firstText = original.content.find((p): p is IToolResultTextPart => p.kind === 'text'); + const replacement: IToolResultTextPart = { + kind: 'text', + value: text, + audience: firstText?.audience, + title: firstText?.title, + }; + // Drop other text parts but keep non-text parts (e.g. binary data) so + // downstream consumers don't lose attachments. + const nonText = original.content.filter(p => p.kind !== 'text'); + return { ...original, content: [replacement, ...nonText] }; + } + + private _recordInCaches(toolId: string, input: unknown, result: IToolResult, caches: readonly IToolResultCache[] | undefined): void { + if (!caches || caches.length === 0) { + return; + } + const text = result.content + .filter((p): p is IToolResultTextPart => p.kind === 'text') + .map(p => p.value) + .join('\n'); + if (!text) { + return; + } + for (const c of caches) { + try { + c.record(toolId, input, text); + } catch (err) { + this._logService.warn(`[ToolResultCompressor] cache ${c.id} threw in record on tool ${toolId}: ${getErrorMessage(err)}`, err); + } + } } - private _sendTelemetry(toolId: string, filterIds: string[], beforeChars: number, afterChars: number) { + private _sendTelemetry(toolId: string, filterIds: string[], beforeChars: number, afterChars: number, cacheHit: boolean) { this._telemetryService.publicLog2( 'toolResultCompressed', { @@ -152,6 +256,7 @@ export class ToolResultCompressorService extends Disposable implements IToolResu filters: filterIds.join(','), beforeChars, afterChars, + cacheHit, }, ); } 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 cc97f9ce0d202..26f98c93ada62 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -403,6 +403,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _lastSessionPickerAction: MenuItemAction | undefined; private _lastSessionPickerOptions: IChatInputPickerOptions | undefined; private readonly _waitForPersistedLanguageModel: MutableDisposable = this._register(new MutableDisposable()); + private readonly _waitForSessionHistoryLanguageModel: MutableDisposable = this._register(new MutableDisposable()); private readonly _chatSessionOptionEmitters = this._register(new DisposableMap>()); /** @@ -753,6 +754,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private initSelectedModel() { + // initSelectedModel is scoped to the current storage key/session type. + // Do not let a delayed restore from a previous session type apply later. + this._waitForPersistedLanguageModel.clear(); + const persistedSelection = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, true); @@ -848,6 +853,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._waitForPersistedLanguageModel.clear(); + this._waitForSessionHistoryLanguageModel.clear(); this.setCurrentLanguageModel(model); this.renderAttachedContext(); }, @@ -1334,7 +1340,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * that was last used - providing continuity. */ private preselectModelFromSessionHistory(): void { - const requests = this._widget?.viewModel?.model.getRequests(); + // Session-history preselection is delayed when extension-provided models + // have not arrived yet. Always clear the previous session-history wait + // before any early return so a listener captured for another session cannot + // later apply its model to the active session. + this._waitForSessionHistoryLanguageModel.clear(); + + const sessionModel = this._widget?.viewModel?.model; + const sessionResource = sessionModel?.sessionResource; + const requests = sessionModel?.getRequests(); + if (!sessionResource) { + return; + } if (!requests || requests.length === 0) { return; } @@ -1376,10 +1393,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Models may not be loaded yet - wait for them - this._waitForPersistedLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(() => { + this._waitForSessionHistoryLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(() => { + const currentSessionResource = this._widget?.viewModel?.model.sessionResource; + if (!currentSessionResource || !isEqual(currentSessionResource, sessionResource)) { + this._waitForSessionHistoryLanguageModel.clear(); + return; + } + const found = tryMatch(); if (found) { - this._waitForPersistedLanguageModel.clear(); + this._waitForSessionHistoryLanguageModel.clear(); this.setCurrentLanguageModel(found); } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 0b629b57a05cf..30406a07eba5d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -813,13 +813,8 @@ export class ModelPickerWidget extends Disposable { this._renderLabel(); })); - let lastIsUBB = !!this._entitlementService.quotas.usageBasedBilling; - this._register(this._entitlementService.onDidChangeQuotaRemaining(() => { - const currentIsUBB = !!this._entitlementService.quotas.usageBasedBilling; - if (currentIsUBB !== lastIsUBB) { - lastIsUBB = currentIsUBB; - this._renderLabel(); - } + this._register(this._entitlementService.onDidChangeUsageBasedBilling(() => { + this._renderLabel(); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index d62c7eea5302e..dce0a6bce75e4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -764,12 +764,18 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Update the toolbar context with new sessionId this.updateActions(); - // Mark the old model as read when closing unless explicitly marked unread + // Mark the old model as read when closing unless explicitly marked unread. + // Deferred because setRead fires _onDidChangeSessions which synchronously + // re-renders the sessions list (~250ms), and that doesn't need to block + // the new chat from displaying. if (oldModelResource) { - const oldSession = this.agentSessionsService.model.getSession(oldModelResource); - if (oldSession && !oldSession.isMarkedUnread()) { - oldSession.setRead(true); - } + const capturedOldResource = oldModelResource; + this._register(disposableTimeout(() => { + const oldSession = this.agentSessionsService.model.getSession(capturedOldResource); + if (oldSession && !oldSession.isMarkedUnread()) { + oldSession.setRead(true); + } + }, 0)); } return model; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 4ff46168a6864..89a4d7ec81aac 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -341,6 +341,8 @@ export class ComputeAutomaticInstructions { private async _getCustomizationsIndex(instructionFiles: readonly IInstructionFile[], _existingVariables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, debugInfo: InstructionsCollectionDebugInfo, token: CancellationToken): Promise { const readTool = this._getTool('readFile'); + const runInTerminalTool = this._getTool('runInTerminal'); + const fileReadTool = readTool ?? runInTerminalTool; const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent); const skillTool = this._getTool('skill'); const currentSessionType = this._currentSessionType; @@ -350,7 +352,7 @@ export class ComputeAutomaticInstructions { const filePath = (uri: URI) => getFilePath(uri, remoteOS); const entries: string[] = []; - if (readTool) { + if (fileReadTool) { const searchNestedAgentMd = this._configurationService.getValue(PromptsConfig.USE_NESTED_AGENT_MD); const agentsMdPromise = searchNestedAgentMd ? this._promptsService.listNestedAgentMDs(token) : Promise.resolve([]); @@ -359,7 +361,7 @@ export class ComputeAutomaticInstructions { entries.push('Here is a list of instruction files that contain rules for working with this codebase.'); entries.push('These files are important for understanding the codebase structure, conventions, and best practices.'); entries.push('When an instruction file applies to your task (based on its description or applyTo pattern), follow the rules specified in it.'); - entries.push(`If the file content is not already included in the context, use the ${readTool.variable} tool to read it before proceeding. Use the exact value from the element as-is with the tool; do not add or remove prefixes or otherwise modify it.`); + entries.push(`If the file content is not already included in the context, use the ${fileReadTool.variable} tool to read it before proceeding. Use the exact value from the element as-is with the tool; do not add or remove prefixes or otherwise modify it.`); entries.push('Only load instruction files when they are relevant to the current task. Do not eagerly load all instructions upfront.'); entries.push('When modifying or creating files, check for instructions whose applyTo pattern matches the file path and follow them.'); let hasContent = false; @@ -432,7 +434,7 @@ export class ComputeAutomaticInstructions { // When the skill tool is available, direct the model to use it by name // instead of reading SKILL.md files directly. This keeps file paths out of // the listing and routes through the proper skill loading pipeline. - const skillLoadTool = skillTool ?? readTool; + const skillLoadTool = skillTool ?? fileReadTool; entries.push(''); if (useSkillAdherencePrompt) { // Stronger skill adherence prompt for experimental feature @@ -440,7 +442,7 @@ export class ComputeAutomaticInstructions { if (skillTool) { entries.push(`BLOCKING REQUIREMENT: When a skill applies to the user's request, you MUST invoke it IMMEDIATELY as your first action, BEFORE generating any other response or taking action on the task. Use ${skillTool.variable} with the skill name to load the relevant skill(s).`); } else { - entries.push(`BLOCKING REQUIREMENT: When a skill applies to the user's request, you MUST load and read the SKILL.md file IMMEDIATELY as your first action, BEFORE generating any other response or taking action on the task. Use ${readTool.variable} to load the relevant skill(s).`); + entries.push(`BLOCKING REQUIREMENT: When a skill applies to the user's request, you MUST load and read the SKILL.md file IMMEDIATELY as your first action, BEFORE generating any other response or taking action on the task. Use ${fileReadTool.variable} to load the relevant skill(s).`); } entries.push('NEVER just mention or reference a skill in your response without actually loading it first. If a skill is relevant, load it before proceeding.'); entries.push('How to determine if a skill applies:'); @@ -459,7 +461,7 @@ export class ComputeAutomaticInstructions { } else { entries.push('Here is a list of skills that contain domain specific knowledge on a variety of topics.'); entries.push('Each skill comes with a description of the topic and a file path that contains the detailed instructions.'); - entries.push(`When a user asks you to perform a task that falls within the domain of a skill, use the ${readTool.variable} tool to acquire the full instructions from the file URI.`); + entries.push(`When a user asks you to perform a task that falls within the domain of a skill, use the ${fileReadTool.variable} tool to acquire the full instructions from the file URI.`); } } const SKILL_DESCRIPTION_CHAR_BUDGET = 15000; @@ -569,7 +571,7 @@ export class ComputeAutomaticInstructions { } } }; - collectToolReference(readTool); + collectToolReference(fileReadTool); collectToolReference(runSubagentTool); collectToolReference(skillTool); return toPromptTextVariableEntry(content, true, toolReferences); diff --git a/src/vs/workbench/contrib/chat/common/tools/copilotToolIds.ts b/src/vs/workbench/contrib/chat/common/tools/copilotToolIds.ts new file mode 100644 index 0000000000000..a23f25b614a6f --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/copilotToolIds.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const enum CopilotToolId { + ReadFile = 'copilot_readFile', +} + +export const enum CopilotChatSettingId { + Gpt55ReadFileToolEnabled = 'github.copilot.chat.gpt55ReadFileTool.enabled', +} diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index e5326f91fbc08..71709dc6a4ce4 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -501,10 +501,11 @@ export class ToolSetForModel { constructor( private readonly _toolSet: IToolSet, private readonly model: ILanguageModelChatMetadata | undefined, + private readonly toolFilter?: (toolData: IToolData) => boolean, ) { } public getTools(r?: IReader): Iterable { - return Iterable.filter(this._toolSet.getTools(r), toolData => toolMatchesModel(toolData, this.model)); + return Iterable.filter(this._toolSet.getTools(r), toolData => toolMatchesModel(toolData, this.model) && (!this.toolFilter || this.toolFilter(toolData))); } } diff --git a/src/vs/workbench/contrib/chat/common/tools/toolResultCompressor.ts b/src/vs/workbench/contrib/chat/common/tools/toolResultCompressor.ts index 7c42875e83cc8..b77172e3eadce 100644 --- a/src/vs/workbench/contrib/chat/common/tools/toolResultCompressor.ts +++ b/src/vs/workbench/contrib/chat/common/tools/toolResultCompressor.ts @@ -39,9 +39,41 @@ export interface IToolResultFilter { apply(text: string, input: unknown): IToolResultFilterOutput; } +/** + * Result of looking up a tool invocation in an {@link IToolResultCache}. + */ +export interface IToolResultCacheHit { + /** The cached output content from the previous run. */ + readonly text: string; + /** Wall-clock timestamp (ms since epoch) of when the cached entry was produced. */ + readonly timestamp: number; +} + +/** + * A read-through / write-through cache for tool results. Used to implement + * "same as last run" response dedup for read-only, deterministic tool calls. + * + * The compressor invokes caches in this order on every `maybeCompress` call: + * 1. `observe(toolId, input)` — caches may use this hook to invalidate + * sibling entries (e.g. a `git commit` clears `git status` / `git diff`). + * 2. `lookup(toolId, input)` — if any cache returns a hit, the compressor + * substitutes the result with a single-line "same output as last run" + * reply and emits `cacheHit: true` telemetry. + * 3. If no hit, after compression the compressor calls `record` so the + * cache can store the (possibly compressed) output. + */ +export interface IToolResultCache { + readonly id: string; + readonly toolIds: readonly string[]; + observe(toolId: string, input: unknown): void; + lookup(toolId: string, input: unknown): IToolResultCacheHit | undefined; + record(toolId: string, input: unknown, text: string): void; +} + export interface IToolResultCompressor { readonly _serviceBrand: undefined; registerFilter(filter: IToolResultFilter): void; + registerCache(cache: IToolResultCache): void; /** * Returns a possibly-compressed copy of `result`, or `undefined` if no * compression was applied (caller should pass through the original). @@ -49,6 +81,47 @@ export interface IToolResultCompressor { maybeCompress(toolId: string, input: unknown, result: IToolResult): IToolResult | undefined; } +/** + * Heuristically decide whether a text part should be excluded from filter + * rewriting because it carries structured data the model is likely to parse. + * + * Currently detects: + * - Top-level JSON objects/arrays (parsed to verify) + * - YAML documents (leading `---` header) + * - TOML-style documents (leading `[section]` header) + * + * Returning `true` means: the registry will NOT pass this text part to any + * filter, even if the filter says it matches. + * + * This is intentionally cheap and conservative — false negatives just mean + * a filter may decline to compress; false positives could corrupt structured + * payloads. + */ +export function isProtectedFromCompression(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + // Top-level JSON object or array — refuse to touch. + const first = trimmed[0]; + const last = trimmed[trimmed.length - 1]; + if ((first === '{' && last === '}') || (first === '[' && last === ']')) { + try { + JSON.parse(trimmed); + return true; + } catch { + // fall through + } + } + // TOML / YAML-style documents at the top level: a line `---` opener or + // a file-level table header like `[section]`. + // These are cheap heuristics — we don't try to parse YAML/TOML. + if (/^---\s*\n/.test(trimmed) || /^\[[A-Za-z_][A-Za-z0-9_.-]*\]\s*\n/.test(trimmed)) { + return true; + } + return false; +} + /** * Outputs below this many characters (UTF-16 code units, i.e. * `string.length`) are not worth compressing: filter savings on short diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index 65a84dbed69af..e94765584f96f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -42,6 +42,7 @@ function createEntitlementService(opts: { copilotTrackingId: undefined, onDidChangeQuotaExceeded: Event.None, onDidChangeQuotaRemaining: Event.None, + onDidChangeUsageBasedBilling: Event.None, quotas: { chat: opts.chat, completions: opts.completions, diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 0df0af2ee2282..0ec94c6fe35af 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -30,6 +30,7 @@ import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl import { MockChatService } from '../../common/chatService/mockChatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { CopilotChatSettingId, CopilotToolId } from '../../../common/tools/copilotToolIds.js'; import { ILanguageModelToolsConfirmationService } from '../../../common/tools/languageModelToolsConfirmationService.js'; import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { IToolResultCompressor } from '../../../common/tools/toolResultCompressor.js'; @@ -41,6 +42,7 @@ import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; const noopToolResultCompressor: IToolResultCompressor = { _serviceBrand: undefined, registerFilter: () => { }, + registerCache: () => { }, maybeCompress: () => undefined, }; @@ -3219,6 +3221,45 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(result.has(claudeTool), false, 'claudeTool should be filtered out by model'); }); + test('gpt-5.5 readFile setting controls Copilot read tool availability', () => { + const readTool: IToolData = { + id: CopilotToolId.ReadFile, + toolReferenceName: 'readFile', + modelDescription: 'Read File Tool', + displayName: 'Read File', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + }; + store.add(service.registerToolData(readTool)); + store.add(service.readToolSet.addTool(readTool)); + + const gpt55Model = { id: 'gpt-5.5', vendor: 'copilot', family: 'gpt-5.5', version: '1.0' } as ILanguageModelChatMetadata; + + configurationService.setUserConfiguration(CopilotChatSettingId.Gpt55ReadFileToolEnabled, false); + + const disabledTools = Array.from(service.getTools(gpt55Model)); + assert.ok(!disabledTools.some(tool => tool.id === CopilotToolId.ReadFile), 'readFile should not be returned from getTools when disabled for gpt-5.5'); + + const disabledReadToolSet = Array.from(service.getToolSetsForModel(gpt55Model)).find(toolSet => toolSet.id === 'read'); + assert.ok(disabledReadToolSet, 'read tool set should exist'); + assert.ok(!Array.from(disabledReadToolSet.getTools()).some(tool => tool.id === CopilotToolId.ReadFile), 'readFile should not be included as a read tool-set member when disabled for gpt-5.5'); + + const disabledEnablementMap = service.toToolAndToolSetEnablementMap(['read/readFile'], gpt55Model); + assert.strictEqual(disabledEnablementMap.has(readTool), false, 'readFile should not be included in explicit enablement maps when disabled for gpt-5.5'); + + configurationService.setUserConfiguration(CopilotChatSettingId.Gpt55ReadFileToolEnabled, true); + + const enabledTools = Array.from(service.getTools(gpt55Model)); + assert.ok(enabledTools.some(tool => tool.id === CopilotToolId.ReadFile), 'readFile should be returned from getTools when enabled for gpt-5.5'); + + const enabledReadToolSet = Array.from(service.getToolSetsForModel(gpt55Model)).find(toolSet => toolSet.id === 'read'); + assert.ok(enabledReadToolSet, 'read tool set should exist'); + assert.ok(Array.from(enabledReadToolSet.getTools()).some(tool => tool.id === CopilotToolId.ReadFile), 'readFile should be included as a read tool-set member when enabled for gpt-5.5'); + + const enabledEnablementMap = service.toToolAndToolSetEnablementMap(['read/readFile'], gpt55Model); + assert.strictEqual(enabledEnablementMap.get(readTool), true, 'readFile should be included in explicit enablement maps when enabled for gpt-5.5'); + }); + test('observeTools returns tools filtered by context', async () => { return runWithFakedTimers({}, async () => { contextKeyService.createKey('featureEnabled', true); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index a20da4cabfeb6..5690b9694197f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -42,6 +42,7 @@ import { IPathService } from '../../../../../services/path/common/pathService.js import { IFileQuery, ISearchService } from '../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { TerminalToolId } from '../../../common/tools/terminalToolIds.js'; import { IRemoteAgentService } from '../../../../../../workbench/services/remote/common/remoteAgentService.js'; import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; @@ -175,6 +176,9 @@ suite('ComputeAutomaticInstructions', () => { if (name === 'readFile') { return { id: 'vscode_readFile', name: 'readFile' }; } + if (name === 'runInTerminal') { + return { id: TerminalToolId.RunInTerminal, name: 'runInTerminal' }; + } if (name === 'runSubagent') { return { id: 'vscode_runSubagent', name: 'runSubagent' }; } @@ -1486,6 +1490,52 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(instructions[0], 'applyTo')[0], '**/*.ts'); }); + test('should generate instructions list when readFile tool unavailable and runInTerminal tool available', async () => { + const rootFolderName = 'instructions-list-terminal-fallback-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/instructions/test.instructions.md`, + contents: [ + '---', + 'description: \'Test instructions\'', + 'applyTo: "**/*.ts"', + '---', + 'Test content', + ] + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, + ChatModeKind.Agent, + { [TerminalToolId.RunInTerminal]: true }, // Enable runInTerminal tool only + undefined, + localSessionType + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for instructions list'); + assert.ok(textVariables[0].value.includes('#tool:runInTerminal'), 'Instructions list should reference the runInTerminal tool'); + assert.ok(!textVariables[0].value.includes('#tool:readFile'), 'Instructions list should not reference the readFile tool'); + + const instructionsList = xmlContents(textVariables[0].value, 'instructions'); + assert.equal(instructionsList.length, 1, 'There should be one instructions list'); + + const instructions = xmlContents(instructionsList[0], 'instruction'); + assert.equal(instructions.length, 1, 'There should be one instruction'); + + assert.equal(xmlContents(instructions[0], 'description')[0], 'Test instructions'); + assert.equal(xmlContents(instructions[0], 'file')[0], getFilePath(`${rootFolder}/.github/instructions/test.instructions.md`)); + assert.equal(xmlContents(instructions[0], 'applyTo')[0], '**/*.ts'); + }); + test('should include agents list when runSubagent tool available', async () => { const rootFolderName = 'agents-list-test'; const rootFolder = `/${rootFolderName}`; @@ -1690,6 +1740,55 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(skills[1], 'name')[0], 'typescript'); }); + test('should include skills list when readFile tool unavailable and runInTerminal tool available', async () => { + const rootFolderName = 'skills-list-terminal-fallback-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/skills/javascript/SKILL.md`, + contents: [ + '---', + 'name: \'javascript\'', + 'description: \'JavaScript best practices\'', + '---', + 'JavaScript skill content', + ] + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, + ChatModeKind.Agent, + { [TerminalToolId.RunInTerminal]: true }, // Enable runInTerminal tool only + undefined, + localSessionType + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for skills list'); + assert.ok(textVariables[0].value.includes('#tool:runInTerminal'), 'Skills list should reference the runInTerminal tool'); + assert.ok(!textVariables[0].value.includes('#tool:readFile'), 'Skills list should not reference the readFile tool'); + + const skillsList = xmlContents(textVariables[0].value, 'skills'); + assert.equal(skillsList.length, 1, 'There should be one skills list'); + + const skills = xmlContents(skillsList[0], 'skill'); + assert.equal(skills.length, 1, 'There should be one skill'); + + assert.equal(xmlContents(skills[0], 'description')[0], 'JavaScript best practices'); + assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/javascript/SKILL.md`)); + assert.equal(xmlContents(skills[0], 'name')[0], 'javascript'); + }); + test('should not include skills list when readFile tool unavailable', async () => { const rootFolderName = 'no-skills-list-test'; const rootFolder = `/${rootFolderName}`; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 8c9728075b5ac..5ae01c0fa2073 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { IMarker as IXtermMarker } from '@xterm/xterm'; -import { DeferredPromise, RunOnceScheduler, timeout, type CancelablePromise } from '../../../../../../base/common/async.js'; +import { DeferredPromise, timeout, type CancelablePromise } from '../../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; @@ -1483,7 +1483,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let exitCode: number | undefined; let altBufferResult: IToolResult | undefined; let didTimeout = false; - let didIdleSilence = false; let didInputNeeded = false; let didSensitiveAutoCancelled = false; // Covers both terminals that start as background (persistentSession) and @@ -1623,7 +1622,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText += `${outputAnalyzerMessage}\n`; } resultText += pollingResult.output; - resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'none')}`; + resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}`; } else if (pollingResult) { resultText += `\n The command is still running, with output:\n`; if (outputAnalyzerMessage) { @@ -1673,7 +1672,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { )); } }); - const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'idleSilence' }>[] = [ + const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })), new Promise<{ type: 'inputNeeded' }>(resolve => { @@ -1687,18 +1686,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (timeoutRacePromise) { raceCandidates.push(timeoutRacePromise); } - // Idle-silence promotion: if no terminal output arrives for N ms, - // hand control back to the model with the terminal ID + output - // collected so far. The process keeps running — model can poll, - // send input, or kill it. Default 60s; 0 disables. - const idleSilenceMs = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdleSilenceTimeoutMs) ?? 60000; - if (idleSilenceMs > 0) { - const idleSilenceDeferred = new DeferredPromise<{ type: 'idleSilence' }>(); - const idleSilenceScheduler = raceCleanup.add(new RunOnceScheduler(() => idleSilenceDeferred.complete({ type: 'idleSilence' as const }), idleSilenceMs)); - raceCleanup.add(toolTerminal.instance.onData(() => idleSilenceScheduler.schedule())); - idleSilenceScheduler.schedule(); - raceCandidates.push(idleSilenceDeferred.p); - } const raceResult = await Promise.race(raceCandidates); raceCleanup.dispose(); @@ -1735,18 +1722,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const timeoutOutput = execution.getOutput(); outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0; terminalResult = timeoutOutput ?? ''; - } else if (raceResult.type === 'idleSilence') { - // No output for N ms - promote to background and hand back to model. Process keeps running. - this._logService.debug(`RunInTerminalTool: Idle silence reached (${idleSilenceMs}ms), promoting to background`); - error = 'idleSilence'; - didIdleSilence = true; - isBackgroundExecution = true; - toolTerminal.isBackground = true; - this._sessionTerminalAssociations.delete(chatSessionResource); - await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true); - const idleSilenceOutput = execution.getOutput(); - outputLineCount = idleSilenceOutput ? count(idleSilenceOutput.trim(), '\n') + 1 : 0; - terminalResult = idleSilenceOutput ?? ''; } else { const executeResult = raceResult.result; // Reset user input state after command execution completes @@ -2000,17 +1975,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didSensitiveAutoCancelled) { resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. The user is unavailable (auto-approve / autopilot mode is on, so no human can focus the terminal to type a secret) and the command has been cancelled. Stop, do NOT retry the command, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret. Tell the user to run the command interactively when they are available.\n\n`); } else if (didInputNeeded) { - resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'none')}\n\n`); + resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`); } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications ? ' You will be automatically notified on your next turn when it completes.' : ''; - resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'timeout')}\n\n`); - } else if (didIdleSilence) { - const notificationHint = shouldSendNotifications - ? ' You will be automatically notified on your next turn when it completes.' - : ''; - resultText.push(`Note: The command produced no new output for an extended period and was moved to background terminal ID ${termId}; the process is still running and has not been killed.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'idleSilence')}\n\n`); + resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ true)}\n\n`); } const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { @@ -2064,12 +2034,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { * tokens) must never be routed through `vscode_askQuestions` * because answers to that tool are sent through the model — the * user is told to type those values directly into the terminal. - * `kill_terminal` is only advertised when the command may be hung - * (`'timeout'` or `'idleSilence'`) — suggesting it in the general case - * leads the model to terminate valid interactive sessions (e.g. - * `npm init`) instead of driving them. + * `kill_terminal` is only advertised on the timeout branch — suggesting it + * in the general case leads the model to terminate valid interactive + * sessions (e.g. `npm init`) instead of driving them. */ - private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, hungHint: 'none' | 'timeout' | 'idleSilence'): string { + private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, mentionTimeout: boolean): string { const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); const lines: string[] = []; lines.push(`This note is not a signal to end the turn — pick one of the actions below and continue.`); @@ -2083,10 +2052,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { lines.push(` 1. If the command may still be producing output or the shell prompt has not returned, call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. This is the default and safest action when unsure.`); lines.push(` 2. Only if the output clearly ends with a real non-secret input prompt (Continue? (y/n), Enter selection, etc. — a normal shell prompt like \`$\` or \`#\` does NOT count), call the vscode_askQuestions tool to ask the user, then send each answer using ${TerminalToolId.SendToTerminal} with id="${termId}" (which returns the next few lines of output). Repeat one prompt at a time. NEVER route secret prompts (passwords, passphrases, tokens, API keys, etc.) through vscode_askQuestions — answers to that tool are sent through the model. For secret prompts, tell the user to type the value directly into the terminal and stop.`); } - if (hungHint === 'timeout') { + if (mentionTimeout) { lines.push(` 3. A timeout does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`); - } else if (hungHint === 'idleSilence') { - lines.push(` 3. Producing no output for an extended period does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`); } return lines.join('\n'); } @@ -2586,7 +2553,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } lastInputNeededOutput = currentOutput; lastInputNeededNotificationTime = now; - const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, 'none'); + const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false); const message = `[Terminal ${termId} notification: command may be waiting for input — assess the output below.]\n${inputAction}\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandParser.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandParser.ts new file mode 100644 index 0000000000000..dcbd9c479752a --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandParser.ts @@ -0,0 +1,383 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Lightweight POSIX-ish command line tokenizer / segmenter used by the + * `run_in_terminal` output-compression filters. We deliberately do NOT + * implement a real shell parser — we only need enough to: + * + * 1. Split a pipeline / `&&` / `||` / `;` chain into segments, + * 2. Tokenize each segment into argv-style words while respecting single + * quotes, double quotes, and backslash escapes, + * 3. Strip leading `FOO=bar` env assignments and common wrapper programs + * (`sudo`, `time`, `nice`, `env`, `xargs`, `stdbuf`) so the filter + * registry can see the "real" program being invoked. + * + * Heredocs, command substitution, brace expansion, glob expansion, and + * arithmetic are intentionally out of scope. + */ + +/** Programs that "wrap" the actual program we want to identify. */ +const WRAPPER_PROGRAMS = new Set([ + 'sudo', 'doas', 'time', 'command', 'builtin', 'exec', + 'nice', 'ionice', 'nohup', 'env', 'xargs', 'stdbuf', + 'unbuffer', 'script', 'timeout', +]); + +const ENV_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*$/; + +/** + * Tokenize a single command segment (no `|`, `&&`, `||`, `;`) into argv-style + * words. Respects single quotes (no escaping), double quotes (with `\\`, + * `\"`, `\$`, `\`` escapes), and backslash-escapes outside of quotes. + * + * Returns `[]` for empty input. Never throws on malformed input — unterminated + * quotes are treated as if they ran to end of string. + */ +export function tokenize(segment: string): string[] { + const tokens: string[] = []; + let cur = ''; + let inSingle = false; + let inDouble = false; + let hasContent = false; + for (let i = 0; i < segment.length; i++) { + const ch = segment[i]; + if (inSingle) { + if (ch === '\'') { + inSingle = false; + } else { + cur += ch; + } + continue; + } + if (inDouble) { + if (ch === '\\' && i + 1 < segment.length) { + const next = segment[i + 1]; + // Inside double quotes, only \, ", $, ` are escaped. + if (next === '\\' || next === '"' || next === '$' || next === '`') { + cur += next; + i++; + continue; + } + cur += ch; + continue; + } + if (ch === '"') { + inDouble = false; + } else { + cur += ch; + } + continue; + } + if (ch === '\\' && i + 1 < segment.length) { + cur += segment[i + 1]; + i++; + hasContent = true; + continue; + } + if (ch === '\'') { + inSingle = true; + hasContent = true; + continue; + } + if (ch === '"') { + inDouble = true; + hasContent = true; + continue; + } + if (/\s/.test(ch)) { + if (cur.length > 0 || hasContent) { + tokens.push(cur); + cur = ''; + hasContent = false; + } + continue; + } + cur += ch; + hasContent = true; + } + if (cur.length > 0 || hasContent) { + tokens.push(cur); + } + return tokens; +} + +export type SegmentSeparator = '|' | '&&' | '||' | ';' | '|&'; + +export interface ICommandSegment { + readonly raw: string; + /** Argv-style tokens after stripping env prefixes / wrappers. */ + readonly tokens: readonly string[]; + /** Argv as written, before stripping env prefixes / wrappers. */ + readonly rawTokens: readonly string[]; + /** Env assignments stripped from the head, in source order. */ + readonly envPrefixes: readonly string[]; + /** Wrapper programs stripped from the head, in source order. */ + readonly wrappers: readonly string[]; + /** Separator that ended this segment, or `undefined` for the last segment. */ + readonly trailingSeparator: SegmentSeparator | undefined; +} + +export interface IParsedCommand { + readonly raw: string; + readonly segments: readonly ICommandSegment[]; +} + +/** + * Split a command line into segments using `|`, `||`, `&&`, `;`, `|&` as + * separators. Honors quoting so that `echo "a;b" | wc -l` splits into two + * segments, not three. + */ +function splitSegments(command: string): Array<{ raw: string; sep: SegmentSeparator | undefined }> { + const out: Array<{ raw: string; sep: SegmentSeparator | undefined }> = []; + let cur = ''; + let inSingle = false; + let inDouble = false; + const push = (sep: SegmentSeparator | undefined) => { + const trimmed = cur.trim(); + if (trimmed.length > 0 || sep !== undefined) { + out.push({ raw: trimmed, sep }); + } + cur = ''; + }; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (inSingle) { + cur += ch; + if (ch === '\'') { + inSingle = false; + } + continue; + } + if (inDouble) { + if (ch === '\\' && i + 1 < command.length) { + cur += ch + command[i + 1]; + i++; + continue; + } + cur += ch; + if (ch === '"') { + inDouble = false; + } + continue; + } + if (ch === '\\' && i + 1 < command.length) { + cur += ch + command[i + 1]; + i++; + continue; + } + if (ch === '\'') { + inSingle = true; + cur += ch; + continue; + } + if (ch === '"') { + inDouble = true; + cur += ch; + continue; + } + if (ch === '|' && command[i + 1] === '|') { + push('||'); + i++; + continue; + } + if (ch === '|' && command[i + 1] === '&') { + push('|&'); + i++; + continue; + } + if (ch === '|') { + push('|'); + continue; + } + if (ch === '&' && command[i + 1] === '&') { + push('&&'); + i++; + continue; + } + if (ch === ';') { + push(';'); + continue; + } + cur += ch; + } + push(undefined); + return out; +} + +/** + * Strip leading env assignments and wrapper programs from a token list. For + * wrappers that take a value flag (e.g. `env -i FOO=bar prog`, `timeout 10 prog`) + * we conservatively consume `--`-terminated flag arguments but keep moving the + * head pointer until we hit a non-flag, non-env, non-wrapper token. + */ +function stripPrefixesAndWrappers(rawTokens: readonly string[]): { + tokens: string[]; + envPrefixes: string[]; + wrappers: string[]; +} { + const envPrefixes: string[] = []; + const wrappers: string[] = []; + let i = 0; + // Leading env assignments. + while (i < rawTokens.length && ENV_ASSIGN_RE.test(rawTokens[i])) { + envPrefixes.push(rawTokens[i]); + i++; + } + // Wrapper programs. Walk until we either run out of tokens or hit a token + // that doesn't look like a wrapper or a flag for one. + while (i < rawTokens.length) { + const tok = rawTokens[i]; + if (WRAPPER_PROGRAMS.has(tok)) { + wrappers.push(tok); + i++; + // Skip flags that belong to the wrapper, plus inner env assignments + // (e.g. `env -i PATH=/usr/bin prog`). + while (i < rawTokens.length) { + const next = rawTokens[i]; + if (next === '--') { + i++; + break; + } + if (next.startsWith('-')) { + i++; + continue; + } + if (ENV_ASSIGN_RE.test(next)) { + envPrefixes.push(next); + i++; + continue; + } + // `timeout` and `nice` take a numeric / signal value before the + // program; consume one such token if present. + if ((tok === 'timeout' || tok === 'nice' || tok === 'ionice') && /^\d/.test(next)) { + i++; + continue; + } + break; + } + continue; + } + break; + } + return { + tokens: rawTokens.slice(i), + envPrefixes, + wrappers, + }; +} + +/** + * Parse a full command line into segments. Each segment carries both the raw + * tokens and the "effective" tokens (with env / wrapper prefixes stripped). + */ +export function parseCommand(command: string | undefined): IParsedCommand | undefined { + if (!command) { + return undefined; + } + const trimmed = command.trim(); + if (!trimmed) { + return undefined; + } + const rawSegments = splitSegments(trimmed); + if (rawSegments.length === 0) { + return undefined; + } + const segments: ICommandSegment[] = rawSegments.map(seg => { + const rawTokens = tokenize(seg.raw); + const { tokens, envPrefixes, wrappers } = stripPrefixesAndWrappers(rawTokens); + return { + raw: seg.raw, + rawTokens, + tokens, + envPrefixes, + wrappers, + trailingSeparator: seg.sep, + }; + }); + return { raw: trimmed, segments }; +} + +/** + * Returns the head program (after stripping env / wrappers) and the first + * subcommand-like token. Long flags (`--no-pager`) are skipped between head + * and sub. Returns `undefined` when the segment has no tokens. + */ +export function segmentHead(segment: ICommandSegment): { head: string; sub: string | undefined } | undefined { + const tokens = segment.tokens; + if (tokens.length === 0) { + return undefined; + } + const head = tokens[0]; + let sub: string | undefined; + for (let i = 1; i < tokens.length; i++) { + if (tokens[i].startsWith('--')) { + continue; + } + sub = tokens[i]; + break; + } + return { head, sub }; +} + +/** Convenience: parse + return head of first segment. */ +export function parseCommandHead(command: string | undefined): { head: string; sub: string | undefined } | undefined { + const parsed = parseCommand(command); + if (!parsed || parsed.segments.length === 0) { + return undefined; + } + return segmentHead(parsed.segments[0]); +} + +/** + * Does the segment's flag set contain any of `flags`? Recognizes both + * short-bundled flags (`-la` contains `l` and `a`) and long flags. + */ +export function segmentHasFlag(segment: ICommandSegment, flags: readonly string[]): boolean { + const longFlags = flags.filter(f => f.length > 1).map(f => `--${f}`); + const shortFlags = flags.filter(f => f.length === 1); + for (const tok of segment.tokens) { + if (!tok.startsWith('-') || tok === '--') { + continue; + } + if (tok.startsWith('--')) { + const name = tok.slice(2).split('=')[0]; + if (longFlags.includes(`--${name}`)) { + return true; + } + continue; + } + // Short-bundled. + const bundled = tok.slice(1); + for (const f of shortFlags) { + if (bundled.includes(f)) { + return true; + } + } + } + return false; +} + +/** + * Iterate over every segment in the parsed command. Useful for filters that + * want to act on any segment of a pipeline (e.g. `cat foo.txt | grep bar` + * — the `cat` filter wants to fire on the first segment). + */ +export function findSegments( + parsed: IParsedCommand, + predicate: (head: { head: string; sub: string | undefined }, segment: ICommandSegment) => boolean, +): ICommandSegment[] { + const out: ICommandSegment[] = []; + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (!head) { + continue; + } + if (predicate(head, seg)) { + out.push(seg); + } + } + return out; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCache.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCache.ts new file mode 100644 index 0000000000000..590007259797c --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCache.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IToolResultCache, IToolResultCacheHit } from '../../../../chat/common/tools/toolResultCompressor.js'; +import { TerminalToolId } from '../../../../chat/common/tools/terminalToolIds.js'; +import { parseCommand, segmentHead } from './terminalCommandParser.js'; + +/** + * Session-memory dedup cache for `run_in_terminal` output. Keyed on + * `::` (cwd currently best-effort — pulled from the input's + * `cwd` field when present, falling back to a single shared bucket). + * + * Read-only command classes ({@link CacheClass}) define TTLs; only + * read-only commands are stored. Mutation commands trigger + * {@link _invalidateSiblings} when observed so a later `git status` won't + * return a stale entry from before a `git commit`. + * + * Designed to live as long as the chat session; entries also age out by TTL. + */ + +interface ITerminalInput { + command?: unknown; + cwd?: unknown; +} + +export const enum CacheClass { + /** `git status`, `ls`, `pwd` — likely to change quickly. */ + Fast = 'fast', + /** test runners. */ + Medium = 'medium', + /** `git log`, `find`, `tree`. */ + Slow = 'slow', +} + +const TTL_MS: Record = { + [CacheClass.Fast]: 30_000, + [CacheClass.Medium]: 120_000, + [CacheClass.Slow]: 300_000, +}; + +const MAX_ENTRIES = 256; + +interface IClassification { + readonly cls: CacheClass | undefined; + /** Programs whose cached entries should be invalidated when this command runs. */ + readonly invalidates: readonly string[]; +} + +/** Classify a command's first segment. `cls === undefined` => do not cache. */ +function classifyCommand(command: string | undefined): IClassification { + const parsed = parseCommand(command); + if (!parsed || parsed.segments.length === 0) { + return { cls: undefined, invalidates: [] }; + } + // For compound commands (e.g. `git status && git commit`), disable caching + // entirely — classifying only the first segment could miss mutations or + // return stale results. + if (parsed.segments.length > 1) { + // Still check all segments for invalidation targets. + const allInvalidates: string[] = []; + for (const seg of parsed.segments) { + const h = segmentHead(seg); + if (h) { + const sub = classifySingleHead(h); + allInvalidates.push(...sub.invalidates); + } + } + return { cls: undefined, invalidates: allInvalidates }; + } + const head = segmentHead(parsed.segments[0]); + if (!head) { + return { cls: undefined, invalidates: [] }; + } + return classifySingleHead(head); +} + +function classifySingleHead(head: { head: string; sub: string | undefined }): IClassification { + switch (head.head) { + case 'git': { + // Mutations clear all cached `git ...` results in this cwd. + if (head.sub && /^(add|commit|push|pull|fetch|merge|rebase|reset|checkout|switch|restore|cherry-pick|revert|stash|tag|branch|am|apply|clean|rm|mv)$/.test(head.sub)) { + return { cls: undefined, invalidates: ['git'] }; + } + if (head.sub === 'status' || head.sub === 'diff' || head.sub === 'show' || head.sub === 'blame') { + return { cls: CacheClass.Fast, invalidates: [] }; + } + if (head.sub === 'log' || head.sub === 'reflog' || head.sub === 'shortlog') { + return { cls: CacheClass.Slow, invalidates: [] }; + } + return { cls: undefined, invalidates: [] }; + } + case 'ls': + case 'pwd': + case 'tree': + case 'find': + return { cls: head.head === 'find' || head.head === 'tree' ? CacheClass.Slow : CacheClass.Fast, invalidates: [] }; + case 'npm': + case 'pnpm': + case 'yarn': + if (head.sub === 'ls' || head.sub === 'list' || head.sub === 'outdated') { + return { cls: CacheClass.Slow, invalidates: [] }; + } + if (head.sub === 'install' || head.sub === 'i' || head.sub === 'ci' || head.sub === 'add' || head.sub === 'remove' || head.sub === 'uninstall' || head.sub === 'update') { + return { cls: undefined, invalidates: ['npm', 'pnpm', 'yarn'] }; + } + if (head.sub === 'test' || head.sub === 'run' || head.sub === undefined) { + return { cls: CacheClass.Medium, invalidates: [] }; + } + return { cls: undefined, invalidates: [] }; + case 'pytest': + case 'jest': + case 'vitest': + case 'cargo': + if (head.head === 'cargo' && head.sub && /^(test|nextest|check|build)$/.test(head.sub)) { + return { cls: CacheClass.Medium, invalidates: [] }; + } + if (head.head !== 'cargo') { + return { cls: CacheClass.Medium, invalidates: [] }; + } + return { cls: undefined, invalidates: [] }; + case 'go': + if (head.sub === 'test' || head.sub === 'build' || head.sub === 'vet') { + return { cls: CacheClass.Medium, invalidates: [] }; + } + return { cls: undefined, invalidates: [] }; + case 'docker': + case 'kubectl': + if (head.sub === 'ps' || head.sub === 'images' || head.sub === 'get' || head.sub === 'describe') { + return { cls: CacheClass.Fast, invalidates: [] }; + } + return { cls: undefined, invalidates: [] }; + case 'env': + case 'printenv': + return { cls: CacheClass.Slow, invalidates: [] }; + case 'gh': + return { cls: CacheClass.Medium, invalidates: [] }; + } + return { cls: undefined, invalidates: [] }; +} + +interface ICacheEntry { + readonly cwd: string; + readonly command: string; + readonly text: string; + readonly timestamp: number; + readonly cls: CacheClass; +} + +function getInput(input: unknown): { command: string; cwd: string } | undefined { + if (typeof input !== 'object' || input === null) { + return undefined; + } + const i = input as ITerminalInput; + if (typeof i.command !== 'string' || !i.command.trim()) { + return undefined; + } + const cwd = typeof i.cwd === 'string' ? i.cwd : ''; + return { command: i.command, cwd }; +} + +export class TerminalOutputCache implements IToolResultCache { + readonly id = 'terminal.session-dedup'; + readonly toolIds = [TerminalToolId.RunInTerminal]; + + private readonly _entries = new Map(); + private readonly _now: () => number; + + constructor(now: () => number = () => Date.now()) { + this._now = now; + } + + private _key(cwd: string, command: string): string { + return `${cwd}::${command.trim()}`; + } + + observe(_toolId: string, input: unknown): void { + const parsed = getInput(input); + if (!parsed) { + return; + } + const { invalidates } = classifyCommand(parsed.command); + if (invalidates.length === 0) { + return; + } + this._invalidateByProgram(parsed.cwd, invalidates); + } + + lookup(_toolId: string, input: unknown): IToolResultCacheHit | undefined { + const parsed = getInput(input); + if (!parsed) { + return undefined; + } + const { cls } = classifyCommand(parsed.command); + if (cls === undefined) { + return undefined; + } + const key = this._key(parsed.cwd, parsed.command); + const entry = this._entries.get(key); + if (!entry) { + return undefined; + } + const ttl = TTL_MS[entry.cls]; + if (this._now() - entry.timestamp > ttl) { + this._entries.delete(key); + return undefined; + } + return { text: entry.text, timestamp: entry.timestamp }; + } + + record(_toolId: string, input: unknown, text: string): void { + const parsed = getInput(input); + if (!parsed) { + return; + } + const { cls } = classifyCommand(parsed.command); + if (cls === undefined) { + return; + } + const key = this._key(parsed.cwd, parsed.command); + // LRU-ish: re-insert at the end to bump recency. + if (this._entries.has(key)) { + this._entries.delete(key); + } + this._entries.set(key, { + cwd: parsed.cwd, + command: parsed.command, + text, + timestamp: this._now(), + cls, + }); + while (this._entries.size > MAX_ENTRIES) { + const oldestKey = this._entries.keys().next().value; + if (oldestKey === undefined) { + break; + } + this._entries.delete(oldestKey); + } + } + + /** External hook for editor file-write notifications etc. */ + invalidateCwd(cwd: string): void { + for (const key of [...this._entries.keys()]) { + const e = this._entries.get(key)!; + if (e.cwd === cwd) { + this._entries.delete(key); + } + } + } + + private _invalidateByProgram(cwd: string, programs: readonly string[]): void { + const progSet = new Set(programs); + for (const key of [...this._entries.keys()]) { + const e = this._entries.get(key)!; + if (e.cwd !== cwd) { + continue; + } + const head = segmentHead(parseCommand(e.command)?.segments[0] ?? { raw: '', tokens: [], rawTokens: [], envPrefixes: [], wrappers: [], trailingSeparator: undefined }); + if (head && progSet.has(head.head)) { + this._entries.delete(key); + } + } + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCompressor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCompressor.ts index 40ceff3e36ba0..2ad597c79c102 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCompressor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalOutputCompressor.ts @@ -5,6 +5,8 @@ import { TerminalToolId } from '../../../../chat/common/tools/terminalToolIds.js'; import { IToolResultCompressor, IToolResultFilter, IToolResultFilterOutput } from '../../../../chat/common/tools/toolResultCompressor.js'; +import { ICommandSegment, parseCommand, parseCommandHead as _parseCommandHead, segmentHasFlag, segmentHead } from './terminalCommandParser.js'; +import { TerminalOutputCache } from './terminalOutputCache.js'; /** * Input shape used by the core `run_in_terminal` tool. We only depend on the @@ -14,41 +16,6 @@ interface ITerminalInput { command?: string; } -/** - * Returns the "head" of a shell command — the first executable word, after - * skipping common env-var assignments like `FOO=bar baz`. `sub` is the first - * non-long-flag token after the head, so `git --no-pager diff` yields - * `{ head: 'git', sub: 'diff' }`. Returns `undefined` when the command can't - * be parsed. - */ -export function parseCommandHead(command: string | undefined): { head: string; sub: string | undefined } | undefined { - if (!command) { - return undefined; - } - // Take only the first pipeline segment so `git diff | cat` still routes to git. - const firstSegment = command.split(/[|;&]/)[0].trim(); - if (!firstSegment) { - return undefined; - } - const tokens = firstSegment.split(/\s+/).filter(t => !/^[A-Z_][A-Z0-9_]*=/.test(t)); - const head = tokens[0]; - if (!head) { - return undefined; - } - // Skip leading long flags like `--no-pager` so `git --no-pager diff` parses - // as `{ head: 'git', sub: 'diff' }`. Short flags (`-la`) stay as the sub - // because for tools like `ls` they're the entire intent. - let sub: string | undefined; - for (let i = 1; i < tokens.length; i++) { - if (tokens[i].startsWith('--')) { - continue; - } - sub = tokens[i]; - break; - } - return { head, sub }; -} - function isTerminalInput(input: unknown): input is ITerminalInput { if (typeof input !== 'object' || input === null) { return false; @@ -57,34 +24,78 @@ function isTerminalInput(input: unknown): input is ITerminalInput { return terminalInput.command === undefined || typeof terminalInput.command === 'string'; } +/** Backwards-compatible re-export so existing tests/consumers keep working. */ +export const parseCommandHead = _parseCommandHead; + +/** + * Build a filter matcher that fires when any segment of the command line + * has the given `(head, sub)` shape, optionally restricted by a flag + * predicate. `sub === '*'` matches any subcommand; `sub === null` matches + * commands with no subcommand. + */ +function makeMatcher(opts: { + head: string; + sub?: string | readonly string[] | '*' | null; + flag?: (seg: ICommandSegment) => boolean; +}) { + const allowedSubs = opts.sub === '*' || opts.sub === undefined ? undefined + : opts.sub === null ? null + : typeof opts.sub === 'string' ? new Set([opts.sub]) + : new Set(opts.sub); + return (input: unknown): boolean => { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (!head || head.head !== opts.head) { + continue; + } + if (allowedSubs === null) { + if (head.sub !== undefined) { + continue; + } + } else if (allowedSubs !== undefined) { + if (head.sub === undefined || !allowedSubs.has(head.sub)) { + continue; + } + } + if (opts.flag && !opts.flag(seg)) { + continue; + } + return true; + } + return false; + }; +} + +// --------------------------------------------------------------------------- +// VCS +// --------------------------------------------------------------------------- + /** * Compresses `git diff` / `git show` output by reducing context lines to a * tighter window and dropping the huge no-op chunks that diffs of generated * files (lockfiles, snapshots) produce. + * + * Notably this does **not** match `git difftool`, which prints a different + * format and would be corrupted by hunk-header rewriting. */ export const gitDiffFilter: IToolResultFilter = { id: 'terminal.git-diff', toolIds: [TerminalToolId.RunInTerminal], - matches(_toolId, input) { - if (!isTerminalInput(input)) { - return false; - } - const parsed = parseCommandHead(input.command); - return parsed?.head === 'git' && (parsed.sub === 'diff' || parsed.sub === 'show'); - }, + matches: (_toolId, input) => makeMatcher({ head: 'git', sub: ['diff', 'show'] })(input), apply(text): IToolResultFilterOutput { const lines = text.split('\n'); const out: string[] = []; - // Number of context lines to keep at the start of each unchanged run before - // collapsing the rest into a single "... N omitted ..." marker. const KEEP_CONTEXT = 1; let contextRun = 0; let inBinaryOrLock = false; - // Pending hunk: we buffer the body so we can rewrite the `@@` header to - // reflect the line counts we actually emit (otherwise the diff is no - // longer valid unified-diff syntax and tools/agents that count lines - // inside a hunk get confused). let pendingHunkHeaderIndex = -1; let pendingHunkOldStart = 0; let pendingHunkNewStart = 0; @@ -104,10 +115,6 @@ export const gitDiffFilter: IToolResultFilter = { const flushContextRun = () => { const omitted = contextRun - KEEP_CONTEXT; if (omitted > 0) { - // Note: this marker is intentionally not valid unified-diff syntax, - // but the surrounding hunk header counts are kept consistent with - // the prefixed (' ', '+', '-') lines we actually emit, so a parser - // that ignores unknown lines still reads correct counts. out.push(`... ${omitted} unchanged context line${omitted === 1 ? '' : 's'} omitted ...`); } contextRun = 0; @@ -129,13 +136,11 @@ export const gitDiffFilter: IToolResultFilter = { if (inBinaryOrLock) { continue; } - // Drop noisy headers we don't need. if (line.startsWith('index ') || line.startsWith('similarity index ') || line.startsWith('dissimilarity index ') || line.startsWith('rename from ') || line.startsWith('rename to ')) { continue; } - // Hunk header: start buffering a new hunk so we can rewrite counts on flush. const hunkMatch = HUNK_RE.exec(line); if (hunkMatch) { flushContextRun(); @@ -145,17 +150,15 @@ export const gitDiffFilter: IToolResultFilter = { pendingOldLines = 0; pendingNewLines = 0; pendingHunkHeaderIndex = out.length; - out.push(line); // placeholder — overwritten by flushHunk() + out.push(line); continue; } - // File-mode markers and binary notices pass through. if (line.startsWith('+++ ') || line.startsWith('--- ') || line.startsWith('Binary files ')) { flushContextRun(); flushHunk(); out.push(line); continue; } - // +/- lines: emit verbatim and account for them in the pending hunk. if (line.startsWith('+')) { flushContextRun(); out.push(line); @@ -168,15 +171,11 @@ export const gitDiffFilter: IToolResultFilter = { pendingOldLines++; continue; } - // Hunk context lines start with a single space. if (!line.startsWith(' ')) { flushContextRun(); out.push(line); continue; } - // Unchanged context line: keep the first KEEP_CONTEXT lines of each run, - // then count the rest so the next non-context line can flush a single - // summary marker. Only the lines we actually emit count toward the hunk. contextRun++; if (contextRun <= KEEP_CONTEXT) { out.push(line); @@ -188,13 +187,68 @@ export const gitDiffFilter: IToolResultFilter = { flushHunk(); const result = out.join('\n'); - return { - text: result, - compressed: result.length < text.length, - }; + return { text: result, compressed: result.length < text.length }; }, }; +/** Trim `git log` output: collapse multiple blank-line runs. */ +export const gitLogFilter: IToolResultFilter = { + id: 'terminal.git-log', + toolIds: [TerminalToolId.RunInTerminal], + matches: (_toolId, input) => makeMatcher({ head: 'git', sub: ['log', 'reflog', 'shortlog'] })(input), + apply(text): IToolResultFilterOutput { + const lines = text.split('\n'); + const out: string[] = []; + let blankRun = 0; + for (const line of lines) { + if (line.trim() === '') { + blankRun++; + if (blankRun <= 1) { + out.push(line); + } + continue; + } + blankRun = 0; + out.push(line); + } + while (out.length > 0 && out[out.length - 1].trim() === '') { + out.pop(); + } + const result = out.join('\n'); + return { text: result, compressed: result.length < text.length }; + }, +}; + +/** Drop the long "(use ... )" hint blocks in `git status`. */ +export const gitStatusFilter: IToolResultFilter = { + id: 'terminal.git-status', + toolIds: [TerminalToolId.RunInTerminal], + matches: (_toolId, input) => makeMatcher({ head: 'git', sub: 'status' })(input), + apply(text): IToolResultFilterOutput { + const HINT_PATTERNS = [ + /^\s*\(use "git add.*"\s+to.*\)\s*$/, + /^\s*\(use "git restore.*"\s+to.*\)\s*$/, + /^\s*\(use "git rm --cached.*"\s+to.*\)\s*$/, + /^\s*\(use "git push" to publish.*\)\s*$/, + /^\s*\(commit or discard.*\)\s*$/, + ]; + const lines = text.split('\n'); + const out: string[] = []; + for (const line of lines) { + if (HINT_PATTERNS.some(re => re.test(line))) { + continue; + } + out.push(line); + } + const result = out.join('\n'); + return { text: result, compressed: result.length < text.length }; + }, +}; + +// --------------------------------------------------------------------------- +// File ops +// --------------------------------------------------------------------------- + /** * Compresses `ls -l` / `ls -la` output by dropping permission/owner/size * columns and keeping only the entry name. Plain `ls` is already terse and @@ -207,17 +261,24 @@ export const lsFilter: IToolResultFilter = { if (!isTerminalInput(input)) { return false; } - const parsed = parseCommandHead(input.command); - if (parsed?.head !== 'ls') { + const parsed = parseCommand(input.command); + if (!parsed) { return false; } - // Only worth running on long-form listings. - return /\s-\w*l/.test(input.command ?? ''); + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (head?.head !== 'ls') { + continue; + } + if (segmentHasFlag(seg, ['l'])) { + return true; + } + } + return false; }, apply(text): IToolResultFilterOutput { const lines = text.split('\n'); const out: string[] = []; - // `ls -l` line: perms links owner group size date1 date2 date3 name const longRe = /^[-dlcbpsDLCBPS][rwx\-tTsS@+.]{9,}\s+\d+\s+\S+\s+\S+\s+\d+\s+\S+\s+\S+\s+\S+\s+(.+)$/; for (const line of lines) { if (!line.trim()) { @@ -235,13 +296,257 @@ export const lsFilter: IToolResultFilter = { } } const result = out.join('\n'); - return { - text: result, - compressed: result.length < text.length, - }; + return { text: result, compressed: result.length < text.length }; + }, +}; + +const MAX_LIST_LINES = 200; + +function capLines(text: string, max: number, label: string): IToolResultFilterOutput { + const lines = text.split('\n'); + if (lines.length <= max + 1) { + return { text, compressed: false }; + } + const kept = lines.slice(0, max); + const omitted = lines.length - max; + kept.push(`... ${omitted} ${label} lines omitted ...`); + const result = kept.join('\n'); + return { text: result, compressed: result.length < text.length }; +} + +export const findFilter: IToolResultFilter = { + id: 'terminal.find', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + return parsed.segments.some(seg => segmentHead(seg)?.head === 'find'); + }, + apply: (text) => capLines(text, MAX_LIST_LINES, 'find result'), +}; + +export const grepFilter: IToolResultFilter = { + id: 'terminal.grep', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + return parsed.segments.some(seg => { + const head = segmentHead(seg); + return head !== undefined && (head.head === 'grep' || head.head === 'rg' || head.head === 'ack' || head.head === 'ag'); + }); + }, + apply: (text) => capLines(text, MAX_LIST_LINES, 'matching'), +}; + +export const treeFilter: IToolResultFilter = { + id: 'terminal.tree', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + return parsed.segments.some(seg => segmentHead(seg)?.head === 'tree'); }, + apply: (text) => capLines(text, MAX_LIST_LINES, 'tree'), }; +// --------------------------------------------------------------------------- +// Test runners +// --------------------------------------------------------------------------- + +function compressTestRunnerOutput(text: string): IToolResultFilterOutput { + const lines = text.split('\n'); + const dropPatterns: RegExp[] = [ + /^\s*PASS\s+\S+/, + /^\s*ok\s+\d+\s+/, + /^\s*\u2713\s/, + /^\s*[.sSEFx]{10,}\s*$/, + /^test\s.+ \.\.\. ok\s*$/, + /^running \d+ tests?$/i, + ]; + const out: string[] = []; + for (const line of lines) { + if (dropPatterns.some(re => re.test(line))) { + continue; + } + out.push(line); + } + const result = out.join('\n'); + return { text: result, compressed: result.length < text.length }; +} + +export const testRunnerFilter: IToolResultFilter = { + id: 'terminal.test-runner', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (!head) { + continue; + } + if (head.head === 'pytest' || head.head === 'jest' || head.head === 'vitest' || head.head === 'playwright' || head.head === 'mocha') { + return true; + } + if (head.head === 'cargo' && head.sub && /^(test|nextest)$/.test(head.sub)) { + return true; + } + if (head.head === 'go' && head.sub === 'test') { + return true; + } + if ((head.head === 'npm' || head.head === 'pnpm' || head.head === 'yarn') && head.sub === 'test') { + return true; + } + if (head.head === 'npx' && head.sub && /^(jest|vitest|playwright|mocha)$/.test(head.sub)) { + return true; + } + } + return false; + }, + apply: (text) => compressTestRunnerOutput(text), +}; + +// --------------------------------------------------------------------------- +// Build tools +// --------------------------------------------------------------------------- + +function compressBuildOutput(text: string): IToolResultFilterOutput { + const dropPatterns: RegExp[] = [ + /^\s*Compiling\s+\S+\s+v\S+/, + /^\s*Downloading\s+\S+/, + /^\s*Downloaded\s+\S+/, + /^\s*Updating\s+crates\.io\s+index/, + /^\s*Finished\s+(dev|release|test)/, + /^make\[\d+\]: (Entering|Leaving) directory/, + /^Download(ed|ing) https?:/, + /^\[INFO\] Downloading from /, + /^\[INFO\] Downloaded from /, + /^> Task :/, + ]; + const lines = text.split('\n'); + const out: string[] = []; + for (const line of lines) { + if (dropPatterns.some(re => re.test(line))) { + continue; + } + out.push(line); + } + const result = out.join('\n'); + return { text: result, compressed: result.length < text.length }; +} + +export const buildToolFilter: IToolResultFilter = { + id: 'terminal.build-tool', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (!head) { + continue; + } + if (head.head === 'cargo' && head.sub && /^(build|check|clippy)$/.test(head.sub)) { + return true; + } + if (head.head === 'go' && (head.sub === 'build' || head.sub === 'vet')) { + return true; + } + if (head.head === 'make' || head.head === 'tsc' || head.head === 'gradle' || head.head === 'mvn') { + return true; + } + if (head.head === 'dotnet' && head.sub === 'build') { + return true; + } + } + return false; + }, + apply: (text) => compressBuildOutput(text), +}; + +// --------------------------------------------------------------------------- +// Linters +// --------------------------------------------------------------------------- + +function compressLinterOutput(text: string): IToolResultFilterOutput { + const lines = text.split('\n'); + const dropPatterns: RegExp[] = [ + /^\s*Success: no issues found\s*$/i, + /^\s*All checks passed\.?\s*$/i, + /^\s*Success:\s*0 errors/i, + ]; + const out: string[] = []; + for (const line of lines) { + if (dropPatterns.some(re => re.test(line))) { + continue; + } + out.push(line); + } + const result = out.join('\n'); + return { text: result, compressed: result.length < text.length }; +} + +export const linterFilter: IToolResultFilter = { + id: 'terminal.linter', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (!head) { + continue; + } + if (head.head === 'eslint' || head.head === 'ruff' || head.head === 'mypy' || head.head === 'prettier' || head.head === 'rubocop' || head.head === 'golangci-lint') { + return true; + } + if (head.head === 'cargo' && head.sub === 'clippy') { + return true; + } + if (head.head === 'npx' && head.sub && /^(eslint|prettier|tsc)$/.test(head.sub)) { + return true; + } + } + return false; + }, + apply: (text) => compressLinterOutput(text), +}; + +// --------------------------------------------------------------------------- +// Package managers +// --------------------------------------------------------------------------- + /** * Compresses `npm install` / `yarn` / `pnpm install` output by stripping * progress lines and audit summary noise, keeping the package summary plus @@ -254,19 +559,26 @@ export const npmInstallFilter: IToolResultFilter = { if (!isTerminalInput(input)) { return false; } - const parsed = parseCommandHead(input.command); + const parsed = parseCommand(input.command); if (!parsed) { return false; } - if (parsed.head === 'npm' && (parsed.sub === 'install' || parsed.sub === 'i' || parsed.sub === 'ci')) { - return true; - } - if ((parsed.head === 'yarn' || parsed.head === 'pnpm') && parsed.sub !== 'test') { - if (/\binstall\b|\badd\b/.test(input.command ?? '')) { + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (!head) { + continue; + } + if (head.head === 'npm' && head.sub && /^(install|i|ci|add)$/.test(head.sub)) { return true; } - if (parsed.sub === undefined) { - return /^\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*(?:yarn|pnpm)\s*$/.test(input.command ?? ''); + if (head.head === 'yarn' || head.head === 'pnpm') { + if (head.sub === 'install' || head.sub === 'add' || head.sub === 'i') { + return true; + } + if (head.sub === undefined) { + // Bare `yarn` / `pnpm` is implicit install in the project root. + return true; + } } } return false; @@ -275,7 +587,7 @@ export const npmInstallFilter: IToolResultFilter = { const lines = text.split('\n'); const dropPatterns: RegExp[] = [ /^npm warn deprecated /i, - /^\s*\[#+>?\s*\] /, // progress bars + /^\s*\[#+>?\s*\] /, /^npm http /i, /^npm timing /i, /^npm sill /i, @@ -292,15 +604,68 @@ export const npmInstallFilter: IToolResultFilter = { out.push(line); } const result = out.join('\n'); - return { - text: result, - compressed: result.length < text.length, - }; + return { text: result, compressed: result.length < text.length }; + }, +}; + +// --------------------------------------------------------------------------- +// Misc utilities +// --------------------------------------------------------------------------- + +/** Sort + dedupe `env` / `printenv` output. */ +export const envFilter: IToolResultFilter = { + id: 'terminal.env', + toolIds: [TerminalToolId.RunInTerminal], + matches(_toolId, input) { + if (!isTerminalInput(input)) { + return false; + } + const parsed = parseCommand(input.command); + if (!parsed) { + return false; + } + // We don't go through makeMatcher() here because `env` is also a + // wrapper and gets stripped during parsing — only fire when there's + // nothing else (i.e. `env` is itself the program). + for (const seg of parsed.segments) { + const head = segmentHead(seg); + if (head?.head === 'printenv') { + return true; + } + // After wrapper-stripping, bare `env` survives only when there was + // no inner program (i.e. the user invoked `env` with no args). + if (head === undefined && seg.wrappers.length > 0 && seg.wrappers[seg.wrappers.length - 1] === 'env' && seg.tokens.length === 0) { + return true; + } + } + return false; + }, + apply(text): IToolResultFilterOutput { + const lines = text.split('\n').filter(l => l.trim() !== ''); + const unique = Array.from(new Set(lines)).sort(); + const result = unique.join('\n'); + return { text: result, compressed: result.length < text.length }; }, }; export function registerTerminalCompressors(compressor: IToolResultCompressor): void { + // VCS compressor.registerFilter(gitDiffFilter); + compressor.registerFilter(gitLogFilter); + compressor.registerFilter(gitStatusFilter); + // File ops compressor.registerFilter(lsFilter); + compressor.registerFilter(findFilter); + compressor.registerFilter(grepFilter); + compressor.registerFilter(treeFilter); + // Test / build / lint + compressor.registerFilter(testRunnerFilter); + compressor.registerFilter(buildToolFilter); + compressor.registerFilter(linterFilter); + // Package managers compressor.registerFilter(npmInstallFilter); + // Misc + compressor.registerFilter(envFilter); + + compressor.registerCache(new TerminalOutputCache()); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 46d538a42f7bb..f64d76b8b6b30 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -25,7 +25,6 @@ export const enum TerminalChatAgentToolsSettingId { AgentSandboxAdvancedRuntime = 'chat.agent.sandbox.advanced.runtime', PreventShellHistory = 'chat.tools.terminal.preventShellHistory', EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel', - IdleSilenceTimeoutMs = 'chat.tools.terminal.idleSilenceTimeoutMs', DetachBackgroundProcesses = 'chat.tools.terminal.detachBackgroundProcesses', BackgroundNotifications = 'chat.tools.terminal.backgroundNotifications', IdlePollInterval = 'chat.tools.terminal.idlePollInterval', @@ -721,17 +720,6 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('tokenize', () => { + test('splits on whitespace', () => { + deepStrictEqual(tokenize('git diff HEAD~1 src/foo.ts'), ['git', 'diff', 'HEAD~1', 'src/foo.ts']); + }); + test('respects single quotes', () => { + deepStrictEqual(tokenize(`grep 'a b c' file`), ['grep', 'a b c', 'file']); + }); + test('respects double quotes with escapes', () => { + deepStrictEqual(tokenize(`echo "a \\"b\\" c"`), ['echo', 'a "b" c']); + }); + test('respects backslash escapes outside quotes', () => { + deepStrictEqual(tokenize('cat foo\\ bar.txt'), ['cat', 'foo bar.txt']); + }); + test('handles unterminated quotes gracefully', () => { + deepStrictEqual(tokenize(`echo "unterminated`), ['echo', 'unterminated']); + }); + test('preserves empty quoted strings', () => { + deepStrictEqual(tokenize(`grep "" file`), ['grep', '', 'file']); + }); + }); + + suite('parseCommand composition', () => { + test('returns undefined for empty input', () => { + strictEqual(parseCommand(undefined), undefined); + strictEqual(parseCommand(''), undefined); + strictEqual(parseCommand(' '), undefined); + }); + + test('splits pipelines', () => { + const parsed = parseCommand('git diff | cat'); + strictEqual(parsed?.segments.length, 2); + strictEqual(parsed?.segments[0].trailingSeparator, '|'); + deepStrictEqual(parsed?.segments[0].tokens, ['git', 'diff']); + deepStrictEqual(parsed?.segments[1].tokens, ['cat']); + }); + + test('splits on && and ||', () => { + const parsed = parseCommand('npm install && npm test || echo fail'); + strictEqual(parsed?.segments.length, 3); + strictEqual(parsed?.segments[0].trailingSeparator, '&&'); + strictEqual(parsed?.segments[1].trailingSeparator, '||'); + }); + + test('does not split on separators inside quotes', () => { + const parsed = parseCommand(`echo "a;b" | wc -l`); + strictEqual(parsed?.segments.length, 2); + deepStrictEqual(parsed?.segments[0].tokens, ['echo', 'a;b']); + }); + + test('strips leading env assignments', () => { + const parsed = parseCommand('CI=1 NODE_ENV=test npm install'); + strictEqual(parsed?.segments.length, 1); + deepStrictEqual(parsed?.segments[0].envPrefixes, ['CI=1', 'NODE_ENV=test']); + deepStrictEqual(parsed?.segments[0].tokens, ['npm', 'install']); + }); + + test('strips sudo wrapper', () => { + const parsed = parseCommand('sudo apt-get install -y vim'); + deepStrictEqual(parsed?.segments[0].wrappers, ['sudo']); + deepStrictEqual(parsed?.segments[0].tokens, ['apt-get', 'install', '-y', 'vim']); + }); + + test('strips time wrapper', () => { + const parsed = parseCommand('time cargo build'); + deepStrictEqual(parsed?.segments[0].wrappers, ['time']); + deepStrictEqual(parsed?.segments[0].tokens, ['cargo', 'build']); + }); + + test('strips timeout wrapper with numeric arg', () => { + const parsed = parseCommand('timeout 30 npm test'); + deepStrictEqual(parsed?.segments[0].wrappers, ['timeout']); + deepStrictEqual(parsed?.segments[0].tokens, ['npm', 'test']); + }); + + test('strips env wrapper with inner env vars', () => { + const parsed = parseCommand('env -i PATH=/usr/bin make all'); + deepStrictEqual(parsed?.segments[0].wrappers, ['env']); + deepStrictEqual(parsed?.segments[0].envPrefixes, ['PATH=/usr/bin']); + deepStrictEqual(parsed?.segments[0].tokens, ['make', 'all']); + }); + + test('strips combined env + wrapper', () => { + const parsed = parseCommand('FOO=bar sudo time git diff'); + deepStrictEqual(parsed?.segments[0].envPrefixes, ['FOO=bar']); + deepStrictEqual(parsed?.segments[0].wrappers, ['sudo', 'time']); + deepStrictEqual(parsed?.segments[0].tokens, ['git', 'diff']); + }); + }); + + suite('segmentHead', () => { + test('handles plain command', () => { + const seg = parseCommand('git diff HEAD~1')!.segments[0]; + deepStrictEqual(segmentHead(seg), { head: 'git', sub: 'diff' }); + }); + + test('skips long flags before subcommand', () => { + const seg = parseCommand('git --no-pager diff src/foo.ts')!.segments[0]; + deepStrictEqual(segmentHead(seg), { head: 'git', sub: 'diff' }); + }); + + test('does not skip short flags', () => { + const seg = parseCommand('git -C /tmp/repo diff')!.segments[0]; + deepStrictEqual(segmentHead(seg), { head: 'git', sub: '-C' }); + }); + }); + + suite('parseCommandHead', () => { + test('returns undefined for empty input', () => { + strictEqual(parseCommandHead(undefined), undefined); + strictEqual(parseCommandHead(''), undefined); + }); + test('parses simple commands', () => { + deepStrictEqual(parseCommandHead('git diff HEAD~5'), { head: 'git', sub: 'diff' }); + }); + test('uses first segment of pipeline', () => { + deepStrictEqual(parseCommandHead('git diff | cat'), { head: 'git', sub: 'diff' }); + }); + test('strips env / wrappers', () => { + deepStrictEqual(parseCommandHead('CI=1 sudo time git status'), { head: 'git', sub: 'status' }); + }); + }); + + suite('segmentHasFlag', () => { + test('detects bundled short flags', () => { + const seg = parseCommand('ls -la')!.segments[0]; + ok(segmentHasFlag(seg, ['l'])); + ok(segmentHasFlag(seg, ['a'])); + ok(!segmentHasFlag(seg, ['r'])); + }); + test('detects long flags', () => { + const seg = parseCommand('git --no-pager log')!.segments[0]; + ok(segmentHasFlag(seg, ['no-pager'])); + ok(!segmentHasFlag(seg, ['pager'])); + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCache.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCache.test.ts new file mode 100644 index 0000000000000..d9a213b0dfefe --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCache.test.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ok, strictEqual } from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TerminalToolId } from '../../browser/tools/toolIds.js'; +import { TerminalOutputCache } from '../../browser/tools/terminalOutputCache.js'; + +function input(command: string, cwd = '/repo') { + return { command, cwd }; +} + +suite('TerminalOutputCache', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('lookup returns recorded value for a cacheable command', () => { + const cache = new TerminalOutputCache(() => 1000); + cache.record(TerminalToolId.RunInTerminal, input('git status'), 'clean'); + const hit = cache.lookup(TerminalToolId.RunInTerminal, input('git status')); + strictEqual(hit?.text, 'clean'); + }); + + test('keys include cwd so same command in different cwd does not collide', () => { + const cache = new TerminalOutputCache(() => 1000); + cache.record(TerminalToolId.RunInTerminal, input('git status', '/a'), 'A'); + cache.record(TerminalToolId.RunInTerminal, input('git status', '/b'), 'B'); + strictEqual(cache.lookup(TerminalToolId.RunInTerminal, input('git status', '/a'))?.text, 'A'); + strictEqual(cache.lookup(TerminalToolId.RunInTerminal, input('git status', '/b'))?.text, 'B'); + }); + + test('Fast class expires after 30s', () => { + let now = 0; + const cache = new TerminalOutputCache(() => now); + now = 1000; + cache.record(TerminalToolId.RunInTerminal, input('git status'), 'A'); + now = 1000 + 31_000; + strictEqual(cache.lookup(TerminalToolId.RunInTerminal, input('git status')), undefined); + }); + + test('Slow class survives past the Fast TTL', () => { + let now = 0; + const cache = new TerminalOutputCache(() => now); + now = 1000; + cache.record(TerminalToolId.RunInTerminal, input('git log --oneline -n 5'), 'A'); + now = 1000 + 60_000; + ok(cache.lookup(TerminalToolId.RunInTerminal, input('git log --oneline -n 5'))); + }); + + test('mutation invalidates same-program reads', () => { + const cache = new TerminalOutputCache(() => 1000); + cache.record(TerminalToolId.RunInTerminal, input('git status'), 'clean'); + // git commit is a mutation that invalidates other git entries in the same cwd. + cache.observe(TerminalToolId.RunInTerminal, input('git commit -m hi')); + strictEqual(cache.lookup(TerminalToolId.RunInTerminal, input('git status')), undefined); + }); + + test('mutation in different cwd does not invalidate', () => { + const cache = new TerminalOutputCache(() => 1000); + cache.record(TerminalToolId.RunInTerminal, input('git status', '/a'), 'A'); + cache.observe(TerminalToolId.RunInTerminal, input('git commit -m hi', '/b')); + ok(cache.lookup(TerminalToolId.RunInTerminal, input('git status', '/a'))); + }); + + test('non-cacheable command does not populate cache', () => { + const cache = new TerminalOutputCache(() => 1000); + cache.record(TerminalToolId.RunInTerminal, input('rm -rf node_modules'), 'gone'); + strictEqual(cache.lookup(TerminalToolId.RunInTerminal, input('rm -rf node_modules')), undefined); + }); + + test('invalidateCwd clears entries for that cwd only', () => { + const cache = new TerminalOutputCache(() => 1000); + cache.record(TerminalToolId.RunInTerminal, input('git status', '/a'), 'A'); + cache.record(TerminalToolId.RunInTerminal, input('git status', '/b'), 'B'); + cache.invalidateCwd('/a'); + strictEqual(cache.lookup(TerminalToolId.RunInTerminal, input('git status', '/a')), undefined); + ok(cache.lookup(TerminalToolId.RunInTerminal, input('git status', '/b'))); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCompressor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCompressor.test.ts index 779a02f934af8..f458a78b75373 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCompressor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalOutputCompressor.test.ts @@ -5,7 +5,8 @@ import { deepStrictEqual, ok, strictEqual } from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { gitDiffFilter, lsFilter, npmInstallFilter, parseCommandHead } from '../../browser/tools/terminalOutputCompressor.js'; +import { gitDiffFilter, gitLogFilter, gitStatusFilter, lsFilter, npmInstallFilter, parseCommandHead, testRunnerFilter, buildToolFilter, linterFilter, envFilter, findFilter, grepFilter, treeFilter } from '../../browser/tools/terminalOutputCompressor.js'; +import { isProtectedFromCompression } from '../../../../chat/common/tools/toolResultCompressor.js'; suite('parseCommandHead', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -191,11 +192,6 @@ suite('npmInstallFilter', () => { ok(!npmInstallFilter.matches('run_in_terminal', { command: 'npm test' })); }); - test('does not match flag-only yarn commands', () => { - ok(!npmInstallFilter.matches('run_in_terminal', { command: 'yarn --version' })); - ok(!npmInstallFilter.matches('run_in_terminal', { command: 'FOO=1 yarn --help' })); - }); - test('drops audit and funding noise', () => { const text = [ 'added 250 packages in 12s', @@ -214,3 +210,157 @@ suite('npmInstallFilter', () => { strictEqual(out.compressed, true); }); }); + +suite('gitDiffFilter - regression', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('does not match `git difftool` (only diff/show)', () => { + ok(!gitDiffFilter.matches('run_in_terminal', { command: 'git difftool HEAD~1' })); + ok(!gitDiffFilter.matches('run_in_terminal', { command: 'git difftool --tool=vscode' })); + }); + + test('does not match `git diff-tree` or `git diff-files`', () => { + ok(!gitDiffFilter.matches('run_in_terminal', { command: 'git diff-tree HEAD' })); + ok(!gitDiffFilter.matches('run_in_terminal', { command: 'git diff-files' })); + }); + + test('matches git show', () => { + ok(gitDiffFilter.matches('run_in_terminal', { command: 'git show HEAD' })); + }); + + test('matches inside a pipeline', () => { + ok(gitDiffFilter.matches('run_in_terminal', { command: 'git diff | cat' })); + }); + + test('matches when wrapped in sudo / time', () => { + ok(gitDiffFilter.matches('run_in_terminal', { command: 'sudo time git diff' })); + }); +}); + +suite('gitLogFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches git log', () => { + ok(gitLogFilter.matches('run_in_terminal', { command: 'git log' })); + ok(gitLogFilter.matches('run_in_terminal', { command: 'git --no-pager log --oneline -n 20' })); + }); + test('does not match git logout / unrelated', () => { + ok(!gitLogFilter.matches('run_in_terminal', { command: 'git status' })); + }); +}); + +suite('gitStatusFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches git status', () => { + ok(gitStatusFilter.matches('run_in_terminal', { command: 'git status' })); + ok(gitStatusFilter.matches('run_in_terminal', { command: 'git status -s' })); + }); + test('does not match git stash', () => { + ok(!gitStatusFilter.matches('run_in_terminal', { command: 'git stash list' })); + }); +}); + +suite('find / grep / tree filters', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('findFilter caps output and adds summary', () => { + const lines = Array.from({ length: 500 }, (_, i) => `./file${i}.ts`).join('\n'); + const out = findFilter.apply(lines, { command: 'find . -name "*.ts"' }); + strictEqual(out.compressed, true); + ok(out.text.includes('omitted')); + // First file should still appear. + ok(out.text.includes('./file0.ts')); + }); + + test('grepFilter caps output', () => { + const lines = Array.from({ length: 500 }, (_, i) => `file${i}.ts:1:match`).join('\n'); + const out = grepFilter.apply(lines, { command: 'grep -rn match .' }); + strictEqual(out.compressed, true); + ok(out.text.includes('omitted')); + }); + + test('treeFilter caps output', () => { + const lines = Array.from({ length: 500 }, (_, i) => `├── file${i}.ts`).join('\n'); + const out = treeFilter.apply(lines, { command: 'tree' }); + strictEqual(out.compressed, true); + }); +}); + +suite('testRunnerFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches common test runners', () => { + ok(testRunnerFilter.matches('run_in_terminal', { command: 'npm test' })); + ok(testRunnerFilter.matches('run_in_terminal', { command: 'pytest' })); + ok(testRunnerFilter.matches('run_in_terminal', { command: 'cargo test' })); + ok(testRunnerFilter.matches('run_in_terminal', { command: 'go test ./...' })); + ok(testRunnerFilter.matches('run_in_terminal', { command: 'npx vitest run' })); + }); +}); + +suite('buildToolFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches build commands', () => { + ok(buildToolFilter.matches('run_in_terminal', { command: 'cargo build' })); + ok(buildToolFilter.matches('run_in_terminal', { command: 'cargo check' })); + ok(buildToolFilter.matches('run_in_terminal', { command: 'go build ./...' })); + ok(buildToolFilter.matches('run_in_terminal', { command: 'make' })); + ok(buildToolFilter.matches('run_in_terminal', { command: 'tsc -p .' })); + }); +}); + +suite('linterFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches linters', () => { + ok(linterFilter.matches('run_in_terminal', { command: 'eslint src' })); + ok(linterFilter.matches('run_in_terminal', { command: 'ruff check .' })); + ok(linterFilter.matches('run_in_terminal', { command: 'cargo clippy' })); + }); +}); + +suite('envFilter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches env / printenv with no args', () => { + ok(envFilter.matches('run_in_terminal', { command: 'env' })); + ok(envFilter.matches('run_in_terminal', { command: 'printenv' })); + }); + + test('sorts and dedupes lines', () => { + const text = ['ZSH=/bin/zsh', 'PATH=/usr/bin', 'PATH=/usr/bin', 'HOME=/home/u'].join('\n'); + const out = envFilter.apply(text, { command: 'env' }); + strictEqual(out.compressed, true); + // Sorted alphabetically. + const lines = out.text.split('\n'); + ok(lines.indexOf('HOME=/home/u') < lines.indexOf('PATH=/usr/bin')); + ok(lines.indexOf('PATH=/usr/bin') < lines.indexOf('ZSH=/bin/zsh')); + // Deduped. + strictEqual(lines.filter(l => l === 'PATH=/usr/bin').length, 1); + }); +}); + +suite('isProtectedFromCompression', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('protects JSON object output', () => { + ok(isProtectedFromCompression('{"a":1,"b":[1,2,3]}')); + }); + test('protects JSON array output', () => { + ok(isProtectedFromCompression('[1, 2, 3, {"k":"v"}]')); + }); + test('protects YAML headers', () => { + ok(isProtectedFromCompression('---\nfoo: bar\nbaz: 1\n')); + }); + test('protects TOML headers', () => { + ok(isProtectedFromCompression('[package]\nname = "x"\n')); + }); + test('does not protect plain text', () => { + ok(!isProtectedFromCompression('hello world\nsome output\n')); + }); + test('does not protect malformed JSON', () => { + ok(!isProtectedFromCompression('{ this is { not json }')); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 6ec1ce4758441..29d6f5716f98e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -2427,35 +2427,6 @@ suite('RunInTerminalTool', () => { }); }); - suite('input-needed steering text', () => { - function buildSteeringText(hungHint: 'none' | 'timeout' | 'idleSilence'): string { - const sessionResource = LocalChatSessionUri.forSession('input-needed-steering-session'); - // eslint-disable-next-line @typescript-eslint/naming-convention - return (runInTerminalTool as unknown as { _buildInputNeededSteeringText(s: URI, t: string, h: 'none' | 'timeout' | 'idleSilence'): string }) - ._buildInputNeededSteeringText(sessionResource, 'test-term-id', hungHint); - } - - test('none mode does not mention timeout, idle silence, or kill_terminal', () => { - const text = buildSteeringText('none'); - ok(!text.toLowerCase().includes('timeout'), 'Expected no mention of timeout in the input-needed (none) hint'); - ok(!text.toLowerCase().includes('no output'), 'Expected no mention of idle silence in the input-needed (none) hint'); - ok(!text.includes(TerminalToolId.KillTerminal), 'Expected kill_terminal not to be advertised in the input-needed (none) hint'); - }); - - test('timeout mode advertises kill_terminal and mentions timeout', () => { - const text = buildSteeringText('timeout'); - ok(text.toLowerCase().includes('timeout'), 'Expected timeout hint to mention "timeout"'); - ok(text.includes(TerminalToolId.KillTerminal), 'Expected timeout hint to advertise kill_terminal'); - }); - - test('idleSilence mode advertises kill_terminal without saying "timeout"', () => { - const text = buildSteeringText('idleSilence'); - ok(!text.toLowerCase().includes('timeout'), 'Idle-silence hint must not refer to a timeout'); - ok(text.toLowerCase().includes('no output'), 'Expected idle-silence hint to describe the no-output condition'); - ok(text.includes(TerminalToolId.KillTerminal), 'Expected idle-silence hint to advertise kill_terminal'); - }); - }); - suite('unique rules deduplication', () => { test('should properly deduplicate rules with same sourceText in auto-approve info', async () => { setAutoApprove({ @@ -2834,6 +2805,7 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { instantiationService.stub(IToolResultCompressor, { _serviceBrand: undefined, registerFilter: () => { }, + registerCache: () => { }, maybeCompress: () => undefined, }); }); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index b0c2c0682a442..256a97a204f68 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -172,6 +172,7 @@ export interface IChatEntitlementService { readonly onDidChangeQuotaExceeded: Event; readonly onDidChangeQuotaRemaining: Event; + readonly onDidChangeUsageBasedBilling: Event; readonly quotas: IQuotas; @@ -472,6 +473,9 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme private readonly _onDidChangeQuotaRemaining = this._register(new Emitter()); readonly onDidChangeQuotaRemaining = this._onDidChangeQuotaRemaining.event; + private readonly _onDidChangeUsageBasedBilling = this._register(new Emitter()); + readonly onDidChangeUsageBasedBilling = this._onDidChangeUsageBasedBilling.event; + private _quotas: IQuotas; get quotas() { return this._quotas; } @@ -552,6 +556,10 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this._onDidChangeQuotaRemaining.fire(); } + if (oldQuota.usageBasedBilling !== quotas.usageBasedBilling) { + this._onDidChangeUsageBasedBilling.fire(); + } + // Track additional spend configuration changes (only when both values come from server snapshots) if (oldQuota.additionalUsageEnabled !== undefined && quotas.additionalUsageEnabled !== undefined && oldQuota.additionalUsageEnabled !== quotas.additionalUsageEnabled) { this.telemetryService.publicLog2('chatAdditionalSpendConfiguration', { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index fb7ba1e97eabf..2478f5ffcea4f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -174,7 +174,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override readonly isSessionsWindow = false; }()); reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; override getCustomAgentTargetForSessionType() { return Target.Undefined; } override requiresCustomModelsForSessionType() { return false; } override getOptionGroupsForSessionType() { return []; } }()); - reg.defineInstance(IChatEntitlementService, new class extends mock() { override readonly quotas = {}; override readonly onDidChangeQuotaRemaining = Event.None; }()); + reg.defineInstance(IChatEntitlementService, new class extends mock() { override readonly quotas = {}; override readonly onDidChangeQuotaRemaining = Event.None; override readonly onDidChangeUsageBasedBilling = Event.None; }()); reg.defineInstance(IChatModeService, new MockChatModeService()); reg.defineInstance(ILanguageModelsService, new class extends mock() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } override getVendors() { return []; } override hasResolvedVendor() { return false; } }()); reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override onDidChangeTools = Event.None; override onDidPrepareToolCallBecomeUnresponsive = Event.None; override getTools() { return []; } }()); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index 86b3e846cedd1..6220ef64aa35f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -225,6 +225,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override readonly onDidChangeAnonymous = Event.None; override readonly quotas = {}; override readonly onDidChangeQuotaRemaining = Event.None; + override readonly onDidChangeUsageBasedBilling = Event.None; }()); reg.defineInstance(IChatModeService, new MockChatModeService()); reg.defineInstance(IChatSessionsService, new class extends mock() { diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 7b7a88d378d72..9e3a3dfb39a1a 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -796,6 +796,7 @@ export class TestChatEntitlementService implements IChatEntitlementService { readonly onDidChangeQuotaExceeded = Event.None; readonly onDidChangeQuotaRemaining = Event.None; + readonly onDidChangeUsageBasedBilling = Event.None; readonly quotas = {}; update(token: CancellationToken): Promise { diff --git a/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts index ccbbd8773f31b..aa15733eb3299 100644 --- a/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts +++ b/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts @@ -32,6 +32,11 @@ declare module 'vscode' { */ detail: string | undefined; + /** + * Optional tooltip text shown when hovering over the description. + */ + tooltip: string | undefined; + /** * Shows the entry in the chat status. */