diff --git a/package-lock.json b/package-lock.json index 85a5fbe2605b5..eadb4002ba5f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4475,9 +4475,9 @@ "license": "Apache-2.0" }, "node_modules/@zip.js/zip.js": { - "version": "2.8.23", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.23.tgz", - "integrity": "sha512-RB+RLnxPJFPrGvQ9rgO+4JOcsob6lD32OcF0QE0yg24oeW9q8KnTTNlugcDaIveEcCbclobJcZP+fLQ++sH0bw==", + "version": "2.8.21", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.21.tgz", + "integrity": "sha512-fkyzXISE3IMrstDO1AgPkJCx14MYHP/suIGiAovEYEuBjq3mffsuL6aMV7ohOSjW4rXtuACuUfpA3GtITgdtYg==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 3c2022bd29dc3..31c753580b2bc 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -232,7 +232,7 @@ display: block !important; width: 100%; margin-left: 0; - padding-left: 18px; + padding-left: 20px; font-size: 11px; line-height: 14px; opacity: 0.8; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index fb17967129bef..6401df034be28 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -21,11 +21,15 @@ import { FileEditorInput } from '../../../files/browser/editors/fileEditorInput. import { NotebookEditorInput } from '../../../notebook/common/notebookEditorInput.js'; import { IChatContextPickService, IChatContextValueItem, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPicker } from '../attachments/chatContextPickService.js'; import { IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IImageVariableEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; -import { IChatWidget } from '../chat.js'; +import { ILanguageModelToolsService, isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; import { imageToHash, isImage } from '../widget/input/editor/chatPasteProviders.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; import { ChatInstructionsPickerPick } from '../promptSyntax/attachInstructionsAction.js'; +import { createDebugEventsAttachment } from '../chatDebug/chatDebugAttachment.js'; +import { IChatDebugService } from '../../common/chatDebugService.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; @@ -38,9 +42,29 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo constructor( @IInstantiationService instantiationService: IInstantiationService, @IChatContextPickService contextPickService: IChatContextPickService, + @IChatDebugService chatDebugService: IChatDebugService, + @IContextKeyService contextKeyService: IContextKeyService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @IChatWidgetService chatWidgetService: IChatWidgetService, ) { super(); + // Bind at the global context key service level so the tools service can evaluate it. + // Widget-scoped keys are not reliably visible to singleton services during async request processing. + const hasAttachedDebugDataKey = ChatContextKeys.chatSessionHasAttachedDebugData.bindTo(contextKeyService); + this._store.add(chatWidgetService.onDidChangeFocusedSession(() => { + const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; + hasAttachedDebugDataKey.set(!!sessionResource && chatDebugService.hasAttachedDebugData(sessionResource)); + languageModelToolsService.flushToolUpdates(); + })); + this._store.add(chatDebugService.onDidAttachDebugData(sessionResource => { + const focusedSession = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; + if (focusedSession && focusedSession.toString() === sessionResource.toString()) { + hasAttachedDebugDataKey.set(true); + languageModelToolsService.flushToolUpdates(); + } + })); + // ############################################################################################### // // Default context picks/values which are "native" to chat. This is NOT the complete list @@ -54,6 +78,7 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(OpenEditorContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ClipboardImageContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ScreenshotContextValuePick))); + this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(DebugEventsSnapshotContextValuePick))); } } @@ -285,3 +310,28 @@ class ScreenshotContextValuePick implements IChatContextValueItem { return blob && convertBufferToScreenshotVariable(blob); } } + +class DebugEventsSnapshotContextValuePick implements IChatContextValueItem { + + readonly type = 'valuePick'; + readonly icon = Codicon.output; + readonly label = localize('chatContext.debugEventsSnapshot', 'Debug Events Snapshot'); + readonly ordinal = -600; + + constructor( + @IChatDebugService private readonly _chatDebugService: IChatDebugService, + ) { } + + isEnabled(widget: IChatWidget): boolean { + const sessionResource = widget.viewModel?.sessionResource; + return !!sessionResource && this._chatDebugService.getEvents(sessionResource).length > 0; + } + + async asAttachment(widget: IChatWidget): Promise { + const sessionResource = widget.viewModel?.sessionResource; + if (!sessionResource) { + return undefined; + } + return createDebugEventsAttachment(sessionResource, this._chatDebugService); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index 6606748d653f4..0ad56d5966924 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -87,7 +87,7 @@ export class ChatSteerWithMessageAction extends Action2 { id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), tooltip: localize('chat.steerWithMessage.tooltip', "Send this message at the next opportunity, signaling the current request to yield"), - icon: Codicon.arrowRight, + icon: Codicon.arrowUp, f1: false, category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( @@ -271,7 +271,7 @@ export function registerChatQueueActions(): void { order: 1, }); MenuRegistry.appendMenuItem(MenuId.ChatExecuteQueue, { - command: { id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), icon: Codicon.arrowRight }, + command: { id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), icon: Codicon.arrowUp }, group: 'navigation', order: 2, }); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts new file mode 100644 index 0000000000000..2d847f283a9e6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { URI } from '../../../../../base/common/uri.js'; +import * as nls from '../../../../../nls.js'; +import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +/** + * Descriptions of each debug event kind for the model. Adding a new event kind + * to {@link IChatDebugEvent} without adding an entry here will cause a compile error. + */ +const debugEventKindDescriptions: Record = { + generic: '- generic (category: "discovery"): File discovery for instructions, skills, agents, hooks. Resolving returns a fileList with full file paths, load status, skip reasons, and source folders. Always resolve these for questions about customization files.\n' + + '- generic (other): Miscellaneous logs. Resolving returns additional text details.', + toolCall: '- toolCall: A tool invocation. Resolving returns tool name, input, output, status, and duration.', + modelTurn: '- modelTurn: An LLM round-trip. Resolving returns model name, token usage, timing, errors, and prompt sections.', + subagentInvocation: '- subagentInvocation: A sub-agent spawn. Resolving returns agent name, status, duration, and counts.', + userMessage: '- userMessage: The full prompt sent to the model. Resolving returns the complete message and all prompt sections (system prompt, instructions, context). Essential for understanding what the model received.', + agentResponse: '- agentResponse: The model\'s response. Resolving returns the full response text and sections.', +}; + +function formatDebugEventsForContext(events: readonly IChatDebugEvent[]): string { + const lines: string[] = []; + for (const event of events) { + const ts = event.created.toISOString(); + const id = event.id ? ` [id=${event.id}]` : ''; + switch (event.kind) { + case 'generic': + lines.push(`[${ts}]${id} ${event.level >= 3 ? 'ERROR' : event.level >= 2 ? 'WARN' : 'INFO'}: ${event.name}${event.details ? ' - ' + event.details : ''}${event.category ? ' (category: ' + event.category + ')' : ''}`); + break; + case 'toolCall': + lines.push(`[${ts}]${id} TOOL_CALL: ${event.toolName}${event.result ? ' result=' + event.result : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'modelTurn': + lines.push(`[${ts}]${id} MODEL_TURN: ${event.requestName ?? 'unknown'}${event.model ? ' model=' + event.model : ''}${event.inputTokens !== undefined ? ' tokens(in=' + event.inputTokens + ',out=' + (event.outputTokens ?? '?') + ')' : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'subagentInvocation': + lines.push(`[${ts}]${id} SUBAGENT: ${event.agentName}${event.status ? ' status=' + event.status : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'userMessage': + lines.push(`[${ts}]${id} USER_MESSAGE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); + break; + case 'agentResponse': + lines.push(`[${ts}]${id} AGENT_RESPONSE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); + break; + default: { + const _: never = event; + void _; + break; + } + } + } + return lines.join('\n'); +} + +/** + * Creates a debug events attachment for a chat session. + * This can be used to attach debug logs to a chat request. + */ +export async function createDebugEventsAttachment( + sessionResource: URI, + chatDebugService: IChatDebugService +): Promise { + chatDebugService.markDebugDataAttached(sessionResource); + if (!chatDebugService.hasInvokedProviders(sessionResource)) { + await chatDebugService.invokeProviders(sessionResource); + } + const events = chatDebugService.getEvents(sessionResource); + const summary = events.length > 0 + ? formatDebugEventsForContext(events) + : nls.localize('debugEventsSnapshot.noEvents', "No debug events found for this conversation."); + + return { + id: 'chatDebugEvents', + name: nls.localize('debugEventsSnapshot.contextName', "Debug Events Snapshot"), + icon: Codicon.output, + kind: 'debugEvents', + snapshotTime: Date.now(), + sessionResource, + value: summary, + modelDescription: 'These are the debug event logs from the current chat conversation. Analyze them to help answer the user\'s troubleshooting question.\n' + + '\n' + + 'CRITICAL INSTRUCTION: You MUST call the resolveDebugEventDetails tool on relevant events BEFORE answering. The log lines below are only summaries — they do NOT contain the actual data (file paths, prompt content, tool I/O, etc.). The real information is only available by resolving events. Never answer based solely on the summary lines. Always resolve first, then answer.\n' + + '\n' + + 'Call resolveDebugEventDetails in parallel on all events that could be relevant to the user\'s question. When in doubt, resolve more events rather than fewer.\n' + + '\n' + + 'IMPORTANT: Do NOT mention event IDs, tool resolution steps, or internal debug mechanics in your response. The user does not know about debug events or event IDs. Present your findings directly and naturally, as if you simply know the answer. Never say things like "I need to resolve events" or show event IDs.\n' + + '\n' + + 'Event types and what resolving them returns:\n' + + Object.values(debugEventKindDescriptions).join('\n'), + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index ec403b30f956d..8276d701b2a05 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -250,7 +250,9 @@ export class ChatDebugEditor extends EditorPane { } this.chatDebugService.activeSessionResource = sessionResource; - this.chatDebugService.invokeProviders(sessionResource); + if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { + this.chatDebugService.invokeProviders(sessionResource); + } this.trackSessionModelChanges(sessionResource); this.overviewView?.setSession(sessionResource); @@ -327,7 +329,9 @@ export class ChatDebugEditor extends EditorPane { this.savedSessionResource = undefined; if (sessionResource) { this.chatDebugService.activeSessionResource = sessionResource; - this.chatDebugService.invokeProviders(sessionResource); + if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { + this.chatDebugService.invokeProviders(sessionResource); + } } else { this.showView(ViewState.Home); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index a9bbec0b6d53f..8cbf8c1a90d89 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -10,7 +10,7 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -29,6 +29,7 @@ import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem, LogsViewMode } f import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; import { IChatWidgetService } from '../chat.js'; +import { createDebugEventsAttachment } from './chatDebugAttachment.js'; const $ = DOM.$; @@ -131,9 +132,8 @@ export class ChatDebugLogsView extends Disposable { } const widget = await this.chatWidgetService.openSession(this.currentSessionResource); if (widget) { - const value = '/troubleshoot '; - widget.inputEditor.setValue(value); - widget.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 }); + const attachment = await createDebugEventsAttachment(this.currentSessionResource, this.chatDebugService); + widget.attachmentModel.addContext(attachment); widget.focusInput(); } })); @@ -389,12 +389,23 @@ export class ChatDebugLogsView extends Disposable { private loadEvents(): void { this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; - this.eventListener.value = this.chatDebugService.onDidAddEvent(e => { + + const addEventDisposable = this.chatDebugService.onDidAddEvent(e => { if (!this.currentSessionResource || e.sessionResource.toString() === this.currentSessionResource.toString()) { this.events.push(e); this.refreshList(); } }); + + // Reload events when provider events are cleared (before re-invoking providers) + const clearEventsDisposable = this.chatDebugService.onDidClearProviderEvents(sessionResource => { + if (!this.currentSessionResource || sessionResource.toString() === this.currentSessionResource.toString()) { + this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; + this.refreshList(); + } + }); + + this.eventListener.value = combinedDisposable(addEventDisposable, clearEventsDisposable); this.updateBreadcrumb(); this.trackSessionState(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 97e45f0280935..4d183f09c9ec0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -15,11 +15,9 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatAgentService } from '../common/participants/chatAgents.js'; -import { IChatDebugEvent, IChatDebugService } from '../common/chatDebugService.js'; import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; -import { ChatRequestQueueKind, IChatService } from '../common/chatService/chatService.js'; +import { IChatService } from '../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; -import { IChatRequestVariableEntry } from '../common/attachments/chatVariableEntries.js'; import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; import { ChatSubmitAction, OpenModePickerAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; import { ManagePluginsAction } from './actions/chatPluginActions.js'; @@ -34,12 +32,8 @@ import { globalAutoApproveDescription, } from './tools/languageModelToolsService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; -import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { Target } from '../common/promptSyntax/promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IChatWidgetService } from './chat.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -52,25 +46,14 @@ export class ChatSlashCommandsContribution extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IAgentSessionsService agentSessionsService: IAgentSessionsService, @IChatService chatService: IChatService, - @IChatDebugService chatDebugService: IChatDebugService, @IConfigurationService configurationService: IConfigurationService, @IDialogService dialogService: IDialogService, @INotificationService notificationService: INotificationService, @IStorageService storageService: IStorageService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, - @IChatWidgetService chatWidgetService: IChatWidgetService, ) { super(); - const troubleshootSessions = new Set(); - const hasTroubleshootDataKey = ChatContextKeys.chatSessionHasTroubleshootData.bindTo(this.contextKeyService); - this._store.add(chatWidgetService.onDidChangeFocusedSession(() => { - const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; - hasTroubleshootDataKey.set(!!sessionResource && troubleshootSessions.has(sessionResource.toString())); - languageModelToolsService.flushToolUpdates(); - })); this._store.add(slashCommandService.registerSlashCommand({ command: 'clear', detail: nls.localize('clear', "Start a new chat and archive the current one"), @@ -137,54 +120,6 @@ export class ChatSlashCommandsContribution extends Disposable { await commandService.executeCommand('github.copilot.debug.showChatLogView'); })); } - this._store.add(slashCommandService.registerSlashCommand({ - command: 'troubleshoot', - detail: nls.localize('troubleshoot', "Troubleshoot with a snapshot of debug events from the conversation so far (run again to refresh)"), - sortText: 'z3_troubleshoot', - executeImmediately: false, - silent: true, - locations: [ChatAgentLocation.Chat], - }, async (prompt, _progress, _history, _location, sessionResource, _token, options) => { - troubleshootSessions.add(sessionResource.toString()); - hasTroubleshootDataKey.set(true); - languageModelToolsService.flushToolUpdates(); - await chatDebugService.invokeProviders(sessionResource); - const events = chatDebugService.getEvents(sessionResource); - const summary = events.length > 0 - ? formatDebugEventsForContext(events) - : nls.localize('troubleshoot.noEvents', "No debug events found for this conversation."); - - const attachedContext: IChatRequestVariableEntry[] = [{ - id: 'chatDebugEvents', - name: nls.localize('troubleshoot.contextName', "Debug Events Snapshot"), - kind: 'debugEvents', - snapshotTime: Date.now(), - sessionResource, - value: summary, - modelDescription: 'These are the debug event logs from the current chat conversation. Analyze them to help answer the user\'s troubleshooting question.\n' - + '\n' - + 'CRITICAL INSTRUCTION: You MUST call the resolveDebugEventDetails tool on relevant events BEFORE answering. The log lines below are only summaries — they do NOT contain the actual data (file paths, prompt content, tool I/O, etc.). The real information is only available by resolving events. Never answer based solely on the summary lines. Always resolve first, then answer.\n' - + '\n' - + 'Call resolveDebugEventDetails in parallel on all events that could be relevant to the user\'s question. When in doubt, resolve more events rather than fewer.\n' - + '\n' - + 'IMPORTANT: Do NOT mention event IDs, tool resolution steps, or internal debug mechanics in your response. The user does not know about debug events or event IDs. Present your findings directly and naturally, as if you simply know the answer. Never say things like "I need to resolve events" or show event IDs.\n' - + '\n' - + 'Event types and what resolving them returns:\n' - + '- generic (category: "discovery"): File discovery for instructions, skills, agents, hooks. Resolving returns a fileList with full file paths, load status, skip reasons, and source folders. Always resolve these for questions about customization files.\n' - + '- userMessage: The full prompt sent to the model. Resolving returns the complete message and all prompt sections (system prompt, instructions, context). Essential for understanding what the model received.\n' - + '- agentResponse: The model\'s response. Resolving returns the full response text and sections.\n' - + '- modelTurn: An LLM round-trip. Resolving returns model name, token usage, timing, errors, and prompt sections.\n' - + '- toolCall: A tool invocation. Resolving returns tool name, input, output, status, and duration.\n' - + '- subagentInvocation: A sub-agent spawn. Resolving returns agent name, status, duration, and counts.\n' - + '- generic (other): Miscellaneous logs. Resolving returns additional text details.', - }]; - - chatService.sendRequest(sessionResource, prompt, { - ...options, - queue: ChatRequestQueueKind.Queued, - attachedContext, - }); - })); this._store.add(slashCommandService.registerSlashCommand({ command: 'agents', detail: nls.localize('agents', "Configure custom agents"), @@ -413,38 +348,3 @@ export class ChatSlashCommandsContribution extends Disposable { })); } } - -function formatDebugEventsForContext(events: readonly IChatDebugEvent[]): string { - const lines: string[] = []; - for (const event of events) { - const ts = event.created.toISOString(); - const id = event.id ? ` [id=${event.id}]` : ''; - switch (event.kind) { - case 'generic': - lines.push(`[${ts}]${id} ${event.level >= 3 ? 'ERROR' : event.level >= 2 ? 'WARN' : 'INFO'}: ${event.name}${event.details ? ' - ' + event.details : ''}${event.category ? ' (category: ' + event.category + ')' : ''}`); - break; - case 'toolCall': - lines.push(`[${ts}]${id} TOOL_CALL: ${event.toolName}${event.result ? ' result=' + event.result : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); - break; - case 'modelTurn': - lines.push(`[${ts}]${id} MODEL_TURN: ${event.requestName ?? 'unknown'}${event.model ? ' model=' + event.model : ''}${event.inputTokens !== undefined ? ' tokens(in=' + event.inputTokens + ',out=' + (event.outputTokens ?? '?') + ')' : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); - break; - case 'subagentInvocation': - lines.push(`[${ts}]${id} SUBAGENT: ${event.agentName}${event.status ? ' status=' + event.status : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); - break; - case 'userMessage': - lines.push(`[${ts}]${id} USER_MESSAGE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); - break; - case 'agentResponse': - lines.push(`[${ts}]${id} AGENT_RESPONSE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); - break; - default: { - const _: never = event; - void _; - break; - } - } - } - return lines.join('\n'); -} - diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 7dcf497e4fec9..fc596d0a73280 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -20,7 +20,7 @@ import { IChatService } from '../common/chatService/chatService.js'; import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; +import { ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { TipEligibilityTracker } from './chatTipEligibilityTracker.js'; import { ChatTipTier, extractCommandIds, ITipBuildContext, ITipDefinition, TIP_CATALOG } from './chatTipCatalog.js'; @@ -146,6 +146,12 @@ export interface IChatTipService { */ hasMultipleTips(): boolean; + /** + * Records usage of a slash command to update tip eligibility for flows where + * the slash command text is transformed before request submission. + */ + recordSlashCommandUsage(command: string): void; + /** * Clears all dismissed tips so they can be shown again. */ @@ -230,6 +236,8 @@ export class ChatTipService extends Disposable implements IChatTipService { if (slashCommandTrackingId) { this._tracker.recordCommandExecuted(slashCommandTrackingId); } + + this._hideShownTipIfNowIneligible(); })); this._thinkingPhrasesEverModified = this._storageService.getBoolean(ChatTipStorageKeys.ThinkingPhrasesEverModified, StorageScope.APPLICATION, false); @@ -264,10 +272,15 @@ export class ChatTipService extends Disposable implements IChatTipService { const slashCommand = (part as ChatRequestSlashCommandPart).slashCommand.command; return this._toSlashCommandTrackingId(slashCommand); } + + if (part.kind === ChatRequestAgentSubcommandPart.Kind) { + const subCommand = (part as ChatRequestAgentSubcommandPart).command.name; + return this._toSlashCommandTrackingId(subCommand); + } } const trimmed = message.text.trimStart(); - const match = /^\/(init|create-(?:instructions|prompt|agent|skill)|fork)(?:\s|$)/.exec(trimmed); + const match = /^(?:@\S+\s+)?\/(init|create-(?:instructions|prompt|agent|skill)|fork)(?:\s|$)/.exec(trimmed); return match ? this._toSlashCommandTrackingId(match[1]) : undefined; } @@ -289,6 +302,16 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + recordSlashCommandUsage(command: string): void { + const trackingId = this._toSlashCommandTrackingId(command); + if (!trackingId) { + return; + } + + this._tracker.recordCommandExecuted(trackingId); + this._hideShownTipIfNowIneligible(); + } + resetSession(): void { this._shownTip = undefined; this._tipRequestId = undefined; @@ -445,6 +468,11 @@ export class ChatTipService extends Disposable implements IChatTipService { } if (!this._isEligible(this._shownTip, contextKeyService)) { + if (this._tracker.isExcluded(this._shownTip)) { + this.hideTip(); + return undefined; + } + const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); if (nextTip) { this._shownTip = nextTip; @@ -453,6 +481,9 @@ export class ChatTipService extends Disposable implements IChatTipService { this._onDidNavigateTip.fire(tip); return tip; } + + this.hideTip(); + return undefined; } return this._createTip(this._shownTip); } @@ -481,6 +512,22 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + private _hideShownTipIfNowIneligible(): void { + if (!this._shownTip || !this._contextKeyService) { + return; + } + + if (this._tipsHiddenForSession) { + return; + } + + if (this._isEligible(this._shownTip, this._contextKeyService)) { + return; + } + + this.hideTip(); + } + private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); // Record the current mode for future eligibility decisions. diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index d9bc284672ed7..27b43d79cc7ca 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -49,6 +49,7 @@ import { HookType } from '../../common/promptSyntax/hookTypes.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 { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; +import { IChatWidgetService } from '../chat.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -129,6 +130,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, @ICommandService private readonly _commandService: ICommandService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, ) { super(); @@ -586,7 +588,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo dto.toolSpecificData = toolInvocation?.toolSpecificData; if (preparedInvocation?.confirmationMessages?.title) { if (!IChatToolInvocation.executionConfirmedOrDenied(toolInvocation) && !autoConfirmed) { - this.playAccessibilitySignal([toolInvocation]); + this.playAccessibilitySignal([toolInvocation], dto.context?.sessionResource); } const userConfirmed = await IChatToolInvocation.awaitConfirmation(toolInvocation, token); if (userConfirmed.type === ToolConfirmKind.Denied) { @@ -943,12 +945,21 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - private playAccessibilitySignal(toolInvocations: ChatToolInvocation[]): void { + private playAccessibilitySignal(toolInvocations: ChatToolInvocation[], chatSessionResource: URI | undefined): void { const autoApproved = this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove); if (autoApproved) { return; } + // Autopilot/auto-approve permission levels auto-approve all tools, skip signal + if (chatSessionResource) { + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) || this._isSessionLiveAutoApproveLevel(chatSessionResource)) { + return; + } + } + // Filter out any tool invocations that have already been confirmed/denied. // This is a defensive check - normally the call site should prevent this, // but tools may be auto-approved through various mechanisms (per-session rules, @@ -1005,6 +1016,17 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return inspected.policyValue === false; } + /** + * Returns true if the session's current (live) permission picker level is auto-approve. + * This checks the widget's current state, not what was stamped on the request, + * so switching to Autopilot mid-session takes effect immediately. + */ + private _isSessionLiveAutoApproveLevel(chatSessionResource: URI): boolean { + const widget = this._chatWidgetService.getWidgetBySessionResource(chatSessionResource) + ?? this._chatWidgetService.lastFocusedWidget; + return !!widget && isAutoApproveLevel(widget.input.currentModeInfo.permissionLevel); + } + private getEligibleForAutoApprovalSpecialCase(toolData: IToolData): string | undefined { if (toolData.id === 'vscode_fetchWebPage_internal') { return 'fetch'; @@ -1057,10 +1079,12 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Auto-Approve All permission level bypasses all tool confirmations, // unless enterprise policy has explicitly disabled global auto-approve. - if (chatSessionResource) { + // Check both the request-stamped level AND the live picker level so that + // switching to Autopilot mid-session takes effect immediately. + if (chatSessionResource && !this._isAutoApprovePolicyRestricted()) { const model = this._chatService.getSession(chatSessionResource); const request = model?.getRequests().at(-1); - if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) && !this._isAutoApprovePolicyRestricted()) { + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) || this._isSessionLiveAutoApproveLevel(chatSessionResource)) { return { type: ToolConfirmKind.ConfirmationNotNeeded, reason: 'auto-approve-all' }; } } @@ -1099,10 +1123,11 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { // Auto-Approve All permission level bypasses all post-execution confirmations, // unless enterprise policy has explicitly disabled global auto-approve. - if (chatSessionResource) { + // Check both the request-stamped level AND the live picker level. + if (chatSessionResource && !this._isAutoApprovePolicyRestricted()) { const model = this._chatService.getSession(chatSessionResource); const request = model?.getRequests().at(-1); - if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) && !this._isAutoApprovePolicyRestricted()) { + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) || this._isSessionLiveAutoApproveLevel(chatSessionResource)) { return { type: ToolConfirmKind.ConfirmationNotNeeded, reason: 'auto-approve-all' }; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index efd1ff467d614..ef9919d8f84a7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -2336,7 +2336,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { // always autoreply in autopilot mode. - const isAutopilot = isResponseVM(context.element) && context.element.model.request?.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot; + const isAutopilot = this._isAutopilotForContext(context); if (!shouldAutoReply && !isAutopilot) { // Roll back the in-progress mark if auto-reply is not enabled. if (stableKey) { @@ -2365,6 +2365,23 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.GlobalAutoApprove)) { + // or the current permission level is already auto-approve/autopilot + if (chatSessionIsEmpty && !this.configurationService.getValue(ChatConfiguration.GlobalAutoApprove) && !isAutoApproveLevel(this._currentPermissionLevel.get())) { this._currentPermissionLevel.set(ChatPermissionLevel.Default, undefined); this.permissionLevelKey.set(ChatPermissionLevel.Default); this.permissionWidget?.refresh(); @@ -1946,11 +1948,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (e.currentSessionResource && newSessionType !== this._currentSessionType) { this._currentSessionType = newSessionType; this.initSelectedModel(); + this.checkModelInSessionPool(); } - // Validate that the current model belongs to the new session's pool - this.checkModelInSessionPool(); - // For contributed sessions with history, pre-select the model // from the last request so the user resumes with the same model. this.preselectModelFromSessionHistory(); @@ -2186,7 +2186,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const pickerOptions: IChatInputPickerOptions = { getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, - hideChevrons: derived(reader => this._stableInputPartWidth.read(reader) < 400), + hideChevrons: derived(reader => this._stableInputPartWidth.read(reader) < CHAT_INPUT_PICKER_COLLAPSE_WIDTH), hoverPosition: { forcePosition: true, hoverPosition: location === ChatWidgetLocation.SidebarRight && !isMaximized ? HoverPosition.LEFT : HoverPosition.RIGHT diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index 51c0ad382e50c..d9beb3af8bb52 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -62,7 +62,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._primaryActionAction = this._register(new Action( 'chat.queuePickerPrimary', isSteerDefault ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"), - ThemeIcon.asClassName(isSteerDefault ? Codicon.arrowRight : Codicon.add), + ThemeIcon.asClassName(isSteerDefault ? Codicon.arrowUp : Codicon.add), !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key), () => this._runDefaultAction() )); @@ -116,7 +116,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._primaryActionAction.label = isSteer ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"); - this._primaryActionAction.class = ThemeIcon.asClassName(isSteer ? Codicon.arrowRight : Codicon.add); + this._primaryActionAction.class = ThemeIcon.asClassName(isSteer ? Codicon.arrowUp : Codicon.add); } private _runDefaultAction(): void { @@ -198,7 +198,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { tooltip: '', enabled: true, checked: isSteerDefault, - icon: Codicon.arrowRight, + icon: Codicon.arrowUp, class: undefined, hover: { content: localize('chat.steerWithMessage.hover', "Send this message at the next opportunity, signaling the current request to yield. The current response will stop and the new message will be sent immediately."), @@ -213,7 +213,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { label: localize('chat.sendImmediately', "Stop and Send"), tooltip: '', enabled: true, - icon: Codicon.arrowUp, + icon: Codicon.arrowRight, class: undefined, hover: { content: localize('chat.sendImmediately.hover', "Cancel the current request and send this message immediately."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index ba97afb5e43c7..f72ccab3f56f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -64,6 +64,8 @@ import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; +import { IChatDebugService } from '../../../../common/chatDebugService.js'; +import { createDebugEventsAttachment } from '../../../chatDebug/chatDebugAttachment.js'; import { getPromptFileType } from '../../../../common/promptSyntax/config/promptFileLocations.js'; /** @@ -848,6 +850,7 @@ interface IVariableCompletionsDetails { class BuiltinDynamicCompletions extends Disposable { private static readonly addReferenceCommand = '_addReferenceCmd'; + private static readonly addDebugEventsSnapshotCommand = '_addDebugEventsSnapshotCmd'; private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:-]*`, 'g'); // MUST be using `g`-flag @@ -864,6 +867,7 @@ class BuiltinDynamicCompletions extends Disposable { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, ) { super(); @@ -949,10 +953,46 @@ class BuiltinDynamicCompletions extends Disposable { return result; }); + // Debug Events Snapshot completion + this.registerVariableCompletions('debugEventsSnapshot', ({ widget, range }) => { + if (widget.location !== ChatAgentLocation.Chat) { + return; + } + + const sessionResource = widget.viewModel?.sessionResource; + if (!sessionResource || this.chatDebugService.getEvents(sessionResource).length === 0) { + return; + } + + const text = `${chatVariableLeader}debugEventsSnapshot`; + const result: CompletionList = { suggestions: [] }; + result.suggestions.push({ + label: { label: text, description: localize('debugEventsSnapshot.description', 'Attach debug events snapshot') }, + filterText: text, + insertText: '', + range, + kind: CompletionItemKind.Text, + sortText: 'z', + command: { + id: BuiltinDynamicCompletions.addDebugEventsSnapshotCommand, title: '', arguments: [widget] + } + }); + return result; + }); + this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addReferenceCommand, (_services, arg) => { assertType(arg instanceof ReferenceArgument); return this.cmdAddReference(arg); })); + + this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addDebugEventsSnapshotCommand, async (_services, widget: IChatWidget) => { + const sessionResource = widget.viewModel?.sessionResource; + if (!sessionResource) { + return; + } + const attachment = await createDebugEventsAttachment(sessionResource, this.chatDebugService); + widget.attachmentModel.addContext(attachment); + })); } private findActiveCodeEditor(): ICodeEditor | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index 26f6954832c0f..461611150227e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -135,7 +135,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { ...action, id: 'chat.permissions.autopilot', label: localize('permissions.autopilot', "Autopilot (Preview)"), - description: localize('permissions.autopilot.subtext', "Copilot handles it from start to finish"), + description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), icon: ThemeIcon.fromId(Codicon.rocket.id), checked: currentLevel === ChatPermissionLevel.Autopilot, enabled: !policyRestricted, @@ -187,7 +187,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { super(action, { actionProvider, reporter: { id: 'ChatPermissionPicker', name: 'ChatPermissionPicker', includeOptions: true }, - listOptions: { descriptionBelow: true, minWidth: 232 }, + listOptions: { descriptionBelow: true, minWidth: 255 }, }, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index e152c00e58a4c..d99f33cca29cf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1533,11 +1533,6 @@ have to be updated for changes to the rules above, or to support more deeply nes } -/* Hide the tools button when the toolbar is in collapsed state */ -.interactive-session .chat-input-toolbar:has(.hide-chevrons) .action-item:has(.codicon-settings) { - display: none; -} - /* Add context button icon sizing */ .interactive-session .chat-input-toolbar .action-item:has(.codicon-add) .action-label { display: flex; diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index cce18bceca5c9..33f7a4a9f7a9b 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -90,7 +90,7 @@ export namespace ChatContextKeys { export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); export const chatSessionHasDebugData = new RawContextKey('chatSessionHasDebugData', false, { type: 'boolean', description: localize('chatSessionHasDebugData', "True when the current chat session has debug log data.") }); - export const chatSessionHasTroubleshootData = new RawContextKey('chatSessionHasTroubleshootData', false, { type: 'boolean', description: localize('chatSessionHasTroubleshootData', "True when the /troubleshoot slash command has been run in the current chat session.") }); + export const chatSessionHasAttachedDebugData = new RawContextKey('chatSessionHasAttachedDebugData', false, { type: 'boolean', description: localize('chatSessionHasAttachedDebugData', "True when a debug events snapshot has been attached in the current chat session.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index a50c09b18a111..a059f0aa836ae 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -125,6 +125,11 @@ export interface IChatDebugService extends IDisposable { */ readonly onDidAddEvent: Event; + /** + * Fired when provider events are cleared for a session (before re-invoking providers). + */ + readonly onDidClearProviderEvents: Event; + /** * Log a generic event to the debug service. */ @@ -167,6 +172,11 @@ export interface IChatDebugService extends IDisposable { */ registerProvider(provider: IChatDebugLogProvider): IDisposable; + /** + * Check whether providers have already been invoked for a given session. + */ + hasInvokedProviders(sessionResource: URI): boolean; + /** * Invoke all registered providers for a given session resource. * Called when the Debug View is opened to fetch events from extensions. @@ -185,6 +195,21 @@ export interface IChatDebugService extends IDisposable { * Delegates to the registered provider's resolveChatDebugLogEvent. */ resolveEvent(eventId: string): Promise; + + /** + * Fired when debug data is attached to a session. + */ + readonly onDidAttachDebugData: Event; + + /** + * Mark a session as having debug data attached. + */ + markDebugDataAttached(sessionResource: URI): void; + + /** + * Check whether a session has had debug data attached. + */ + hasAttachedDebugData(sessionResource: URI): boolean; } /** diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index cce684e6d5b81..9cdd711a311ab 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -25,6 +25,14 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic private readonly _onDidAddEvent = this._register(new Emitter()); readonly onDidAddEvent: Event = this._onDidAddEvent.event; + private readonly _onDidClearProviderEvents = this._register(new Emitter()); + readonly onDidClearProviderEvents: Event = this._onDidClearProviderEvents.event; + + private readonly _onDidAttachDebugData = this._register(new Emitter()); + readonly onDidAttachDebugData: Event = this._onDidAttachDebugData.event; + + private readonly _debugDataAttachedSessions = new ResourceMap(); + private readonly _providers = new Set(); private readonly _invocationCts = new ResourceMap(); @@ -102,6 +110,7 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._buffer.fill(undefined); this._head = 0; this._size = 0; + this._debugDataAttachedSessions.clear(); } registerProvider(provider: IChatDebugLogProvider): IDisposable { @@ -121,6 +130,10 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic }); } + hasInvokedProviders(sessionResource: URI): boolean { + return this._invocationCts.has(sessionResource); + } + async invokeProviders(sessionResource: URI): Promise { if (!LocalChatSessionUri.isLocalSession(sessionResource)) { return; @@ -180,6 +193,7 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic cts.dispose(); this._invocationCts.delete(sessionResource); } + this._debugDataAttachedSessions.delete(sessionResource); } private _clearProviderEvents(sessionResource: URI): void { @@ -203,6 +217,18 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS] = undefined; } this._size = write; + this._onDidClearProviderEvents.fire(sessionResource); + } + + markDebugDataAttached(sessionResource: URI): void { + if (!this._debugDataAttachedSessions.has(sessionResource)) { + this._debugDataAttachedSessions.set(sessionResource, true); + this._onDidAttachDebugData.fire(sessionResource); + } + } + + hasAttachedDebugData(sessionResource: URI): boolean { + return this._debugDataAttachedSessions.has(sessionResource); } async resolveEvent(eventId: string): Promise { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 3e04c2fed58ed..6eac3cb02b97c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -304,7 +304,8 @@ export type PromptFileSkipReason = | 'parse-error' | 'disabled' | 'all-hooks-disabled' - | 'claude-hooks-disabled'; + | 'claude-hooks-disabled' + | 'workspace-untrusted'; /** * Result of discovering a single prompt file. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 63f3e0b0e418d..9599b40394a9a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -40,6 +40,7 @@ import { ChatRequestHooks, IHookCommand, parseSubagentHooksFromYaml } from '../h import { HookType } from '../hookTypes.js'; import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptFileAttributes.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; @@ -173,6 +174,7 @@ export class PromptsService extends Disposable implements IPromptsService { @IPathService private readonly pathService: IPathService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IWorkspaceTrustManagementService private readonly workspaceTrustService: IWorkspaceTrustManagementService, ) { super(); @@ -223,6 +225,7 @@ export class PromptsService extends Disposable implements IPromptsService { this.getFileLocatorEvent(PromptsType.hook), Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS) || e.affectsConfiguration(PromptsConfig.USE_CLAUDE_HOOKS)), this._onDidPluginHooksChange.event, + this.workspaceTrustService.onDidChangeTrust, ) )); @@ -1245,6 +1248,10 @@ export class PromptsService extends Disposable implements IPromptsService { return undefined; } + if (!this.workspaceTrustService.isWorkspaceTrusted()) { + return undefined; + } + const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); @@ -1630,6 +1637,19 @@ export class PromptsService extends Disposable implements IPromptsService { const extensionId = promptPath.extension?.identifier?.value; const name = basename(uri); + // Ignored if workspace is untrusted + if (!this.workspaceTrustService.isWorkspaceTrusted()) { + files.push({ + uri: promptPath.uri, + storage: promptPath.storage, + status: 'skipped', + skipReason: 'workspace-untrusted', + name: basename(promptPath.uri), + extensionId: promptPath.extension?.identifier?.value, + }); + continue; + } + // Skip Claude hooks when the setting is disabled if (getHookSourceFormat(uri) === HookSourceFormat.Claude && useClaudeHooks === false) { files.push({ diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts index b3ff4593a43e7..dbf7e4e7105bd 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts @@ -15,7 +15,7 @@ export const ResolveDebugEventDetailsToolData: IToolData = { id: ResolveDebugEventDetailsToolId, toolReferenceName: 'resolveDebugEventDetails', displayName: localize('resolveDebugEventDetails.displayName', "Resolve Debug Event Details"), - when: ChatContextKeys.chatSessionHasTroubleshootData, + when: ChatContextKeys.chatSessionHasAttachedDebugData, canBeReferencedInPrompt: false, modelDescription: 'Resolves the full details for a specific chat debug event by its event ID. Use this tool to get detailed information about a debug event such as tool call input/output, model turn details, user message sections, or file lists. The event ID can be found in the debug event log summary provided in the conversation context.', source: ToolDataSource.Internal, diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts index 74879d30d2111..15094b0ce1adb 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts @@ -21,6 +21,8 @@ export const AUTOPILOT_CONTINUATION_MESSAGE = '- You have open questions or ambiguities — make good decisions and keep working\n' + '- You encountered an error — try to resolve it or find an alternative approach\n' + '- There are remaining steps — complete them first\n\n' + + 'When you ARE done, first provide a brief text summary of what was accomplished, then call task_complete. ' + + 'Both the summary message and the tool call are required.\n\n' + 'Keep working autonomously until the task is truly finished, then call task_complete.'; export const TaskCompleteToolData: IToolData = { @@ -29,8 +31,10 @@ export const TaskCompleteToolData: IToolData = { modelDescription: 'Signal that the user\'s task is fully done. You MUST call this tool when your work is complete — ' + 'whether you made code changes, answered a question, or completed any other kind of task. ' + - 'Provide a brief summary of what was accomplished. If the summary is trivial (e.g. answering a question), omit it. ' + + 'Provide a brief summary of what was accomplished. ' + 'Do not restate the summary in your message text — it is shown to the user directly.\n\n' + + 'IMPORTANT: Before calling this tool, you MUST output a brief text message summarizing what was done. ' + + 'The task is not complete until both your summary message AND this tool call are present.\n\n' + 'When to call:\n' + '- After answering the user\'s question or completing a conversational request\n' + '- After you have completed ALL requested changes\n' + diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 447a1bac04486..66009e7549b64 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -25,7 +25,7 @@ import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; -import { ChatTipTier } from '../../browser/chatTipCatalog.js'; +import { ChatTipTier, TIP_CATALOG } from '../../browser/chatTipCatalog.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; import { IChatService } from '../../common/chatService/chatService.js'; @@ -220,6 +220,110 @@ suite('ChatTipService', () => { assert.ok(!executedCommands.includes(FORK_CONVERSATION_TRACKING_COMMAND)); }); + + test('hides shown slash tip after submitted slash command without clicking tip link', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + + let tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + for (let i = 0; i < TIP_CATALOG.length && tip?.id !== 'tip.init'; i++) { + tip = service.navigateToNextTip(); + } + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.init', 'Expected to navigate to the init tip before submitting /init'); + + let didHide = false; + testDisposables.add(service.onDidHideTip(() => didHide = true)); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-advance-init'), + message: { + text: '/init', + parts: [], + }, + }); + + assert.ok(didHide, 'Expected slash tip to hide after submitting /init'); + assert.notStrictEqual(service.getWelcomeTip(contextKeyService)?.id, 'tip.init', 'Expected init tip to stay excluded after slash usage'); + }); + + test('removes slash tip from rotation after submitted slash command via eligibility tracking', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + + let tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + for (let i = 0; i < TIP_CATALOG.length && tip?.id !== 'tip.init'; i++) { + tip = service.navigateToNextTip(); + } + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.init'); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-rotate-init'), + message: { + text: '/init', + parts: [], + }, + }); + + for (let i = 0; i < TIP_CATALOG.length; i++) { + tip = service.navigateToNextTip(); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.init', 'Expected init tip to be removed from tip rotation'); + } + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes(CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND), 'Expected slash usage to be tracked in executed command exclusions'); + }); + + test('removes slash tip from rotation when slash usage is recorded before input transformation', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + + let tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + for (let i = 0; i < TIP_CATALOG.length && tip?.id !== 'tip.init'; i++) { + tip = service.navigateToNextTip(); + } + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.init'); + + service.recordSlashCommandUsage('init'); + + for (let i = 0; i < TIP_CATALOG.length; i++) { + tip = service.navigateToNextTip(); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.init', 'Expected init tip to be removed from tip rotation'); + } + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes(CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND), 'Expected slash usage to be tracked in executed command exclusions'); + }); + test('records fork tip usage for submitted /fork command', () => { const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); instantiationService.stub(IChatService, { 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 f8ee29ce85fbf..87928cde0e3c1 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 @@ -24,7 +24,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { LanguageModelToolsService } from '../../../browser/tools/languageModelToolsService.js'; import { ChatModel, IChatModel } from '../../../common/model/chatModel.js'; import { IChatService, IChatToolInputInvocationData, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl, IToolInvocation, ToolDataSource, ToolSet, IToolResultTextPart } from '../../../common/tools/languageModelToolsService.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -83,13 +83,13 @@ function registerToolForTest(service: LanguageModelToolsService, store: any, id: }; } -function stubGetSession(chatService: MockChatService, sessionId: string, options?: { requestId?: string; capture?: { invocation?: any } }): IChatModel { +function stubGetSession(chatService: MockChatService, sessionId: string, options?: { requestId?: string; capture?: { invocation?: any }; modeInfo?: { permissionLevel?: ChatPermissionLevel } }): IChatModel { const requestId = options?.requestId ?? 'requestId'; const capture = options?.capture; const fakeModel = { sessionId, sessionResource: LocalChatSessionUri.forSession(sessionId), - getRequests: () => [{ id: requestId, modelId: 'test-model' }], + getRequests: () => [{ id: requestId, modelId: 'test-model', modeInfo: options?.modeInfo }], } as ChatModel; chatService.addSession(fakeModel); chatService.appendProgress = (request, progress) => { @@ -1453,6 +1453,144 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 0, 'accessibility signal should not be played when auto-approve is enabled'); }); + test('autopilot permission level bypasses global auto-approve check', async () => { + // When autopilot is on, tools should auto-approve without needing global auto-approve enabled + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); // Global OFF + } + }); + + const tool = registerToolForTest(testService, store, 'autopilotTool', { + prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Confirm?', message: 'Should be auto-approved by autopilot' } }), + invoke: async () => ({ content: [{ kind: 'text', value: 'autopilot approved' }] }) + }); + + const sessionId = 'test-autopilot'; + stubGetSession(testChatService, sessionId, { + requestId: 'req1', + modeInfo: { permissionLevel: ChatPermissionLevel.Autopilot }, + }); + + // Tool should be auto-approved even though global auto-approve is off + const result = await testService.invokeTool( + tool.makeDto({ test: 1 }, { sessionId }), + async () => 0, + CancellationToken.None + ); + assert.strictEqual(result.content[0].value, 'autopilot approved'); + }); + + test('autopilot finds correct request by chatRequestId', async () => { + // When chatRequestId is provided, the exact request should be matched + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + } + }); + + const tool = registerToolForTest(testService, store, 'autopilotIdTool', { + prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Confirm?', message: 'Test' } }), + invoke: async () => ({ content: [{ kind: 'text', value: 'found by id' }] }) + }); + + const sessionId = 'test-autopilot-id'; + const fakeModel = { + sessionId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + getRequests: () => [ + { id: 'req-old', modelId: 'test-model', modeInfo: undefined }, + { id: 'req-autopilot', modelId: 'test-model', modeInfo: { permissionLevel: ChatPermissionLevel.Autopilot } }, + ], + } as ChatModel; + testChatService.addSession(fakeModel); + + const dto = tool.makeDto({ test: 1 }, { sessionId }); + dto.chatRequestId = 'req-autopilot'; + + const result = await testService.invokeTool(dto, async () => 0, CancellationToken.None); + assert.strictEqual(result.content[0].value, 'found by id'); + }); + + test('autopilot auto-approves terminal tool with confirmation messages', async () => { + // Terminal tools always return confirmationMessages when their own auto-approve is off. + // In autopilot mode, shouldAutoConfirm should still auto-approve the tool. + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + } + }); + + const tool = registerToolForTest(testService, store, 'terminalTool', { + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Run shell command?', + message: 'echo hello', + }, + toolSpecificData: { + kind: 'terminal' as const, + terminalToolSessionId: 'test', + terminalCommandId: 'cmd-1', + commandLine: { original: 'echo hello' }, + language: 'sh', + }, + }), + invoke: async () => ({ content: [{ kind: 'text', value: 'terminal executed' }] }) + }); + + const sessionId = 'test-autopilot-terminal'; + stubGetSession(testChatService, sessionId, { + requestId: 'req1', + modeInfo: { permissionLevel: ChatPermissionLevel.Autopilot }, + }); + + // Terminal tool should be auto-approved by autopilot even without terminal auto-approve enabled + const result = await testService.invokeTool( + tool.makeDto({ command: 'echo hello', explanation: 'test', goal: 'test', isBackground: false }, { sessionId }), + async () => 0, + CancellationToken.None + ); + assert.strictEqual(result.content[0].value, 'terminal executed'); + }); + + test('bypass approvals auto-approves terminal tool with confirmation messages', async () => { + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + } + }); + + const tool = registerToolForTest(testService, store, 'terminalToolBypass', { + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Run shell command?', + message: 'ls -la', + }, + toolSpecificData: { + kind: 'terminal' as const, + terminalToolSessionId: 'test', + terminalCommandId: 'cmd-2', + commandLine: { original: 'ls -la' }, + language: 'sh', + }, + }), + invoke: async () => ({ content: [{ kind: 'text', value: 'bypass executed' }] }) + }); + + const sessionId = 'test-bypass-terminal'; + stubGetSession(testChatService, sessionId, { + requestId: 'req1', + modeInfo: { permissionLevel: ChatPermissionLevel.AutoApprove }, + }); + + const result = await testService.invokeTool( + tool.makeDto({ command: 'ls -la', explanation: 'test', goal: 'test', isBackground: false }, { sessionId }), + async () => 0, + CancellationToken.None + ); + assert.strictEqual(result.content[0].value, 'bypass executed'); + }); + test('shouldAutoConfirm with basic configuration', async () => { // Test basic shouldAutoConfirm behavior with simple configuration const { service: testService, chatService: testChatService } = createTestToolsService(store, { diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts index 5a247eaea99d8..9b9b0aac42a22 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -214,6 +214,44 @@ suite('ChatDebugServiceImpl', () => { }); }); + suite('markDebugDataAttached', () => { + test('should track attached debug data per session', () => { + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), false); + + const fired: URI[] = []; + disposables.add(service.onDidAttachDebugData(uri => fired.push(uri))); + + service.markDebugDataAttached(sessionGeneric); + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), true); + assert.strictEqual(fired.length, 1); + assert.strictEqual(fired[0].toString(), sessionGeneric.toString()); + + // Idempotent — second call should not fire again + service.markDebugDataAttached(sessionGeneric); + assert.strictEqual(fired.length, 1); + + // Other sessions remain unaffected + assert.strictEqual(service.hasAttachedDebugData(sessionA), false); + }); + + test('should clear attached debug data on endSession', () => { + service.markDebugDataAttached(sessionGeneric); + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), true); + + service.endSession(sessionGeneric); + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), false); + }); + + test('should clear attached debug data on clear', () => { + service.markDebugDataAttached(sessionA); + service.markDebugDataAttached(sessionB); + + service.clear(); + assert.strictEqual(service.hasAttachedDebugData(sessionA), false); + assert.strictEqual(service.hasAttachedDebugData(sessionB), false); + }); + }); + suite('registerProvider', () => { test('should register and unregister a provider', async () => { const extSession = URI.parse('vscode-chat-session://local/ext-session'); @@ -312,6 +350,32 @@ suite('ChatDebugServiceImpl', () => { assert.strictEqual(firstToken.isCancellationRequested, true); }); + test('should fire onDidClearProviderEvents when clearing provider events', async () => { + const clearedSessions: URI[] = []; + disposables.add(service.onDidClearProviderEvents(sessionResource => clearedSessions.push(sessionResource))); + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async (sessionResource) => [{ + kind: 'generic', + sessionResource, + created: new Date(), + name: 'provider-event', + level: ChatDebugLogLevel.Info, + }], + }; + + disposables.add(service.registerProvider(provider)); + + // First invocation clears empty set and fires clear event + await service.invokeProviders(sessionGeneric); + assert.strictEqual(clearedSessions.length, 1, 'Clear event should fire on first invocation'); + + // Second invocation clears provider events from first invocation + await service.invokeProviders(sessionGeneric); + assert.strictEqual(clearedSessions.length, 2, 'Clear event should fire on second invocation'); + assert.strictEqual(clearedSessions[1].toString(), sessionGeneric.toString()); + }); + test('should not cancel invocations for different sessions', async () => { const tokens: Map = new Map(); 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 8a0dcd1bef3e0..e4a970b527136 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 @@ -24,9 +24,10 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; -import { TestContextService, TestUserDataProfileService } from '../../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestUserDataProfileService, TestWorkspaceTrustManagementService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, getFilePath, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; @@ -181,6 +182,8 @@ suite('ComputeAutomaticInstructions', () => { instaService.stub(IContextKeyService, new MockContextKeyService()); + instaService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService())); + instaService.stub(IAgentPluginService, { plugins: observableValue('testPlugins', []), allPlugins: observableValue('testAllPlugins', []), diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index a5e0efdd72b44..2889362b11f95 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -35,7 +35,7 @@ import { IWorkbenchEnvironmentService } from '../../../../../../services/environ import { IFilesConfigurationService } from '../../../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; import { toUserDataProfile } from '../../../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { TestContextService, TestUserDataProfileService } from '../../../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestUserDataProfileService, TestWorkspaceTrustManagementService } from '../../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; @@ -54,6 +54,7 @@ import { HookType } from '../../../../common/promptSyntax/hookTypes.js'; import { IContextKeyService, IContextKeyChangeEvent } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../../platform/workspace/common/workspaceTrust.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -65,6 +66,7 @@ suite('PromptsService', () => { let fileService: IFileService; let testPluginsObservable: ISettableObservable; let testAllPluginsObservable: ISettableObservable; + let workspaceTrustService: TestWorkspaceTrustManagementService; setup(async () => { instaService = disposables.add(new TestInstantiationService()); @@ -166,6 +168,9 @@ suite('PromptsService', () => { instaService.stub(IContextKeyService, new MockContextKeyService()); + workspaceTrustService = disposables.add(new TestWorkspaceTrustManagementService()); + instaService.stub(IWorkspaceTrustManagementService, workspaceTrustService); + testPluginsObservable = observableValue('testPlugins', []); testAllPluginsObservable = observableValue('testAllPlugins', []); @@ -3633,5 +3638,87 @@ suite('PromptsService', () => { assert.ok(after, 'Expected hooks result after plugin update'); assert.deepStrictEqual(after.hooks[HookType.PreToolUse], [{ type: 'command', command: 'echo after' }]); }); + + test('returns undefined when workspace is untrusted', async function () { + workspaceContextService.setWorkspace(testWorkspace(URI.file('/test-workspace'))); + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, { [HOOKS_SOURCE_FOLDER]: true }); + + await mockFiles(fileService, [ + { + path: '/test-workspace/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo test' }, + ], + }, + }), + ], + }, + ]); + + // Trusted workspace should return hooks + const trustedResult = await service.getHooks(CancellationToken.None); + assert.ok(trustedResult, 'Expected hooks when workspace is trusted'); + assert.strictEqual(trustedResult.hooks[HookType.PreToolUse]?.length, 1); + + // Untrusted workspace should return undefined + await workspaceTrustService.setWorkspaceTrust(false); + const untrustedResult = await service.getHooks(CancellationToken.None); + assert.strictEqual(untrustedResult, undefined, 'Expected undefined hooks when workspace is untrusted'); + + // Re-trusting should return hooks again + await workspaceTrustService.setWorkspaceTrust(true); + const reTrustedResult = await service.getHooks(CancellationToken.None); + assert.ok(reTrustedResult, 'Expected hooks after workspace becomes trusted again'); + assert.strictEqual(reTrustedResult.hooks[HookType.PreToolUse]?.length, 1); + }); + + test('discovery info marks hooks as skipped when workspace is untrusted', async function () { + workspaceContextService.setWorkspace(testWorkspace(URI.file('/test-workspace'))); + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, { [HOOKS_SOURCE_FOLDER]: true }); + + await mockFiles(fileService, [ + { + path: '/test-workspace/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo test' }, + ], + }, + }), + ], + }, + ]); + + await workspaceTrustService.setWorkspaceTrust(false); + const discoveryInfo = await service.getPromptDiscoveryInfo(PromptsType.hook, CancellationToken.None); + assert.strictEqual(discoveryInfo.files.length, 1, 'Expected one discovery result'); + assert.strictEqual(discoveryInfo.files[0].status, 'skipped'); + assert.strictEqual(discoveryInfo.files[0].skipReason, 'workspace-untrusted'); + }); + + test('suppresses plugin hooks when workspace is untrusted', async function () { + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, {}); + + const { plugin } = createTestPlugin('/plugins/test-plugin', [{ + type: HookType.PreToolUse, + originalId: 'plugin-pre-tool-use', + hooks: [{ type: 'command', command: 'echo from-plugin' }], + }]); + + testPluginsObservable.set([plugin], undefined); + testAllPluginsObservable.set([plugin], undefined); + + await workspaceTrustService.setWorkspaceTrust(false); + const result = await service.getHooks(CancellationToken.None); + assert.strictEqual(result, undefined, 'Expected undefined hooks when workspace is untrusted, even with plugin hooks'); + }); }); }); diff --git a/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts b/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts index a49f79b70dc64..363f8a12c5f26 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts @@ -17,7 +17,7 @@ import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } fr import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { ChatAgentLocation, ChatConfiguration } from '../../chat/common/constants.js'; +import { ChatConfiguration } from '../../chat/common/constants.js'; import { ChatImageMimeType, ChatMessageRole, IChatMessage, IChatMessagePart, ILanguageModelsService } from '../../chat/common/languageModels.js'; import { McpCommandIds } from './mcpCommandIds.js'; import { IMcpServerSamplingConfiguration, mcpServerSamplingSection } from './mcpConfiguration.js'; @@ -241,11 +241,8 @@ export class McpSamplingService extends Disposable implements IMcpSamplingServic return config.allowedOutsideChat === undefined ? ModelMatch.UnsureAllowedOutsideChat : ModelMatch.NotAllowed; } - // 2. Get the configured models, or the default model(s) - const foundModelIdsDeep = config.allowedModels?.filter(m => !!this._languageModelsService.lookupLanguageModel(m)) || this._languageModelsService.getLanguageModelIds().filter(m => this._languageModelsService.lookupLanguageModel(m)?.isDefaultForLocation[ChatAgentLocation.Chat]); - - const foundModelIds = foundModelIdsDeep.flat().sort((a, b) => b.length - a.length); // Sort by length to prefer most specific - + // 2. Get the configured models, or the default free model(s) + const foundModelIds = config.allowedModels?.filter(m => !!this._languageModelsService.lookupLanguageModel(m)) || this._getDefaultModels(); if (!foundModelIds.length) { return ModelMatch.NoMatchingModel; } @@ -261,6 +258,20 @@ export class McpSamplingService extends Disposable implements IMcpSamplingServic return foundModelIds[0]; // Return the first matching model } + private _getDefaultModels() { + const candidates = this._languageModelsService.getLanguageModelIds().map(m => { + const model = this._languageModelsService.lookupLanguageModel(m); + return model && !model.multiplierNumeric && !model.targetChatSessionType ? { model, id: m } : undefined; + }).filter(isDefined); + + const someDefault = candidates.findIndex(c => Object.values(c.model.isDefaultForLocation).some(Boolean)); + if (someDefault !== -1) { + [candidates[0], candidates[someDefault]] = [candidates[someDefault], candidates[0]]; + } + + return candidates.map(c => c.id); + } + private _configKey(server: IMcpServer) { return `${server.collection.label}: ${server.definition.label}`; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index eef1a76329b34..d0af956072869 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -17,7 +17,7 @@ import { IChatWidgetService } from '../../../../../chat/browser/chat.js'; import { ChatElicitationRequestPart } from '../../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../../../../chat/common/chatService/chatService.js'; -import { ChatAgentLocation } from '../../../../../chat/common/constants.js'; +import { ChatAgentLocation, ChatPermissionLevel } from '../../../../../chat/common/constants.js'; import { ChatMessageRole, getTextResponseFromStream, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; import { IToolInvocationContext } from '../../../../../chat/common/tools/languageModelToolsService.js'; import { ITaskService } from '../../../../../tasks/common/taskService.js'; @@ -108,6 +108,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private readonly _onDidFinishCommand = this._register(new Emitter()); readonly onDidFinishCommand: Event = this._onDidFinishCommand.event; + /** The chat session resource for this tool invocation, used to check permission level. */ + private readonly _sessionResource: URI | undefined; + constructor( private readonly _execution: IExecution, private readonly _pollFn: ((execution: IExecution, token: CancellationToken, taskService: ITaskService) => Promise) | undefined, @@ -124,6 +127,8 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { ) { super(); + this._sessionResource = invocationContext?.sessionResource; + // Start async to ensure listeners are set up timeout(0).then(() => { this._startMonitoring(command, invocationContext, token); @@ -237,7 +242,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Check for generic "press any key" prompts from scripts. if ((!isTask || !isTaskInactive) && detectsGenericPressAnyKeyPattern(output)) { - this._logService.trace('OutputMonitor: Idle -> generic "press any key" detected, requesting free-form input'); + this._logService.trace('OutputMonitor: Idle -> generic "press any key" detected'); + // In autopilot mode, auto-reply to "press any key" prompts + if (this._isAutopilotMode()) { + this._logService.trace('OutputMonitor: Autopilot mode -> auto-replying to "press any key"'); + await this._execution.instance.sendText('', true); + return { shouldContinuePollling: true }; + } + this._logService.trace('OutputMonitor: Requesting free-form input for "press any key"'); // Register a marker to track this prompt position so we don't re-detect it const currentMarker = this._execution.instance.registerMarker(); if (currentMarker) { @@ -286,7 +298,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } - const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); if (autoReply && !this._isSensitivePrompt(confirmationPrompt.prompt)) { const explicitInput = confirmationPrompt.suggestedInput ?? this._extractExplicitInputFromPrompt(confirmationPrompt.prompt); const normalizedInput = this._normalizeAutoReplyInput(explicitInput); @@ -588,6 +600,27 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return /(password|passphrase|token|api\s*key|secret)/i.test(prompt); } + /** + * Returns true if the current session is in Autopilot mode (not Bypass Approvals). + * In Autopilot, terminal prompts should be auto-replied to so the agent can + * work autonomously from start to finish. + */ + private _isAutopilotMode(): boolean { + if (!this._sessionResource) { + return false; + } + // Check the live widget picker level + const widget = this._chatWidgetService.getWidgetBySessionResource(this._sessionResource) + ?? this._chatWidgetService.lastFocusedWidget; + if (widget?.input.currentModeInfo.permissionLevel === ChatPermissionLevel.Autopilot) { + return true; + } + // Fall back to the request-stamped level + const model = this._chatService.getSession(this._sessionResource); + const request = model?.getRequests().at(-1); + return request?.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot; + } + private _normalizeAutoReplyInput(input: string | undefined): string | undefined { if (!input) { return undefined; @@ -626,7 +659,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!confirmationPrompt?.options.length) { return undefined; } - const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); let model = this._chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)[0]?.input.currentLanguageModel; if (model) { const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); 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 93f3b0c40d045..fd8fcd1f37a75 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -63,7 +63,7 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { IHistoryService } from '../../../../../services/history/common/history.js'; import { TerminalCommandArtifactCollector } from './terminalCommandArtifactCollector.js'; import { isNumber, isString } from '../../../../../../base/common/types.js'; -import { ChatConfiguration } from '../../../../chat/common/constants.js'; +import { ChatConfiguration, isAutoApproveLevel } from '../../../../chat/common/constants.js'; import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { TerminalChatCommandId } from '../../../chat/browser/terminalChat.js'; import { clamp } from '../../../../../../base/common/numbers.js'; @@ -644,8 +644,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } + // Check if the session's permission level (Autopilot/Bypass Approvals) auto-approves all tools. + // When active, skip terminal confirmation entirely since the user has opted into full auto-approval. + const isSessionAutoApproved = chatSessionResource && this._isSessionAutoApproveLevel(chatSessionResource); + // If forceConfirmationReason is set, always show confirmation regardless of auto-approval - const shouldShowConfirmation = !isFinalAutoApproved || context.forceConfirmationReason !== undefined; + const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; const confirmationMessages = shouldShowConfirmation ? { title: confirmationTitle, message: new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal)), @@ -659,6 +663,30 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + /** + * Returns true if the chat session's permission level (Autopilot/Bypass Approvals) + * auto-approves all tool calls, unless enterprise policy restricts it. + * Checks both the request-stamped level and the live picker level. + */ + private _isSessionAutoApproveLevel(chatSessionResource: URI): boolean { + const inspected = this._configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspected.policyValue === false) { + return false; + } + // Check the live widget picker level (handles mid-session switches). + // Fall back to lastFocusedWidget if the session-specific widget isn't found + // (e.g., widget was backgrounded or URI mismatch). + const widget = this._chatWidgetService.getWidgetBySessionResource(chatSessionResource) + ?? this._chatWidgetService.lastFocusedWidget; + if (widget && isAutoApproveLevel(widget.input.currentModeInfo.permissionLevel)) { + return true; + } + // Fall back to the request-stamped level + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + return isAutoApproveLevel(request?.modeInfo?.permissionLevel); + } + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; if (!toolSpecificData) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 3ff42709b5163..0fff7114eb07b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -165,8 +165,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb : {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); const defaultAllowWrite = [...this._defaultWritePaths]; - const linuxAllowWrite = [...new Set([...defaultAllowWrite, ...(linuxFileSystemSetting.allowWrite ?? [])])]; - const macAllowWrite = [...new Set([...defaultAllowWrite, ...(macFileSystemSetting.allowWrite ?? [])])]; + const linuxAllowWrite = [...new Set([...(linuxFileSystemSetting.allowWrite ?? []), ...defaultAllowWrite])]; + const macAllowWrite = [...new Set([...(macFileSystemSetting.allowWrite ?? []), ...defaultAllowWrite])]; let allowedDomains = networkSetting.allowedDomains ?? []; if (networkSetting.allowTrustedDomains) { 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 e362782b44b24..48ff9c225544b 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 @@ -86,7 +86,8 @@ suite('RunInTerminalTool', () => { }, store); instantiationService.stub(IChatService, { - onDidDisposeSession: chatServiceDisposeEmitter.event + onDidDisposeSession: chatServiceDisposeEmitter.event, + getSession: () => undefined, }); instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService))); instantiationService.stub(IWorkspaceContextService, workspaceContextService);