diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 31480601a415c..9e78b070f5fb1 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1992,10 +1992,15 @@ "responses", "messages" ], + "enumItemLabels": [ + "Chat Completions", + "Responses", + "Messages" + ], "enumDescriptions": [ - "Chat Completions API.", - "Responses API.", - "Messages API." + "Chat Completions API", + "Responses API", + "Messages API" ], "default": "chat-completions", "title": "API Type", @@ -2044,6 +2049,17 @@ "responses", "messages" ], + "enumItemLabels": [ + "Chat Completions", + "Responses", + "Messages" + ], + "enumDescriptions": [ + "Chat Completions API", + "Responses API", + "Messages API" + ], + "title": "API Type", "markdownDescription": "Request/response format used to talk to this endpoint:\n- `chat-completions`: Chat Completions API (default).\n- `responses`: Responses API.\n- `messages`: Messages API.\n\nWhen omitted, falls back to the group-level `apiType`, then to the URL path." }, "toolCalling": { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index c9a73049655c8..12bfd6d8a0b83 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -15,7 +15,6 @@ import { EmbeddingType, getWellKnownEmbeddingTypeInfo, IEmbeddingsComputer } fro import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes'; import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer'; -import { isAnthropicFamily, isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoint'; import { IAutomodeService } from '../../../platform/endpoint/node/automodeService'; import { CopilotChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint'; @@ -59,21 +58,42 @@ const experimentalAutoModelHintMarkers = ['minimax', 'mp3yn0h7', 'yaqq2gxh']; * Returns the available context size options for a model, or undefined if the * model does not support configurable context sizes. * - * For opus models with a large context window (>= 900K tokens), offers a - * standard 200K option and the model's full context size. + * Driven entirely by CAPI billing metadata: + * - When CAPI returns a `long_context` tier, offers `default.context_max` as + * the default option and `modelMaxPromptTokens` as an opt-in larger option. + * - When the long-context tier has higher prices, the larger option includes a + * cost indicator so the user knows they are opting into higher billing. + * - When there is no `long_context` tier, no selector is shown. */ function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; description: string; isDefault: boolean }[] | undefined { - const maxTokens = endpoint.modelMaxPromptTokens; + const pricing = endpoint.tokenPricing; - // Claude Opus models with a large context window (~1M or more) get a 200K/full toggle - if (isAnthropicFamily(endpoint) && endpoint.family.startsWith('claude-opus') && maxTokens > 900_000) { - return [ - { value: 200_000, description: vscode.l10n.t('Balanced default'), isDefault: true }, - { value: maxTokens, description: vscode.l10n.t('Longer sessions without compaction'), isDefault: false }, - ]; + // Only offer a selector when CAPI provides a default context max, + // which indicates a meaningful distinction between default and long context tiers. + if (!pricing?.default.contextMax) { + return undefined; } - return undefined; + const defaultMax = pricing.default.contextMax; + const fullMax = endpoint.modelMaxPromptTokens; + + // No point showing a selector if the default is already the full context + if (defaultMax >= fullMax) { + return undefined; + } + + const hasLongContextSurcharge = !!pricing.longContext; + + return [ + { value: defaultMax, description: vscode.l10n.t('Default pricing'), isDefault: true }, + { + value: fullMax, + description: hasLongContextSurcharge + ? vscode.l10n.t('Longer sessions (higher cost)') + : vscode.l10n.t('Longer sessions without compaction'), + isDefault: false, + }, + ]; } function formatTokenCount(count: number): string { @@ -92,17 +112,12 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem return {}; } - const family = endpoint.family.toLowerCase(); - if (isGeminiFamily(endpoint)) { - return {}; - } - const properties: Record[string]> = {}; // Reasoning effort config const effortLevels = endpoint.supportsReasoningEffort; if (effortLevels && effortLevels.length > 1) { - properties.reasoningEffort = buildReasoningEffortSchemaProperty(effortLevels, family); + properties.reasoningEffort = buildReasoningEffortSchemaProperty(effortLevels, endpoint.family.toLowerCase()); } // Context size config diff --git a/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts b/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts index 20c17fdd5e2ea..814ef7d3b4d27 100644 --- a/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts +++ b/extensions/copilot/src/platform/customInstructions/common/customInstructionsService.ts @@ -24,7 +24,7 @@ import { IFileSystemService } from '../../filesystem/common/fileSystemService'; import { ILogService } from '../../log/common/logService'; import { IPromptPathRepresentationService } from '../../prompts/common/promptPathRepresentationService'; import { IWorkspaceService } from '../../workspace/common/workspaceService'; -import { COPILOT_INSTRUCTIONS_PATH, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_LOCATION_KEY, PERSONAL_SKILL_FOLDERS, PromptsType, SKILLS_LOCATION_KEY, USE_AGENT_SKILLS_SETTING, WORKSPACE_SKILL_FOLDERS } from './promptTypes'; +import { COPILOT_INSTRUCTIONS_PATH, COPILOT_PERSONAL_INSTRUCTIONS_PATH, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_LOCATION_KEY, PERSONAL_SKILL_FOLDERS, PromptsType, SKILLS_LOCATION_KEY, USE_AGENT_SKILLS_SETTING, WORKSPACE_SKILL_FOLDERS } from './promptTypes'; declare const TextDecoder: { decode(input: Uint8Array): string; @@ -317,6 +317,14 @@ export class CustomInstructionsService extends Disposable implements ICustomInst // ignore non-existing instruction files } } + try { + const uri = extUriBiasedIgnorePathCase.joinPath(this.envService.userHome, COPILOT_PERSONAL_INSTRUCTIONS_PATH); + if ((await this.fileSystemService.stat(uri)).type === FileType.File) { + result.push(uri); + } + } catch (e) { + // ignore non-existing instruction files + } } return result; } diff --git a/extensions/copilot/src/platform/customInstructions/common/promptTypes.ts b/extensions/copilot/src/platform/customInstructions/common/promptTypes.ts index aeb07244dc815..1f8d0d3808d70 100644 --- a/extensions/copilot/src/platform/customInstructions/common/promptTypes.ts +++ b/extensions/copilot/src/platform/customInstructions/common/promptTypes.ts @@ -22,6 +22,7 @@ export const USE_AGENT_SKILLS_SETTING = 'chat.useAgentSkills'; export const USE_SKILL_ADHERENCE_PROMPT_SETTING = 'chat.experimental.useSkillAdherencePrompt'; export const COPILOT_INSTRUCTIONS_PATH = '.github/copilot-instructions.md'; +export const COPILOT_PERSONAL_INSTRUCTIONS_PATH = '.copilot/copilot-instructions.md'; /** * File extension for the reusable prompt files. diff --git a/extensions/copilot/src/platform/customInstructions/test/node/customInstructionsService.spec.ts b/extensions/copilot/src/platform/customInstructions/test/node/customInstructionsService.spec.ts index 5247266d12adf..d7fe1bbe959fd 100644 --- a/extensions/copilot/src/platform/customInstructions/test/node/customInstructionsService.spec.ts +++ b/extensions/copilot/src/platform/customInstructions/test/node/customInstructionsService.spec.ts @@ -6,18 +6,22 @@ import { afterEach, beforeEach, expect, suite, test } from 'vitest'; import { URI } from '../../../../util/vs/base/common/uri'; import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; -import { IConfigurationService } from '../../../configuration/common/configurationService'; +import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; +import { MockFileSystemService } from '../../../filesystem/node/test/mockFileSystemService'; import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services'; import { TestWorkspaceService } from '../../../test/node/testWorkspaceService'; import { IWorkspaceService } from '../../../workspace/common/workspaceService'; import { ICustomInstructionsService } from '../../common/customInstructionsService'; +import { IFileSystemService } from '../../../filesystem/common/fileSystemService'; +import { mockFiles } from '../../../promptFiles/test/node/mockFiles'; suite('CustomInstructionsService - Skills', () => { let accessor: ITestingServicesAccessor; let customInstructionsService: ICustomInstructionsService; let configService: InMemoryConfigurationService; + let fileSystemService: MockFileSystemService; beforeEach(async () => { const services = createPlatformServices(); @@ -38,6 +42,7 @@ suite('CustomInstructionsService - Skills', () => { accessor = services.createTestingAccessor(); customInstructionsService = accessor.get(ICustomInstructionsService); + fileSystemService = accessor.get(IFileSystemService) as MockFileSystemService; }); afterEach(() => { @@ -323,6 +328,28 @@ suite('CustomInstructionsService - Skills', () => { }); suite('chat.instructionsFilesLocations config', () => { + test('should return workspace and home copilot instruction files from getAgentInstructions', async () => { + await configService.setConfig(ConfigKey.UseInstructionFiles, true); + await mockFiles(fileSystemService, [ + { path: '/workspace/.github/copilot-instructions.md', contents: ['Workspace instructions'] }, + { path: '/home/testuser/.copilot/copilot-instructions.md', contents: ['Home instructions'] }, + ]); + + expect((await customInstructionsService.getAgentInstructions()).map(uri => uri.path)).toEqual([ + '/workspace/.github/copilot-instructions.md', + '/home/testuser/.copilot/copilot-instructions.md', + ]); + }); + + test('should skip home copilot instruction files from getAgentInstructions when disabled', async () => { + await configService.setConfig(ConfigKey.UseInstructionFiles, false); + await mockFiles(fileSystemService, [ + { path: '/home/testuser/.copilot/copilot-instructions.md', contents: ['Home instructions'] }, + ]); + + expect(await customInstructionsService.getAgentInstructions()).toEqual([]); + }); + test('should recognize instruction file in absolute path location', async () => { await configService.setNonExtensionConfig('chat.instructionsFilesLocations', { '/custom/instructions': true diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index b77771c89311c..c8b2a8a833780 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -83,7 +83,12 @@ export interface IModelTokenPriceTier { input_price: number; output_price: number; cache_price: number; - context_max: number; + /** + * The maximum context window size (in tokens) for this pricing tier. + * Present on the `default` tier only when a `long_context` tier also + * exists; always present on the `long_context` tier itself. + */ + context_max?: number; } export interface IModelTokenPrices { diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 7011c5a361daf..783cb0e5db466 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -126,6 +126,7 @@ function normalizePriceTier(tier: IModelTokenPriceTier, scale: number): ITokenPr inputPrice: tier.input_price * scale, outputPrice: tier.output_price * scale, cacheReadTokenPrice: tier.cache_price * scale, + contextMax: tier.context_max, }; } diff --git a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts index 54616af75c57d..46912f0693c3d 100644 --- a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts @@ -340,6 +340,15 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe return modelLimit; } + // When a long context tier exists, use max_context_window_tokens as the + // prompt token basis so users can opt into the full context window via + // the model picker. The configurationSchema default (defaultContextMax) + // ensures users aren't billed at the long-context rate without explicit opt-in. + if (chatModelInfo.billing?.token_prices?.long_context && chatModelInfo.capabilities?.limits?.max_context_window_tokens) { + modelLimit += chatModelInfo.capabilities.limits.max_context_window_tokens; + return modelLimit; + } + // Check if CAPI has prompt token limits and return those if (chatModelInfo.capabilities?.limits?.max_prompt_tokens) { modelLimit += chatModelInfo.capabilities.limits.max_prompt_tokens; diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 7a84ccecb7113..e752a88eae738 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -270,6 +270,12 @@ export interface ITokenPriceTier { readonly outputPrice: number; /** Cost in AICs per million cached (read) tokens */ readonly cacheReadTokenPrice: number; + /** + * The largest prompt size (in tokens) billed at this tier's rates. + * Derived from CAPI `billing.token_prices..context_max`. + * Present only when CAPI provides a `long_context` tier. + */ + readonly contextMax?: number; } /** diff --git a/extensions/copilot/src/platform/promptFiles/test/node/agentInstructionsLocator.spec.ts b/extensions/copilot/src/platform/promptFiles/test/node/agentInstructionsLocator.spec.ts index be92d70eac781..1ae56d36020b4 100644 --- a/extensions/copilot/src/platform/promptFiles/test/node/agentInstructionsLocator.spec.ts +++ b/extensions/copilot/src/platform/promptFiles/test/node/agentInstructionsLocator.spec.ts @@ -212,6 +212,24 @@ suite('AgentInstructionsLocator', () => { expect(paths).not.toContain(`${userHome}/.claude/CLAUDE.md`); }); + test('should collect ~/.copilot/copilot-instructions.md when enabled', async () => { + const userHome = '/home/testuser'; + await mockFiles(fileSystem, [ + { path: `${userHome}/.copilot/copilot-instructions.md`, contents: ['Copilot guidelines from home'] }, + { path: `${rootFolder}/src/file.ts`, contents: ['console.log("test");'] }, + ]); + + await configService.setConfig(ConfigKey.UseInstructionFiles, true); + let result = await locator.listAgentInstructions(CancellationToken.None); + let paths = result.map(f => f.uri.path); + expect(paths).toContain(`${userHome}/.copilot/copilot-instructions.md`); + + await configService.setConfig(ConfigKey.UseInstructionFiles, false); + result = await locator.listAgentInstructions(CancellationToken.None); + paths = result.map(f => f.uri.path); + expect(paths).not.toContain(`${userHome}/.copilot/copilot-instructions.md`); + }); + test('should collect parent folder CLAUDE configurations when includeWorkspaceFolderParents is enabled', async () => { await mockFiles(fileSystem, [ // `.git/HEAD` marks the parent folder as a repository root. diff --git a/extensions/copilot/src/platform/promptFiles/vscode-node/agentInstructionsLocator.ts b/extensions/copilot/src/platform/promptFiles/vscode-node/agentInstructionsLocator.ts index f709b44d8fea7..5e8533d091846 100644 --- a/extensions/copilot/src/platform/promptFiles/vscode-node/agentInstructionsLocator.ts +++ b/extensions/copilot/src/platform/promptFiles/vscode-node/agentInstructionsLocator.ts @@ -26,6 +26,7 @@ const AGENT_MD_FILENAME = 'AGENTS.md'; const CLAUDE_MD_FILENAME = 'CLAUDE.md'; const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; const CLAUDE_CONFIG_FOLDER = '.claude'; +const COPILOT_CONFIG_FOLDER = '.copilot'; const COPILOT_CUSTOM_INSTRUCTIONS_FILENAME = 'copilot-instructions.md'; const GITHUB_CONFIG_FOLDER = '.github'; @@ -93,14 +94,16 @@ export class AgentInstructionsLocator extends Disposable { promises.push(this.findFilesInRoots([this.envService.userHome], CLAUDE_CONFIG_FOLDER, [claudeMdFile], token, resolvedAgentFiles)); } - // `useCopilotInstructionsFiles` gates only `.github/copilot-instructions.md`. + // `useCopilotInstructionsFiles` gates both workspace and personal + // `copilot-instructions.md` discovery. // Reuses the existing extension config (default true) instead of hard-coding the qualified key. const useCopilotInstructionsFiles = this.configurationService.getConfig(ConfigKey.UseInstructionFiles) !== false; if (!useCopilotInstructionsFiles) { logger?.logInfo('Copilot instructions files are disabled via configuration.'); } else { - const githubConfigFiles: IWorkspaceInstructionFile[] = [{ fileName: COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, type: AgentInstructionFileType.copilotInstructionsMd }]; - promises.push(this.findFilesInRoots(rootFolders, GITHUB_CONFIG_FOLDER, githubConfigFiles, token, resolvedAgentFiles)); + const copilotInstructionsFile: IWorkspaceInstructionFile = { fileName: COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, type: AgentInstructionFileType.copilotInstructionsMd }; + promises.push(this.findFilesInRoots(rootFolders, GITHUB_CONFIG_FOLDER, [copilotInstructionsFile], token, resolvedAgentFiles)); // copilot-instructions.md in .github folder under workspace root + promises.push(this.findFilesInRoots([this.envService.userHome], COPILOT_CONFIG_FOLDER, [copilotInstructionsFile], token, resolvedAgentFiles)); // copilot-instructions.md in ~/.copilot folder } // Files at the workspace root itself (AGENTS.md / CLAUDE.md / CLAUDE.local.md). diff --git a/src/vs/base/browser/ui/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index cf35f8126c68d..dc861b1495e83 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -17,7 +17,7 @@ } .monaco-dialog-modal-block.dimmed { - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.5); } /** Dialog: Container */ diff --git a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts new file mode 100644 index 0000000000000..3216a0069405e --- /dev/null +++ b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../sandbox/common/settings.js'; +import { createSchema, schemaProperty } from './agentHostSchema.js'; + +/** + * Top-level keys the agent host's root config bag exposes for sandboxing. + * All sandbox-related values live nested under {@link AgentHostSandboxConfigKey.Sandbox} + * — the persisted JSON has a single `"sandbox": { ... }` object rather than a + * dozen flat keys. + */ +export const enum AgentHostSandboxConfigKey { + Sandbox = 'sandbox', +} + +/** + * Well-known sub-keys inside the agent host's `sandbox` object. These are + * intentionally a flat, prefix-free namespace owned by the agent host — + * distinct from the workbench's `chat.agent.sandbox.*` setting IDs. Hosts + * (today: the workbench client) translate from their setting IDs to these + * keys when forwarding values via a `RootConfigChanged` action. + */ +export const enum AgentHostSandboxKey { + Enabled = 'enabled', + WindowsEnabled = 'enabled.windows', + AllowUnsandboxedCommands = 'allowUnsandboxedCommands', + AutoApproveUnsandboxedCommands = 'autoApproveUnsandboxedCommands', + LinuxFileSystem = 'fileSystem.linux', + MacFileSystem = 'fileSystem.mac', + WindowsFileSystem = 'fileSystem.windows', + AdvancedRuntime = 'advanced.runtime', + AllowedNetworkDomains = 'allowedNetworkDomains', + DeniedNetworkDomains = 'deniedNetworkDomains', +} + +/** Shape of the persisted/forwarded `sandbox` object. */ +export type ISandboxConfigValue = Partial<{ + [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue; + [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue; + [AgentHostSandboxKey.AllowUnsandboxedCommands]: boolean; + [AgentHostSandboxKey.AutoApproveUnsandboxedCommands]: boolean; + [AgentHostSandboxKey.LinuxFileSystem]: Record; + [AgentHostSandboxKey.MacFileSystem]: Record; + [AgentHostSandboxKey.WindowsFileSystem]: Record; + [AgentHostSandboxKey.AdvancedRuntime]: Record; + [AgentHostSandboxKey.AllowedNetworkDomains]: string[]; + [AgentHostSandboxKey.DeniedNetworkDomains]: string[]; +}>; + +/** + * Schema for the subset of workbench sandbox settings that hosts (today: the + * workbench client) may forward into the agent host's root config bag. + * + * The agent host's terminal sandbox engine reads these values through + * {@link IAgentConfigurationService.getRootValue}. Only the modern, + * normalized form of each setting is declared here — the workbench is + * expected to: + * + * - map the legacy boolean form of `chat.agent.sandbox.enabled` to the + * `'on' | 'off' | 'allowNetwork'` enum, and + * - migrate values from any deprecated setting IDs to their modern key + * + * before pushing a `RootConfigChanged` action. That keeps the agent-host + * schema (and validation) free of backward-compat baggage. + */ +export const sandboxConfigSchema = createSchema({ + [AgentHostSandboxConfigKey.Sandbox]: schemaProperty({ + type: 'object', + title: localize('agentHost.config.sandbox.title', "Agent Sandbox"), + properties: { + [AgentHostSandboxKey.Enabled]: { + type: 'string', + title: localize('agentHost.config.sandbox.enabled.title', "Sandbox Enabled"), + enum: [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On, AgentSandboxEnabledValue.AllowNetwork], + }, + [AgentHostSandboxKey.WindowsEnabled]: { + type: 'string', + title: localize('agentHost.config.sandbox.windowsEnabled.title', "Sandbox Enabled (Windows)"), + enum: [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On, AgentSandboxEnabledValue.AllowNetwork], + }, + [AgentHostSandboxKey.AllowUnsandboxedCommands]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.allowUnsandboxedCommands.title', "Allow Unsandboxed Commands"), + }, + [AgentHostSandboxKey.AutoApproveUnsandboxedCommands]: { + type: 'boolean', + title: localize('agentHost.config.sandbox.autoApproveUnsandboxedCommands.title', "Auto-Approve Unsandboxed Commands"), + }, + [AgentHostSandboxKey.LinuxFileSystem]: { + type: 'object', + title: localize('agentHost.config.sandbox.linuxFileSystem.title', "Linux Sandbox Filesystem"), + }, + [AgentHostSandboxKey.MacFileSystem]: { + type: 'object', + title: localize('agentHost.config.sandbox.macFileSystem.title', "macOS Sandbox Filesystem"), + }, + [AgentHostSandboxKey.WindowsFileSystem]: { + type: 'object', + title: localize('agentHost.config.sandbox.windowsFileSystem.title', "Windows Sandbox Filesystem"), + }, + [AgentHostSandboxKey.AdvancedRuntime]: { + type: 'object', + title: localize('agentHost.config.sandbox.advancedRuntime.title', "Advanced Sandbox Runtime"), + }, + [AgentHostSandboxKey.AllowedNetworkDomains]: { + type: 'array', + title: localize('agentHost.config.sandbox.allowedDomains.title', "Allowed Network Domains"), + items: { type: 'string', title: localize('agentHost.config.sandbox.allowedDomains.item.title', "Domain") }, + }, + [AgentHostSandboxKey.DeniedNetworkDomains]: { + type: 'array', + title: localize('agentHost.config.sandbox.deniedDomains.title', "Denied Network Domains"), + items: { type: 'string', title: localize('agentHost.config.sandbox.deniedDomains.item.title', "Domain") }, + }, + }, + }), +}); + +/** + * Maps modern workbench sandbox setting IDs (the ones the engine asks about) + * to the sub-keys inside the agent host's `sandbox` config object. + * + * Deprecated setting IDs are intentionally absent: hosts forwarding values + * into the agent host are expected to migrate deprecated → modern IDs + * before dispatching `RootConfigChanged`. + */ +export const sandboxSettingIdToAgentHostKey: Readonly> = { + [AgentSandboxSettingId.AgentSandboxEnabled]: AgentHostSandboxKey.Enabled, + [AgentSandboxSettingId.AgentSandboxWindowsEnabled]: AgentHostSandboxKey.WindowsEnabled, + [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands]: AgentHostSandboxKey.AllowUnsandboxedCommands, + [AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands]: AgentHostSandboxKey.AutoApproveUnsandboxedCommands, + [AgentSandboxSettingId.AgentSandboxLinuxFileSystem]: AgentHostSandboxKey.LinuxFileSystem, + [AgentSandboxSettingId.AgentSandboxMacFileSystem]: AgentHostSandboxKey.MacFileSystem, + [AgentSandboxSettingId.AgentSandboxWindowsFileSystem]: AgentHostSandboxKey.WindowsFileSystem, + [AgentSandboxSettingId.AgentSandboxAdvancedRuntime]: AgentHostSandboxKey.AdvancedRuntime, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: AgentHostSandboxKey.AllowedNetworkDomains, + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: AgentHostSandboxKey.DeniedNetworkDomains, +}; + diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 17899e9ec0840..c9979e4cbb29e 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -12,6 +12,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, defaultAgentHostCustomizationConfigValues } from '../common/agentHostCustomizationConfig.js'; +import { sandboxConfigSchema } from '../common/sandboxConfigSchema.js'; import type { ISchema, SchemaDefinition, SchemaValue } from '../common/agentHostSchema.js'; import { ProtocolError } from '../common/state/sessionProtocol.js'; import { ActionType } from '../common/state/sessionActions.js'; @@ -125,10 +126,11 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi // than replacing it. const existing = this._stateManager.rootState.config; const ownSchema = agentHostCustomizationConfigSchema.toProtocol(); + const sandboxSchema = sandboxConfigSchema.toProtocol(); this._stateManager.rootState.config = { schema: { type: 'object', - properties: { ...existing?.schema.properties, ...ownSchema.properties }, + properties: { ...existing?.schema.properties, ...ownSchema.properties, ...sandboxSchema.properties }, }, values: { ...existing?.values, ...this._loadPersistedRootConfig() }, }; @@ -266,7 +268,10 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi try { const raw = fs.readFileSync(this._rootConfigResource.fsPath, 'utf8'); const parsed = JSON.parse(raw) as Record; - return agentHostCustomizationConfigSchema.validateOrDefault(parsed, defaults); + return { + ...agentHostCustomizationConfigSchema.validateOrDefault(parsed, defaults), + ...sandboxConfigSchema.validateOrDefault(parsed, {}), + }; } catch (err) { const code = err && typeof err === 'object' && hasKey(err, { code: true }) ? String(err.code) : undefined; if (code !== 'ENOENT') { diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index d836fbb9f57a2..bab96278c87fb 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -50,6 +50,9 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { SessionDataService } from './sessionDataService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; +import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; +import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; +import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; @@ -131,6 +134,8 @@ async function startAgentHost(): Promise { diServices.set(IProductService, productService); diServices.set(ITelemetryService, telemetryService); instantiationService = new InstantiationService(diServices); + diServices.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); + diServices.set(ISandboxHelperService, new SandboxHelperService()); const gitService = instantiationService.createInstance(AgentHostGitService); diServices.set(IAgentHostGitService, gitService); // Checkpoint service depends on session data + git services, so diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index abd31cfa373f7..3da5234e18507 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -54,6 +54,9 @@ import { ISessionDataService } from '../common/sessionDataService.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { SessionDataService } from './sessionDataService.js'; +import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../sandbox/common/terminalSandboxMxcRuntime.js'; +import { ISandboxHelperService } from '../../sandbox/common/sandboxHelperService.js'; +import { SandboxHelperService } from '../../sandbox/node/sandboxHelper.js'; import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { resolveServerUrls } from './serverUrls.js'; @@ -205,6 +208,8 @@ async function main(): Promise { diServices.set(ISessionDataService, sessionDataService); diServices.set(ITelemetryService, telemetryService); const instantiationService = new InstantiationService(diServices); + diServices.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); + diServices.set(ISandboxHelperService, new SandboxHelperService()); const gitService = instantiationService.createInstance(AgentHostGitService); diServices.set(IAgentHostGitService, gitService); const checkpointService = disposables.add(instantiationService.createInstance(AgentHostCheckpointService)); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3ef142c13a3a4..3ec2080b8dae6 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -684,6 +684,9 @@ export class AgentSideEffects extends Disposable { // Strip confirmationTitle so createToolReadyAction emits the // auto-approved (no-options) action. effective = { ...e, state: { ...e.state, confirmationTitle: undefined } }; + } else if (effective.state.confirmationTitle) { + // Make sure the agent is registered for the eventual `SessionToolCallConfirmed` response. + this._toolCallAgents.set(`${sessionKey}:${e.state.toolCallId}`, agent.id); } this._stateManager.dispatchServerAction( sessionKey, diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts new file mode 100644 index 0000000000000..e23997d47bf36 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { dirname } from '../../../../base/common/path.js'; +import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IEnvironmentService, INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { ISandboxHelperService, type ISandboxDependencyStatus } from '../../../sandbox/common/sandboxHelperService.js'; +import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; +import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { AgentHostSandboxConfigKey, sandboxConfigSchema, sandboxSettingIdToAgentHostKey } from '../../common/sandboxConfigSchema.js'; + +/** Subdirectory under the user home + product data folder where the engine creates its temp dir. */ +const SANDBOX_TEMP_DIR_NAME = 'tmp'; + +/** + * Host adapter that bridges agent-host environment data into the shared + * {@link TerminalSandboxEngine}. One instance per session, wired up via + * {@link createAgentHostSandboxEngine}. + */ +class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { + readonly onDidChangeRoots = Event.None; + readonly onDidChangeSandboxSettings: Event; + private readonly _sandboxHelper: ISandboxHelperService; + + constructor( + private readonly _sessionId: string, + private readonly _workingDirectory: URI | undefined, + private readonly _environmentService: INativeEnvironmentService, + private readonly _productService: IProductService, + private readonly _agentConfigurationService: IAgentConfigurationService, + sandboxHelper: ISandboxHelperService, + ) { + this._sandboxHelper = sandboxHelper; + this.onDidChangeSandboxSettings = this._agentConfigurationService.onDidRootConfigChange; + } + + async getOS(): Promise { + return OS; + } + + async getRuntimeInfo(): Promise { + const appRoot = dirname(FileAccess.asFileUri('').path); + const runAsNode = !!process.versions['electron']; + return { appRoot, execPath: process.execPath, runAsNode }; + } + + async getUserHome(): Promise { + return this._environmentService.userHome; + } + + async getSandboxTempDir(): Promise { + const userHome = this._environmentService.userHome; + if (!userHome) { + return undefined; + } + const sandboxRoot = URI.joinPath(userHome, this._productService.dataFolderName, SANDBOX_TEMP_DIR_NAME); + return URI.joinPath(sandboxRoot, `agenthost_${this._sessionId}`); + } + + async getWorkspaceStorageReadRoot(): Promise { + // The agent host has no workspace-storage equivalent today. + return undefined; + } + + getWriteRoots(): readonly URI[] { + return this._workingDirectory ? [this._workingDirectory] : []; + } + + async checkSandboxDependencies(): Promise { + return this._sandboxHelper.checkSandboxDependencies(); + } + + async getWindowsMxcFilesystemPolicy() { + return this._sandboxHelper.getWindowsMxcFilesystemPolicy(); + } + + async getWindowsMxcEnvironment() { + return this._sandboxHelper.getWindowsMxcEnvironment(); + } + + getSandboxSetting(settingId: string): T | undefined { + // The agent host stores sandbox settings nested under a single + // top-level `sandbox` object with prefix-free sub-keys (e.g. + // `sandbox.enabled` rather than `chat.agent.sandbox.enabled`). Map + // from the engine's modern setting ID into that sub-key namespace; + // unknown IDs (which include all deprecated keys — handled host-side + // by the workbench client) resolve to undefined. + const innerKey = sandboxSettingIdToAgentHostKey[settingId]; + if (innerKey === undefined) { + return undefined; + } + const sandbox = this._agentConfigurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); + return sandbox?.[innerKey] as T | undefined; + } +} + +/** + * Construct a per-session {@link TerminalSandboxEngine} for the agent host. + * The returned engine is registered with the caller's instantiation service + * but the caller is responsible for disposing it (typically by registering it + * alongside the per-session {@link ShellManager}). + */ +export function createAgentHostSandboxEngine( + instantiationService: IInstantiationService, + environmentService: IEnvironmentService, + productService: IProductService, + agentConfigurationService: IAgentConfigurationService, + sandboxHelper: ISandboxHelperService, + sessionId: string, + workingDirectory: URI | undefined, +): TerminalSandboxEngine { + const host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, sandboxHelper); + return instantiationService.createInstance(TerminalSandboxEngine, host); +} + diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 51e9ac54e12bf..43f25a7fc4e4f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1494,7 +1494,7 @@ export class CopilotAgent extends Disposable implements IAgent { return async (callbacks: Parameters[0]) => { const disableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.DisableCustomTerminalTool) === true; - const shellTools = disableCustomTerminalTool ? [] : await createShellTools(shellManager, this._terminalManager, this._logService); + const shellTools = disableCustomTerminalTool ? [] : await createShellTools(shellManager, this._terminalManager, this._logService, callbacks.requestUnsandboxedCommandConfirmation); const customAgents = await toSdkCustomAgents(plugins.flatMap(p => p.agents), this._fileService); return { onPermissionRequest: callbacks.onPermissionRequest, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 163a02fad0fc0..32cfc520e9874 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -22,6 +22,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; +import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { platformSessionSchema } from '../../common/agentHostSchema.js'; import { AgentSignal } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; @@ -35,7 +36,7 @@ import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeRequestParams, IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; -import type { ShellManager } from './copilotShellTools.js'; +import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; @@ -267,6 +268,7 @@ export type SessionWrapperFactory = (callbacks: { readonly onPermissionRequest: (request: ITypedPermissionRequest) => Promise; readonly onUserInputRequest: (request: UserInputRequest, invocation: { sessionId: string }) => Promise; readonly onElicitationRequest: (context: ElicitationContext) => Promise; + readonly requestUnsandboxedCommandConfirmation: (request: IUnsandboxedCommandConfirmationRequest) => Promise; readonly hooks: { readonly onPreToolUse: (input: PreToolUseHookInput) => Promise; readonly onPostToolUse: (input: PostToolUseHookInput) => Promise; @@ -683,6 +685,7 @@ export class CopilotAgentSession extends Disposable { onPermissionRequest: request => this.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => this.handleUserInputRequest(request, invocation), onElicitationRequest: context => this.handleElicitationRequest(context), + requestUnsandboxedCommandConfirmation: request => this.requestUnsandboxedCommandConfirmation(request), clientTools: this.createClientSdkTools(), hooks: { onPreToolUse: input => this._handlePreToolUse(input), @@ -961,11 +964,26 @@ export class CopilotAgentSession extends Disposable { } } + const isShellRequest = request.kind === 'shell' + || (request.kind === 'custom-tool' && typeof request.toolName === 'string' && isShellTool(request.toolName)); + this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); const deferred = new DeferredPromise(); this._pendingPermissions.set(toolCallId, deferred); + if (isShellRequest && await this._isShellSandboxedByDefault()) { + // Session may have been disposed while we awaited the engine + // check; if so the deferred has already been settled and + // removed, so leave it alone. + if (this._pendingPermissions.get(toolCallId) === deferred) { + this._pendingPermissions.delete(toolCallId); + this._logService.info(`[Copilot:${this.sessionId}] Auto-approving sandboxed shell command for tool call ${toolCallId}`); + return { kind: 'approve-once' }; + } + return { kind: 'reject' }; + } + // Derive display information from the permission request kind const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request, this._workingDirectory); @@ -1053,6 +1071,27 @@ export class CopilotAgentSession extends Disposable { return extUriBiasedIgnorePathCase.isEqualOrParent(permissionUri, attachmentsDir); } + /** + * Returns true when our custom shell tool is registered and the + * {@link TerminalSandboxEngine} reports sandboxing is enabled — i.e. + * shell commands run inside the sandbox by default. The shell tool + * prompts on its own when escalating to unsandboxed execution, so the + * SDK's pre-call permission prompt is redundant in that case. + * + * Returns false when shell tools are not registered (the SDK's built-in + * terminal runs unsandboxed via `AgentHostConfigKey.DisableCustomTerminalTool`) + * so the standard confirmation flow is preserved. + */ + private async _isShellSandboxedByDefault(): Promise { + if (!this._shellManager) { + return false; + } + if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.DisableCustomTerminalTool) === true) { + return false; + } + return this._shellManager.getOrCreateSandboxEngine().isEnabled(); + } + /** * Builds an {@link FileEdit} preview for a write permission request. * @@ -1126,6 +1165,45 @@ export class CopilotAgentSession extends Disposable { return false; } + async requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise { + const deferred = new DeferredPromise(); + this._pendingPermissions.set(request.toolCallId, deferred); + + const displayName = getToolDisplayName(request.toolName); + const blockedDomains = request.blockedDomains?.length ? request.blockedDomains.join(', ') : undefined; + const confirmationTitle = blockedDomains + ? localize('agentHost.unsandboxedCommandConfirmation.title.blockedDomains', "Run Command Outside the Sandbox to Access {0}?", blockedDomains) + : localize('agentHost.unsandboxedCommandConfirmation.title.generic', "Run Command Outside the Sandbox?"); + const invocationMessage = request.reason + ? localize('agentHost.unsandboxedCommandConfirmation.reason', "Reason for leaving the sandbox: {0}", request.reason) + : blockedDomains + ? localize('agentHost.unsandboxedCommandConfirmation.blockedDomains', "This command needs to access blocked network domain(s): {0}.", blockedDomains) + : localize('agentHost.unsandboxedCommandConfirmation.generic', "This command needs to run outside the sandbox."); + + this._onDidSessionProgress.fire({ + kind: 'pending_confirmation', + session: this.sessionUri, + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: request.toolCallId, + toolName: request.toolName, + displayName, + invocationMessage, + toolInput: request.command, + confirmationTitle, + }, + // Intentionally omit `permissionKind: 'shell'`: that would route this + // through the shell rule-based auto-approver and silently approve + // common safe commands (`pwd`, `ls`, etc.) without prompting. + // Mirrors the workbench's sandbox-aware analyzer, which forces + // `isAutoApproveAllowed: false` whenever `requiresUnsandboxConfirmation` + // is set. + parentToolCallId: this._activeToolCalls.get(request.toolCallId)?.parentToolCallId, + }); + + return deferred.p; + } + // ---- user input handling ------------------------------------------------ /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index d78215194e59a..14004b8afb109 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -10,10 +10,18 @@ import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; import * as platform from '../../../../base/common/platform.js'; import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { IEnvironmentService } from '../../../environment/common/environment.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { ISandboxHelperService } from '../../../sandbox/common/sandboxHelperService.js'; +import type { ITerminalSandboxResolvedNetworkDomains } from '../../../sandbox/common/terminalSandboxService.js'; +import { TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; import { isZsh } from '../agentHostShellUtils.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; +import { IAgentConfigurationService } from '../agentConfigurationService.js'; /** * Maximum scrollback content (in bytes) returned to the model in tool results. @@ -101,6 +109,7 @@ export class ShellManager extends Disposable { private readonly _shells = new Map(); private readonly _toolCallShells = new Map(); private _resolvedExecutable: Promise | undefined; + private _sandboxEngine: TerminalSandboxEngine | undefined; /** Set of shell ids currently executing a command and unsafe to share. */ private readonly _busyShellIds = new Set(); /** Release listeners for shells held after a tool returns while the command is still running. */ @@ -111,9 +120,14 @@ export class ShellManager extends Disposable { constructor( private readonly _sessionUri: URI, - private readonly _workingDirectory: URI | undefined, + public readonly workingDirectory: URI | undefined, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IEnvironmentService private readonly _environmentService: IEnvironmentService, + @IProductService private readonly _productService: IProductService, + @IAgentConfigurationService private readonly _agentConfigurationService: IAgentConfigurationService, + @ISandboxHelperService private readonly _sandboxHelper: ISandboxHelperService, ) { super(); @@ -145,6 +159,32 @@ export class ShellManager extends Disposable { return this._resolvedExecutable; } + /** + * Lazily constructs the per-session {@link TerminalSandboxEngine}. The engine + * is registered for disposal alongside the {@link ShellManager}; its temp dir + * is cleaned up best-effort on dispose. + */ + getOrCreateSandboxEngine(): TerminalSandboxEngine { + if (!this._sandboxEngine) { + const sessionId = this._sessionUri.path.split('/').pop() ?? generateUuid(); + const engine = createAgentHostSandboxEngine( + this._instantiationService, + this._environmentService, + this._productService, + this._agentConfigurationService, + this._sandboxHelper, + sessionId, + this.workingDirectory, + ); + this._register(engine); + this._register(toDisposable(() => { + void engine.cleanupTempDir().catch(err => this._logService.warn('[ShellManager] Sandbox temp dir cleanup failed', err)); + })); + this._sandboxEngine = engine; + } + return this._sandboxEngine; + } + /** * Acquire a shell of the given type for executing a single command. The * returned reference holds the shell exclusively — its terminal will not @@ -194,7 +234,7 @@ export class ShellManager extends Disposable { channel: terminalUri, claim, name: shellDisplayName, - cwd: cwd ?? this._workingDirectory?.fsPath, + cwd: cwd ?? this.workingDirectory?.fsPath, }, { shell: executable, preventShellHistory: true, nonInteractive: true }); const shell: IManagedShell = { id, terminalUri, shellType, executable }; @@ -589,8 +629,21 @@ async function executeCommandWithSentinel( interface IShellToolArgs { command: string; timeout?: number; + requestUnsandboxedExecution?: boolean; + requestUnsandboxedExecutionReason?: string; } +export interface IUnsandboxedCommandConfirmationRequest { + readonly toolCallId: string; + readonly toolName: string; + readonly shellExecutable: string; + readonly command: string; + readonly reason?: string; + readonly blockedDomains?: readonly string[]; +} + +export type UnsandboxedCommandConfirmationHandler = (request: IUnsandboxedCommandConfirmationRequest) => Promise; + interface IWriteShellArgs { command: string; } @@ -612,21 +665,35 @@ export async function createShellTools( shellManager: ShellManager, terminalManager: IAgentHostTerminalManager, logService: ILogService, + confirmUnsandboxedExecution?: UnsandboxedCommandConfirmationHandler, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise[]> { const executable = await shellManager.getResolvedExecutable(); const shellType = shellTypeForExecutable(executable); + const engine = shellManager.getOrCreateSandboxEngine(); + const sandboxEnabled = await engine.isEnabled(); + const networkDomains = sandboxEnabled ? engine.getResolvedNetworkDomains() : undefined; const primaryTool: Tool = { name: shellType, description: shellType === 'bash' - ? (isZsh(executable) ? createZshModelDescription(false) : createBashModelDescription(false)) - : createPowerShellModelDescription(shellType, executable, false), + ? (isZsh(executable) ? createZshModelDescription(sandboxEnabled, networkDomains) : createBashModelDescription(sandboxEnabled, networkDomains)) + : createPowerShellModelDescription(shellType, executable, sandboxEnabled, networkDomains), parameters: { type: 'object', properties: { command: { type: 'string', description: 'The command to execute' }, timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' }, + ...(sandboxEnabled ? { + requestUnsandboxedExecution: { + type: 'boolean', + description: 'Request that this command run outside the sandbox. Only set this after first executing the command in the sandbox and observing that sandboxing caused the failure. The user will be prompted before the command runs unsandboxed.', + }, + requestUnsandboxedExecutionReason: { + type: 'string', + description: 'A short explanation of the sandboxed execution failure or blocked-domain requirement that justifies retrying outside the sandbox. Only provide this when requestUnsandboxedExecution is true.', + }, + } : {}), }, required: ['command'], }, @@ -640,7 +707,83 @@ export async function createShellTools( ); let shouldReleaseShell = true; try { - const result = await executeCommandInShell(ref.object, args.command, timeoutMs, terminalManager, logService); + let commandToRun = args.command; + if (sandboxEnabled) { + if (args.requestUnsandboxedExecution && !engine.areUnsandboxedCommandsAllowed()) { + return makeFailureResult( + 'Unsandboxed execution is disabled by the chat.agent.sandbox.allowUnsandboxedCommands setting.', + 'unsandboxed_disabled' + ); + } + + const autoApproveUnsandboxed = engine.isAutoApproveUnsandboxedCommands(); + const requestUnsandboxedConfirmation = async (blockedDomains?: readonly string[]): Promise => { + if (autoApproveUnsandboxed) { + return true; + } + if (!confirmUnsandboxedExecution) { + const blocked = blockedDomains?.join(', ') ?? '(unknown)'; + return makeFailureResult( + `Command requires approval to run outside the sandbox. Blocked domains: ${blocked}. Re-run with requestUnsandboxedExecution=true and requestUnsandboxedExecutionReason explaining why unsandboxed access is required.`, + 'sandbox_blocked' + ); + } + + const approved = await confirmUnsandboxedExecution({ + toolCallId: invocation.toolCallId, + toolName: invocation.toolName, + shellExecutable: executable, + command: args.command, + reason: args.requestUnsandboxedExecutionReason, + blockedDomains, + }); + return approved; + }; + + let wrapped = await engine.wrapCommand( + args.command, + args.requestUnsandboxedExecution, + executable, + ref.object.shellType === 'bash' ? shellManager.workingDirectory : undefined, + ); + + if (args.requestUnsandboxedExecution && !wrapped.isSandboxWrapped) { + const decision = await requestUnsandboxedConfirmation(wrapped.blockedDomains); + if (typeof decision !== 'boolean') { + return decision; + } + if (!decision) { + const blocked = wrapped.blockedDomains?.join(', ') ?? '(none)'; + return makeFailureResult( + `User declined to run command outside the sandbox. Blocked domains: ${blocked}.`, + 'sandbox_blocked' + ); + } + } + + if (wrapped.requiresUnsandboxConfirmation) { + const decision = await requestUnsandboxedConfirmation(wrapped.blockedDomains); + if (typeof decision !== 'boolean') { + return decision; + } + if (!decision) { + const blocked = wrapped.blockedDomains?.join(', ') ?? '(unknown)'; + return makeFailureResult( + `User declined to run command outside the sandbox. Blocked domains: ${blocked}.`, + 'sandbox_blocked' + ); + } + + wrapped = await engine.wrapCommand( + args.command, + true, + executable, + ref.object.shellType === 'bash' ? shellManager.workingDirectory : undefined, + ); + } + commandToRun = wrapped.command; + } + const result = await executeCommandInShell(ref.object, commandToRun, timeoutMs, terminalManager, logService); if (result.keepShellBusy) { shouldReleaseShell = false; shellManager.holdShellUntilCommandFinishes(ref.object); @@ -776,10 +919,6 @@ export async function createShellTools( return [primaryTool, readTool, writeTool, shutdownTool, listTool, redirectTool]; } -interface ITerminalSandboxResolvedNetworkDomains { - allowedDomains: string[]; - deniedDomains: string[]; -} function isWindowsPowerShell(envShell: string): boolean { return envShell.endsWith('System32\\WindowsPowerShell\\v1.0\\powershell.exe'); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index a49d0acf5c1f2..a75ca49f252dd 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -4,21 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import type { ToolInvocation, ToolResultObject } from '@github/copilot-sdk'; +import type { Tool, ToolInvocation, ToolResultObject } from '@github/copilot-sdk'; import { DeferredPromise } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import * as platform from '../../../../base/common/platform.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { IEnvironmentService } from '../../../environment/common/environment.js'; +import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { ISandboxHelperService } from '../../../sandbox/common/sandboxHelperService.js'; +import { IWindowsMxcTerminalSandboxRuntime, WindowsMxcTerminalSandboxRuntime } from '../../../sandbox/common/terminalSandboxMxcRuntime.js'; +import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; +import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; import { TerminalClaimKind, type TerminalClaim, type TerminalInfo } from '../../common/state/protocol/state.js'; import { formatTerminalText, IAgentHostTerminalManager, type ICommandFinishedEvent, type ISendTextOptions } from '../../node/agentHostTerminalManager.js'; -import { createShellTools, isMultilineCommand, ShellManager, prefixForHistorySuppression, shellTypeForExecutable } from '../../node/copilot/copilotShellTools.js'; +import { createShellTools, type IUnsandboxedCommandConfirmationRequest, isMultilineCommand, ShellManager, prefixForHistorySuppression, shellTypeForExecutable } from '../../node/copilot/copilotShellTools.js'; class TestAgentHostTerminalManager implements IAgentHostTerminalManager { declare readonly _serviceBrand: undefined; @@ -94,14 +103,87 @@ suite('CopilotShellTools', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); - function createServices(): { instantiationService: IInstantiationService; terminalManager: TestAgentHostTerminalManager } { + interface IFakeAgentConfigurationService { + readonly service: IAgentConfigurationService; + setSandboxValue(key: string, value: unknown): void; + } + + function createFakeAgentConfigurationService(initialSandbox?: Record): IFakeAgentConfigurationService { + const sandbox: Record = { ...initialSandbox }; + const configValues: Record = { [AgentHostSandboxConfigKey.Sandbox]: sandbox }; + const emitter = disposables.add(new Emitter()); + const service: IAgentConfigurationService = { + _serviceBrand: undefined, + onDidRootConfigChange: emitter.event, + getEffectiveValue: () => undefined, + getEffectiveWorkingDirectory: () => undefined, + getSessionConfigValues: () => undefined, + updateSessionConfig: () => { /* no-op */ }, + getRootValue: ((_schema: unknown, key: string) => configValues[key]) as IAgentConfigurationService['getRootValue'], + updateRootConfig: () => { /* no-op */ }, + persistRootConfig: () => { /* no-op */ }, + }; + return { + service, + setSandboxValue(key, value) { + sandbox[key] = value; + emitter.fire(); + }, + }; + } + + function createStubSandboxHelperService(): ISandboxHelperService { + // Stub used by every test that constructs a `ShellManager`. Avoids loading + // the real node-only `SandboxHelperService`, which dynamically imports + // `@microsoft/mxc-sdk` and fails to resolve in the electron renderer test + // runner used by `scripts/test.bat`. + return { + _serviceBrand: undefined, + checkSandboxDependencies: async () => undefined, + getWindowsMxcFilesystemPolicy: async () => ({ readonlyPaths: [], readwritePaths: [] }), + getWindowsMxcEnvironment: async () => [], + } satisfies ISandboxHelperService; + } + + function createServices(options?: { sandboxEnabled?: boolean; deletedFolders?: string[]; createdFiles?: Map }): { instantiationService: IInstantiationService; terminalManager: TestAgentHostTerminalManager; agentConfigurationService: IFakeAgentConfigurationService } { const terminalManager = new TestAgentHostTerminalManager(); + const initialSandboxValues: Record = {}; + if (options?.sandboxEnabled) { + initialSandboxValues[AgentHostSandboxKey.Enabled] = AgentSandboxEnabledValue.On; + // Windows uses a separate enable key; the engine treats + // `Enabled=On` on non-Windows and `WindowsEnabled=AllowNetwork` + // on Windows as "sandbox active". Set both so tests exercise + // the sandbox path on every OS. + initialSandboxValues[AgentHostSandboxKey.WindowsEnabled] = AgentSandboxEnabledValue.AllowNetwork; + } + const agentConfigurationService = createFakeAgentConfigurationService(initialSandboxValues); const services = new ServiceCollection(); services.set(ILogService, new NullLogService()); services.set(IAgentHostTerminalManager, terminalManager); + services.set(IAgentConfigurationService, agentConfigurationService.service); + services.set(IFileService, { + createFile: async (uri: URI, content: VSBuffer) => { + if (options?.createdFiles) { + options.createdFiles.set(uri.path, content.toString()); + } + return ({} as never); + }, + createFolder: async () => ({} as never), + del: async (uri: URI) => { options?.deletedFolders?.push(uri.path); }, + realpath: async () => undefined, + } as Partial as IFileService); + services.set(IEnvironmentService, { + userHome: URI.file('/home/test-user'), + } as Partial & { userHome: URI } as IEnvironmentService); + services.set(IProductService, { dataFolderName: '.test-data' } as Partial as IProductService); + // Stub the sandbox helper so the engine never imports `@microsoft/mxc-sdk` + // (a node-only dynamic import that fails to resolve in the electron + // renderer test runner used by `scripts/test.bat` on Windows CI). + services.set(ISandboxHelperService, createStubSandboxHelperService()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); services.set(IInstantiationService, instantiationService); - return { instantiationService, terminalManager }; + services.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); + return { instantiationService, terminalManager, agentConfigurationService }; } async function waitForSentTexts(terminalManager: TestAgentHostTerminalManager, count: number): Promise { @@ -212,6 +294,7 @@ suite('CopilotShellTools', () => { const services = new ServiceCollection(); services.set(ILogService, new NullLogService()); services.set(IAgentHostTerminalManager, terminalManager); + services.set(ISandboxHelperService, createStubSandboxHelperService()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); services.set(IInstantiationService, instantiationService); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); @@ -231,6 +314,7 @@ suite('CopilotShellTools', () => { const services = new ServiceCollection(); services.set(ILogService, new NullLogService()); services.set(IAgentHostTerminalManager, terminalManager); + services.set(ISandboxHelperService, createStubSandboxHelperService()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); services.set(IInstantiationService, instantiationService); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); @@ -557,4 +641,324 @@ suite('CopilotShellTools', () => { assert.strictEqual(terminalManager.writes[0].data, 'answer\r'); shellRef.dispose(); }); + + test('getOrCreateSandboxEngine returns the same engine across calls', async () => { + const { instantiationService } = createServices(); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + + const engineA = shellManager.getOrCreateSandboxEngine(); + const engineB = shellManager.getOrCreateSandboxEngine(); + + assert.strictEqual(engineA, engineB, 'Sandbox engine should be cached across calls'); + }); + + test('primary shell tool schema only exposes requestUnsandboxedExecution params when the sandbox is enabled', async () => { + const enabled = createServices({ sandboxEnabled: true }); + const enabledShell = disposables.add(enabled.instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-enabled'), undefined)); + const enabledTools = await createShellTools(enabledShell, enabled.terminalManager, new NullLogService()); + const enabledPrimary = enabledTools[0] as Tool; + const enabledSchema = enabledPrimary.parameters as { properties: Record }; + const enabledPropertyNames = Object.keys(enabledSchema.properties); + + assert.ok(enabledPropertyNames.includes('requestUnsandboxedExecution'), 'Sandbox-enabled schema should expose requestUnsandboxedExecution'); + assert.ok(enabledPropertyNames.includes('requestUnsandboxedExecutionReason'), 'Sandbox-enabled schema should expose requestUnsandboxedExecutionReason'); + + const disabled = createServices(); + const disabledShell = disposables.add(disabled.instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-disabled'), undefined)); + const disabledTools = await createShellTools(disabledShell, disabled.terminalManager, new NullLogService()); + const disabledPrimary = disabledTools[0] as Tool; + const disabledSchema = disabledPrimary.parameters as { properties: Record }; + const disabledPropertyNames = Object.keys(disabledSchema.properties); + + assert.ok(!disabledPropertyNames.includes('requestUnsandboxedExecution'), 'Sandbox-disabled schema should not expose requestUnsandboxedExecution'); + assert.ok(!disabledPropertyNames.includes('requestUnsandboxedExecutionReason'), 'Sandbox-disabled schema should not expose requestUnsandboxedExecutionReason'); + }); + + test('primary shell tool sends commands unwrapped when the sandbox is disabled', async () => { + const { instantiationService, terminalManager } = createServices(); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'echo hello', timeout: 1 }, + }; + await bashTool.handler({ command: 'echo hello', timeout: 1 }, invocation); + + const sentCommand = terminalManager.sentTexts[0]?.data ?? ''; + assert.ok(sentCommand.includes('echo hello'), `Expected the raw command to be sent. Sent: ${sentCommand}`); + assert.ok(!sentCommand.includes('sandbox-runtime'), `Sandbox wrapper should not be applied when sandbox is disabled. Sent: ${sentCommand}`); + }); + + test('primary shell tool wraps commands through the sandbox engine when the sandbox is enabled', async function () { + const { instantiationService, terminalManager } = createServices({ sandboxEnabled: true }); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'echo hello', timeout: 1 }, + }; + await bashTool.handler({ command: 'echo hello', timeout: 1 }, invocation); + + const sentCommand = terminalManager.sentTexts[0]?.data ?? ''; + // POSIX wraps via `sandbox-runtime` and embeds the user command; + // Windows wraps via the MXC executable and carries the user command + // in the JSON config file referenced by the wrapper. + if (platform.isWindows) { + assert.ok(sentCommand.includes('wxc-exec'), `Expected the command to be wrapped by the MXC runtime. Sent: ${sentCommand}`); + } else { + assert.ok(sentCommand.includes('sandbox-runtime'), `Expected the command to be wrapped by the sandbox runtime. Sent: ${sentCommand}`); + assert.ok(sentCommand.includes('echo hello'), `Wrapped command should still contain the user command. Sent: ${sentCommand}`); + } + }); + + test('primary shell tool writes a sandbox config exposing the working directory as writable', async () => { + // Cross-platform smoke test: enabling the sandbox should result in a sandbox config file + // being written, and the session's working directory should be a writable path in that + // config. The JSON shape differs between POSIX (`filesystem.allowWrite`) and the Windows + // MXC runtime (`filesystem.readwritePaths`). + const createdFiles = new Map(); + const workingDirectory = URI.file('/workspace/test-workspace'); + const { instantiationService, terminalManager } = createServices({ sandboxEnabled: true, createdFiles }); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), workingDirectory)); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'echo hello', timeout: 1 }, + }; + await bashTool.handler({ command: 'echo hello', timeout: 1 }, invocation); + + const sandboxConfigEntry = [...createdFiles.entries()].find(([path]) => /vscode-sandbox-settings-.*\.json$/.test(path)); + assert.ok(sandboxConfigEntry, `Expected a sandbox config file to be written. Files: ${[...createdFiles.keys()].join(', ')}`); + const config = JSON.parse(sandboxConfigEntry[1]); + const writablePaths: string[] = platform.isWindows ? config.filesystem.readwritePaths : config.filesystem.allowWrite; + assert.ok(Array.isArray(writablePaths), `Expected writable paths array. Got: ${JSON.stringify(config.filesystem)}`); + const expectedPath = platform.isWindows ? '\\workspace\\test-workspace' : '/workspace/test-workspace'; + assert.ok(writablePaths.includes(expectedPath), `Expected working directory in writable paths. Got: ${JSON.stringify(writablePaths)}`); + }); + + test('primary shell tool merges configured filesystem allowRead paths into the sandbox config', async () => { + // Cross-platform: pick the OS-specific filesystem setting key and verify the configured + // allowRead path lands in the rendered sandbox config (POSIX `filesystem.allowRead` / + // Windows MXC `filesystem.readonlyPaths`). + const createdFiles = new Map(); + const configuredReadPath = platform.isWindows ? 'C:\\tools\\custom' : '/tools/custom'; + const fileSystemKey = platform.isWindows + ? AgentHostSandboxKey.WindowsFileSystem + : platform.isMacintosh ? AgentHostSandboxKey.MacFileSystem : AgentHostSandboxKey.LinuxFileSystem; + const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true, createdFiles }); + agentConfigurationService.setSandboxValue(fileSystemKey, { allowRead: [configuredReadPath] }); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), URI.file('/workspace/test-workspace'))); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'echo hello', timeout: 1 }, + }; + await bashTool.handler({ command: 'echo hello', timeout: 1 }, invocation); + + const sandboxConfigEntry = [...createdFiles.entries()].find(([path]) => /vscode-sandbox-settings-.*\.json$/.test(path)); + assert.ok(sandboxConfigEntry, `Expected a sandbox config file to be written. Files: ${[...createdFiles.keys()].join(', ')}`); + const config = JSON.parse(sandboxConfigEntry[1]); + const readablePaths: string[] = platform.isWindows ? config.filesystem.readonlyPaths : config.filesystem.allowRead; + assert.ok(Array.isArray(readablePaths), `Expected readable paths array. Got: ${JSON.stringify(config.filesystem)}`); + assert.ok(readablePaths.includes(configuredReadPath), `Expected configured read path in readable paths. Got: ${JSON.stringify(readablePaths)}`); + }); + + test('primary shell tool requests confirmation before rerunning outside the sandbox', async function () { + // The Windows sandbox only exposes Off/AllowNetwork — there is no "enabled but network-blocked" + // state, so `requiresUnsandboxConfirmation` is unreachable on Windows. + if (platform.isWindows) { + this.skip(); + } + const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); + // `requiresUnsandboxConfirmation` only fires when unsandboxed commands are allowed AND a + // blocked domain is detected — otherwise the engine keeps the command sandboxed. + agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); + terminalManager.commandDetectionSupported = true; + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; + const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + confirmationRequests.push(request); + return true; + }); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'curl https://example.com' }, + }; + const resultPromise = bashTool.handler({ command: 'curl https://example.com' }, invocation); + await terminalManager.commandFinishedListenerRegistered.p; + terminalManager.fireCommandFinished({ + commandId: 'cmd-1', + exitCode: 0, + command: 'curl https://example.com', + output: '', + }); + const result = await resultPromise as ToolResultObject; + + assert.strictEqual(confirmationRequests.length, 1); + assert.deepStrictEqual(confirmationRequests[0]?.blockedDomains, ['example.com']); + assert.ok(terminalManager.sentTexts.length >= 1, 'Approved command should be sent to the terminal unsandboxed'); + assert.ok(terminalManager.sentTexts.every(entry => !entry.data.includes('sandbox-runtime')), 'No wrapped sandbox-runtime command should be sent after approval'); + assert.strictEqual(result.resultType, 'success'); + }); + + test('primary shell tool returns sandbox_blocked when user declines unsandboxed rerun', async function () { + // See above: the Windows sandbox never reports blocked domains, so this confirmation flow + // is unreachable on Windows. + if (platform.isWindows) { + this.skip(); + } + const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); + agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async () => false); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'curl https://example.com' }, + }; + const result = await bashTool.handler({ command: 'curl https://example.com' }, invocation) as ToolResultObject; + + assert.strictEqual(result.resultType, 'failure'); + assert.strictEqual(result.error, 'sandbox_blocked'); + assert.match(result.textResultForLlm ?? '', /declined/i); + assert.strictEqual(terminalManager.sentTexts.length, 0); + }); + + test('primary shell tool asks for confirmation when requestUnsandboxedExecution is explicitly set', async function () { + const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); + agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; + const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + confirmationRequests.push(request); + return false; + }); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { + command: 'echo hello', + requestUnsandboxedExecution: true, + requestUnsandboxedExecutionReason: 'sandbox blocked required syscall', + }, + }; + const result = await bashTool.handler({ + command: 'echo hello', + requestUnsandboxedExecution: true, + requestUnsandboxedExecutionReason: 'sandbox blocked required syscall', + }, invocation) as ToolResultObject; + + assert.strictEqual(confirmationRequests.length, 1); + assert.strictEqual(confirmationRequests[0]?.reason, 'sandbox blocked required syscall'); + assert.strictEqual(result.resultType, 'failure'); + assert.strictEqual(result.error, 'sandbox_blocked'); + assert.match(result.textResultForLlm ?? '', /declined/i); + assert.strictEqual(terminalManager.sentTexts.length, 0); + }); + + test('primary shell tool returns unsandboxed_disabled when allowUnsandboxedCommands is off', async function () { + const { instantiationService, terminalManager } = createServices({ sandboxEnabled: true }); + // `chat.agent.sandbox.allowUnsandboxedCommands` is intentionally not set, + // so the engine would silently re-sandbox the command. The shell tool + // must surface a dedicated failure instead. + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; + const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + confirmationRequests.push(request); + return true; + }); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { + command: 'echo hello', + requestUnsandboxedExecution: true, + requestUnsandboxedExecutionReason: 'sandbox blocked required syscall', + }, + }; + const result = await bashTool.handler({ + command: 'echo hello', + requestUnsandboxedExecution: true, + requestUnsandboxedExecutionReason: 'sandbox blocked required syscall', + }, invocation) as ToolResultObject; + + assert.strictEqual(result.resultType, 'failure'); + assert.strictEqual(result.error, 'unsandboxed_disabled'); + assert.match(result.textResultForLlm ?? '', /allowUnsandboxedCommands/); + assert.strictEqual(confirmationRequests.length, 0, 'No confirmation should have been requested'); + assert.strictEqual(terminalManager.sentTexts.length, 0, 'Disallowed command should not be sent to the terminal'); + }); + + test('primary shell tool skips confirmation when autoApproveUnsandboxedCommands is enabled', async function () { + const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); + agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); + agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AutoApproveUnsandboxedCommands, true); + terminalManager.commandDetectionSupported = true; + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; + const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { + confirmationRequests.push(request); + return true; + }); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'curl https://example.com' }, + }; + const resultPromise = bashTool.handler({ command: 'curl https://example.com' }, invocation); + await terminalManager.commandFinishedListenerRegistered.p; + terminalManager.fireCommandFinished({ + commandId: 'cmd-1', + exitCode: 0, + command: 'curl https://example.com', + output: '', + }); + const result = await resultPromise as ToolResultObject; + + assert.strictEqual(confirmationRequests.length, 0, 'No confirmation should have been requested when auto-approve is enabled'); + assert.ok(terminalManager.sentTexts.length >= 1, 'Auto-approved command should be sent to the terminal unsandboxed'); + assert.ok(terminalManager.sentTexts.every(entry => !entry.data.includes('sandbox-runtime')), 'Auto-approved command should run unsandboxed'); + assert.strictEqual(result.resultType, 'success'); + }); }); diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 954ff03a79510..7ff89bc556efa 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -8,9 +8,9 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { posix, win32 } from '../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../base/common/platform.js'; +import { arch } from '../../../base/common/process.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { IConfigurationChangeEvent, IConfigurationService } from '../../configuration/common/configuration.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { matchesDomainPattern, normalizeDomain } from '../../networkFilter/common/domainMatcher.js'; @@ -78,6 +78,19 @@ export interface ITerminalSandboxEngineHost { getWindowsMxcFilesystemPolicy(): Promise; /** Resolves host environment variables needed by the Windows MXC process container. */ getWindowsMxcEnvironment(): Promise; + /** + * Returns the effective value of a sandbox-related configuration setting, + * or `undefined` when the setting is not configured. Implementations are + * responsible for mapping deprecated keys to modern ones (the engine + * only ever asks for the modern setting IDs). + */ + getSandboxSetting(settingId: string): T | undefined; + /** + * Fires when any value returned by {@link getSandboxSetting} may have + * changed. The engine invalidates its sandbox-config file on each event. + * Implementations should pre-filter to sandbox-relevant keys. + */ + readonly onDidChangeSandboxSettings: Event; } /** @@ -120,16 +133,13 @@ export class TerminalSandboxEngine extends Disposable { constructor( private readonly _host: ITerminalSandboxEngineHost, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @IWindowsMxcTerminalSandboxRuntime private readonly _windowsMxcRuntime: IWindowsMxcTerminalSandboxRuntime, ) { super(); - this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, (e: IConfigurationChangeEvent | undefined) => { - if (this._affectsSandboxConfiguration(e)) { - this.setNeedsForceUpdateConfigFile(); - } + this._register(Event.runAndSubscribe(this._host.onDidChangeSandboxSettings, () => { + this.setNeedsForceUpdateConfigFile(); })); this._register(this._host.onDidChangeRoots(() => this.setNeedsForceUpdateConfigFile())); } @@ -145,6 +155,15 @@ export class TerminalSandboxEngine extends Disposable { return this._isSandboxAllowNetworkConfigured(); } + areUnsandboxedCommandsAllowed(): boolean { + return this._areUnsandboxedCommandsAllowed(); + } + + isAutoApproveUnsandboxedCommands(): boolean { + return this._areUnsandboxedCommandsAllowed() + && this._getSettingValue(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands) === true; + } + async getOS(): Promise { this._os = await this._host.getOS(); return this._os; @@ -159,8 +178,8 @@ export class TerminalSandboxEngine extends Disposable { } getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { - const allowedDomains = this._getSettingValue(AgentNetworkDomainSettingId.AllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains) ?? []; - const deniedDomains = this._getSettingValue(AgentNetworkDomainSettingId.DeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains) ?? []; + const allowedDomains = this._getSettingValue(AgentNetworkDomainSettingId.AllowedNetworkDomains) ?? []; + const deniedDomains = this._getSettingValue(AgentNetworkDomainSettingId.DeniedNetworkDomains) ?? []; return { allowedDomains, deniedDomains }; } @@ -340,27 +359,6 @@ export class TerminalSandboxEngine extends Disposable { // ---- private helpers ---------------------------------------------------- - private _affectsSandboxConfiguration(e: IConfigurationChangeEvent | undefined): boolean { - if (!e) { - return true; // initial run-and-subscribe - } - return e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxEnabled) - || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxWindowsEnabled) - || e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) - || e.affectsConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains) - || e.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains) - || e.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains) - || e.affectsConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains) - || e.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains) - || e.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains) - || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxLinuxFileSystem) - || e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxLinuxFileSystem) - || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxMacFileSystem) - || e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxMacFileSystem) - || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxWindowsFileSystem) - || e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxAdvancedRuntime); - } - private async _checkSandboxDependencies(forceRefresh = false): Promise { const os = await this.getOS(); if (os === OperatingSystem.Windows) { @@ -509,7 +507,7 @@ export class TerminalSandboxEngine extends Disposable { return this._getSandboxConfiguredWindowsEnabledValue() === AgentSandboxEnabledValue.AllowNetwork; } const value = this._getSandboxConfiguredEnabledValue(); - return value === true || value === AgentSandboxEnabledValue.On || value === AgentSandboxEnabledValue.AllowNetwork; + return value === AgentSandboxEnabledValue.On || value === AgentSandboxEnabledValue.AllowNetwork; } private async _resolveRuntimeInfo(): Promise { @@ -523,7 +521,9 @@ export class TerminalSandboxEngine extends Disposable { this._runAsNode = runtimeInfo.runAsNode ?? false; this._userHome = await this._host.getUserHome(); this._srtPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'sandbox-runtime', 'dist', 'cli.js'); - this._rgPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'); + const rgPlatform = this._os === OperatingSystem.Windows ? 'win32' : this._os === OperatingSystem.Macintosh ? 'darwin' : 'linux'; + const rgBinary = this._os === OperatingSystem.Windows ? 'rg.exe' : 'rg'; + this._rgPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', `${rgPlatform}-${arch}`, rgBinary); this._mxcPath = this._windowsMxcRuntime.getExecutablePath(this._appRoot, runtimeInfo.arch); } @@ -537,10 +537,10 @@ export class TerminalSandboxEngine extends Disposable { const allowNetwork = await this.isSandboxAllowNetworkEnabled(); const linuxFileSystemSetting = this._os === OperatingSystem.Linux - ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, AgentSandboxSettingId.DeprecatedAgentSandboxLinuxFileSystem) ?? {} + ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxLinuxFileSystem) ?? {} : {}; const macFileSystemSetting = this._os === OperatingSystem.Macintosh - ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxMacFileSystem, AgentSandboxSettingId.DeprecatedAgentSandboxMacFileSystem) ?? {} + ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxMacFileSystem) ?? {} : {}; const windowsFileSystemSetting = this._os === OperatingSystem.Windows ? this._getSettingValue(AgentSandboxSettingId.AgentSandboxWindowsFileSystem) ?? {} @@ -774,8 +774,8 @@ export class TerminalSandboxEngine extends Disposable { return root ? [this._getUriPath(root)] : []; } - private _getSandboxConfiguredEnabledValue(): AgentSandboxEnabledValue | boolean { - return this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) ?? AgentSandboxEnabledValue.Off; + private _getSandboxConfiguredEnabledValue(): AgentSandboxEnabledValue { + return this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled) ?? AgentSandboxEnabledValue.Off; } private _getSandboxConfiguredWindowsEnabledValue(): AgentSandboxEnabledValue { @@ -793,25 +793,7 @@ export class TerminalSandboxEngine extends Disposable { return this._getSettingValue(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands) === true; } - private _getSettingValue(settingId: AgentSandboxSettingId | AgentNetworkDomainSettingId, ...deprecatedSettingIds: (AgentSandboxSettingId | AgentNetworkDomainSettingId)[]): T | undefined { - const setting = this._configurationService.inspect(settingId); - if (setting.userValue !== undefined) { - return setting.value; - } - if (deprecatedSettingIds.length > 0) { - const userConfiguredKeys = this._configurationService.keys().user; - for (const deprecatedId of deprecatedSettingIds) { - const deprecated = this._configurationService.inspect(deprecatedId); - // Some deprecated settings are parent keys of newer settings, for example - // `chat.agent.sandbox` and `chat.agent.sandbox.fileSystem.linux`. Inspecting the - // parent key can return the namespace object even when the deprecated key itself - // was not configured, so only fall back when the exact deprecated key exists. - if (deprecated.userValue !== undefined && userConfiguredKeys.includes(deprecatedId)) { - this._logService.warn(`TerminalSandboxEngine: Using deprecated setting ${deprecatedId} because ${settingId} is not set. Please update your settings to use ${settingId} instead.`); - return deprecated.value; - } - } - } - return setting.value; + private _getSettingValue(settingId: AgentSandboxSettingId | AgentNetworkDomainSettingId): T | undefined { + return this._host.getSandboxSetting(settingId); } } diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index b77cbc94c5a04..2eedb768c69e3 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -7,10 +7,9 @@ import { deepStrictEqual, ok, strictEqual } from 'assert'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; import { OperatingSystem } from '../../../../base/common/platform.js'; +import { arch } from '../../../../base/common/process.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { IFileService } from '../../../files/common/files.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; @@ -23,12 +22,18 @@ suite('TerminalSandboxEngine', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; - let configurationService: TestConfigurationService; + let sandboxSettings: Map; + let sandboxSettingsEmitter: Emitter; let fileService: MockFileService; let createdFiles: Map; let createFileCount: number; let createdFolders: string[]; + function setSandboxSetting(key: string, value: unknown): void { + sandboxSettings.set(key, value); + sandboxSettingsEmitter.fire(); + } + class MockFileService { private readonly _realpaths = new Map(); @@ -76,6 +81,8 @@ suite('TerminalSandboxEngine', () => { checkSandboxDependencies: (): Promise => Promise.resolve({ bubblewrapInstalled: true, socatInstalled: true }), getWindowsMxcFilesystemPolicy: (): Promise => Promise.resolve(undefined), getWindowsMxcEnvironment: (): Promise => Promise.resolve(undefined), + getSandboxSetting: (settingId: string): T | undefined => sandboxSettings.has(settingId) ? sandboxSettings.get(settingId) as T : undefined, + onDidChangeSandboxSettings: sandboxSettingsEmitter.event, ...overrides, }; return Object.assign(host, { rootsEmitter }); @@ -100,7 +107,7 @@ suite('TerminalSandboxEngine', () => { } function enableWindowsSandbox(): void { - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.AllowNetwork); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxEnabledValue.AllowNetwork); } setup(() => { @@ -108,12 +115,12 @@ suite('TerminalSandboxEngine', () => { createFileCount = 0; createdFolders = []; instantiationService = store.add(new TestInstantiationService()); - configurationService = new TestConfigurationService(); + sandboxSettings = new Map(); + sandboxSettingsEmitter = store.add(new Emitter()); fileService = new MockFileService(); - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + sandboxSettings.set(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); - instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); @@ -145,6 +152,16 @@ suite('TerminalSandboxEngine', () => { ok(!wrapped.command.startsWith('ELECTRON_RUN_AS_NODE='), `Did not expect ELECTRON_RUN_AS_NODE prefix. Actual: ${wrapped.command}`); }); + test('wrapCommand adds ripgrep-universal platform-arch bin directory to PATH', async () => { + const host = createHost(); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); + await engine.getSandboxConfigPath(); + + const wrapped = await engine.wrapCommand('echo hi'); + + ok(wrapped.command.includes(`/app/node_modules/@vscode/ripgrep-universal/bin/linux-${arch}`), `Expected ripgrep-universal platform-arch path in command. Actual: ${wrapped.command}`); + }); + test('onDidChangeRoots triggers a sandbox config rewrite on the next wrap', async () => { let writeRoots: URI[] = [URI.file('/workspace-a')]; const host = createHost({ @@ -168,7 +185,7 @@ suite('TerminalSandboxEngine', () => { }); test('resolves filesystem paths and expands home on Linux when writing the config', async () => { - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { allowRead: ['~/read-link'], allowWrite: ['/write-link'], denyRead: ['~/deny-read-link'], @@ -200,7 +217,7 @@ suite('TerminalSandboxEngine', () => { }); test('keeps filesystem paths without symlinks when writing the config', async () => { - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { allowRead: ['~/read-plain'], allowWrite: ['/write-plain'], denyRead: ['~/deny-read-plain'], @@ -226,7 +243,7 @@ suite('TerminalSandboxEngine', () => { const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); // Disable the sandbox so the engine never creates a temp dir. - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); strictEqual(engine.getTempDir(), undefined); await engine.cleanupTempDir(); // must not throw @@ -242,7 +259,7 @@ suite('TerminalSandboxEngine', () => { }); test('isEnabled returns true on Windows when Windows sandbox setting allows network even if global sandboxing is off', async () => { - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.Off); enableWindowsSandbox(); const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); @@ -284,7 +301,7 @@ suite('TerminalSandboxEngine', () => { test('wrapCommand applies Windows filesystem setting to MXC config', async () => { enableWindowsSandbox(); - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { allowWrite: ['C:\\configured\\write'], allowRead: ['C:\\configured\\read'], denyRead: ['C:\\configured\\secret'], @@ -340,7 +357,7 @@ suite('TerminalSandboxEngine', () => { test('allowNetwork maps to MXC allow network config on Windows', async () => { enableWindowsSandbox(); - configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); const host = createWindowsHost(); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, host)); diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 7aeeb120987f9..5dedaecb00b5c 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -462,15 +462,15 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { private dimColor(color: string): string { - // Blend a CSS color with black at 30% opacity to match the - // dimming overlay of `rgba(0, 0, 0, 0.3)` used by modals. + // Blend a CSS color with black at 50% opacity to match the + // dimming overlay of `rgba(0, 0, 0, 0.5)` used by modals. const parsed = Color.Format.CSS.parse(color); if (!parsed) { return color; } - const dimFactor = 0.7; // 1 - 0.3 opacity of black overlay + const dimFactor = 0.5; // 1 - 0.5 opacity of black overlay const r = Math.round(parsed.rgba.r * dimFactor); const g = Math.round(parsed.rgba.g * dimFactor); const b = Math.round(parsed.rgba.b * dimFactor); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 1df687735333a..c261f0f093291 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -57,6 +57,7 @@ Each provider lives in its own subfolder and implements `ISessionsProvider`: src/vs/sessions/contrib/providers/ ├── agentHost/ # Local agent host provider ├── copilotChatSessions/ # Copilot chat sessions provider (wraps ChatSessionsService) +├── localChatSessions/ # Local in-process VS Code chat sessions provider └── remoteAgentHost/ # Remote agent host provider (one instance per connection) ``` @@ -65,6 +66,7 @@ Providers can import from all layers below them (core, services, non-provider co ### Provider-Specific Documentation - [Copilot Chat Sessions Provider](contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md) — wraps `ChatSessionsService`, metadata contract, workspace derivation +- [Local Chat Sessions Provider](contrib/providers/localChatSessions/LOCAL_CHAT_SESSIONS_PROVIDER.md) — local in-process VS Code chat, self-managed session list via storage - [Remote Agent Host Provider](contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md) — remote connections, per-host provider instances ### Related Specifications diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index f217dc84c9b83..39084b88b038a 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -188,10 +188,25 @@ } .agent-sessions-workbench .part.editor .multiDiffEntry { - padding: 6px 8px 0; + padding: 0 8px; box-sizing: border-box; } +.agent-sessions-workbench .part.editor .multiDiffEntry .header { + cursor: pointer; + background: transparent; + position: relative; +} + +.agent-sessions-workbench .part.editor .multiDiffEntry .header::before { + content: ''; + position: absolute; + inset: 0; + background: var(--vscode-agentsPanel-background); + z-index: -1; + pointer-events: none; +} + .agent-sessions-workbench .part.editor .multiDiffEntry .header-content { border-radius: 6px; border: none; @@ -209,10 +224,6 @@ line-height: 22px; } -.agent-sessions-workbench .part.editor .multiDiffEntry .header { - cursor: pointer; -} - .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus, .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible { outline: none; @@ -223,10 +234,6 @@ box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); } -.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content { - background: color-mix(in srgb, var(--vscode-sideBarSectionHeader-background) 95%, var(--vscode-foreground)); -} - .agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions { opacity: 0; pointer-events: none; @@ -249,6 +256,19 @@ .agent-sessions-workbench .part.editor .diff-hidden-lines .center { box-shadow: none; + overflow: hidden; +} + +/* The hidden-lines bar is split into two halves (original / modified) sitting + * side by side. Round only the outer corners so the pair forms a single pill. */ +.agent-sessions-workbench .part.editor .editor.original .diff-hidden-lines .center { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} + +.agent-sessions-workbench .part.editor .editor.modified .diff-hidden-lines .center { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; } .agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status { diff --git a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css index db84e8a5da921..29a029cc0d803 100644 --- a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css +++ b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css @@ -181,6 +181,6 @@ .run-script-action-modal-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.5); z-index: 2549; } diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts index 101d0e8b92090..421cb155122be 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileSessionTypePicker.ts @@ -62,9 +62,19 @@ export class MobileSessionTypePicker extends SessionTypePicker { } // Build sheet items — composite id is `providerId\u0000sessionTypeId` - // so we can map back to the right provider on selection. Use the - // provider's label as a section title on the first item of each - // group so the sheet visually separates providers. + // so we can map back to the right provider on selection. Show the + // provider's label as a section title only for provider groups + // that contain at least one duplicated session type label. + const labelCounts = new Map(); + for (const { sessionType } of this._folderSessionTypes) { + labelCounts.set(sessionType.label, (labelCounts.get(sessionType.label) ?? 0) + 1); + } + const providersWithDuplicates = new Set(); + for (const { providerId, sessionType } of this._folderSessionTypes) { + if ((labelCounts.get(sessionType.label) ?? 0) > 1) { + providersWithDuplicates.add(providerId); + } + } const sheetItems: IMobilePickerSheetItem[] = []; let lastProviderId: string | undefined; for (const { providerId, sessionType } of this._folderSessionTypes) { @@ -75,7 +85,7 @@ export class MobileSessionTypePicker extends SessionTypePicker { label: sessionType.label, icon: sessionType.icon, checked: providerId === this._picked?.providerId && sessionType.id === this._picked?.sessionTypeId, - sectionTitle: isFirstInGroup ? (this._sessionsProvidersService.getProvider(providerId)?.label ?? providerId) : undefined, + sectionTitle: providersWithDuplicates.has(providerId) && isFirstInGroup ? (this._sessionsProvidersService.getProvider(providerId)?.label ?? providerId) : undefined, }); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 3b8292999ab05..5dc2db449b2a4 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -186,9 +186,20 @@ export class SessionTypePicker extends Disposable { return; } - // Group items by provider so the dropdown shows a provider header - // followed by that provider's types. Insert a separator between - // adjacent providers' types so the grouping is visually clear. + // Determine which providers contain at least one duplicated label. + // Only those providers need a group title for disambiguation. + const labelCounts = new Map(); + for (const { sessionType } of folderTypes) { + labelCounts.set(sessionType.label, (labelCounts.get(sessionType.label) ?? 0) + 1); + } + const providersWithDuplicates = new Set(); + for (const { providerId, sessionType } of folderTypes) { + if ((labelCounts.get(sessionType.label) ?? 0) > 1) { + providersWithDuplicates.add(providerId); + } + } + const hasDuplicateLabels = providersWithDuplicates.size > 0; + const providersService = this.sessionsProvidersService; const groupedItems: IActionListItem[] = []; let lastProviderId: string | undefined; @@ -196,9 +207,6 @@ export class SessionTypePicker extends Disposable { const provider = providersService.getProvider(providerId); const groupTitle = provider?.label ?? providerId; const isFirstInGroup = providerId !== lastProviderId; - if (isFirstInGroup && lastProviderId !== undefined) { - groupedItems.push({ kind: ActionListItemKind.Separator, label: '' }); - } lastProviderId = providerId; const isCurrent = this._picked?.providerId === providerId && this._picked?.sessionTypeId === sessionType.id; const item: ISessionTypePickerItem = isCurrent @@ -207,9 +215,12 @@ export class SessionTypePicker extends Disposable { groupedItems.push({ kind: ActionListItemKind.Action, label: sessionType.label, - group: { + group: providersWithDuplicates.has(providerId) ? { title: isFirstInGroup ? groupTitle : '', icon: sessionType.icon, + } : { + title: '', + icon: sessionType.icon, }, item, }); @@ -236,7 +247,7 @@ export class SessionTypePicker extends Disposable { getAriaLabel: (item) => item.label ?? '', getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), }, - { showGroupTitleOnFirstItem: true }, + { showGroupTitleOnFirstItem: hasDuplicateLabels }, ); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts index 1fce44dacf55b..8c5e15884c7dd 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -5,19 +5,13 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; -import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING, LOCAL_SESSION_ENABLED_SETTING, LocalSessionType } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../../nls.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ForkConversationAction } from '../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; -import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { raceTimeout } from '../../../../../base/common/async.js'; Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', @@ -34,13 +28,6 @@ Registry.as(ConfigurationExtensions.Configuration).regis experiment: { mode: 'startup' }, description: localize('sessions.chat.claudeAgent.enabled', "Enable Claude Agent sessions in the Agents window. Start and resume agentic coding sessions powered by Anthropic's Claude Agent SDK directly. Uses your existing Copilot subscription."), }, - [LOCAL_SESSION_ENABLED_SETTING]: { - type: 'boolean', - default: true, - tags: ['experimental'], - experiment: { mode: 'startup' }, - description: localize('sessions.chat.localAgent.enabled', "Enable Local VS Code chat sessions in the Agents Window."), - }, }, }); @@ -69,43 +56,3 @@ class DefaultSessionsProviderContribution extends Disposable implements IWorkben } registerWorkbenchContribution2(DefaultSessionsProviderContribution.ID, DefaultSessionsProviderContribution, WorkbenchPhase.AfterRestored); - -registerAction2(class extends ForkConversationAction { - protected override _openForkedSession(instantiationService: IInstantiationService, parentSessionResource: URI, forkedSessionResource: URI): Promise { - return instantiationService.invokeFunction(async accessor => { - const sessionsManagementService = accessor.get(ISessionsManagementService); - const logService = accessor.get(ILogService); - - const parentSession = sessionsManagementService.getSession(parentSessionResource); - if (!parentSession) { - logService.error(`Parent session ${parentSessionResource.toString()} not found when forking conversation`); - return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); - } - - if (parentSession.sessionType !== LocalSessionType.id) { - return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); - } - - // Local sessions — wait for the forked session to appear, but - // bound the wait so a missing session does not hang forever. - if (!sessionsManagementService.getSession(forkedSessionResource)) { - let listener: IDisposable | undefined; - const appeared = await raceTimeout(new Promise(resolve => { - listener = sessionsManagementService.onDidChangeSessions(() => { - if (sessionsManagementService.getSession(forkedSessionResource)) { - resolve(true); - } - }); - }), 30_000); - listener?.dispose(); - - if (!appeared) { - logService.error(`Forked session ${forkedSessionResource.toString()} did not appear within timeout`); - return; - } - } - await sessionsManagementService.openSession(forkedSessionResource); - - }); - } -}); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index da2aab5699aa9..01b6dbbfacaf2 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -30,7 +30,8 @@ import { ISessionsManagementService } from '../../../../services/sessions/common import { SessionItemContextMenuId } from '../../../sessions/browser/views/sessionsList.js'; import { BranchPicker } from './branchPicker.js'; import { ClaudePermissionModePicker } from './claudePermissionModePicker.js'; -import { ClaudeCodeSessionType, COPILOT_PROVIDER_ID, CopilotChatSessionsProvider, CopilotCloudSessionType, LocalSessionType } from './copilotChatSessionsProvider.js'; +import { ClaudeCodeSessionType, COPILOT_PROVIDER_ID, CopilotChatSessionsProvider, CopilotCloudSessionType } from './copilotChatSessionsProvider.js'; +import { LocalSessionType } from '../../localChatSessions/browser/localChatSessionsProvider.js'; import { IsolationPicker } from './isolationPicker.js'; import { ModePicker } from './modePicker.js'; import { CloudModelPicker } from './modelPicker.js'; @@ -101,7 +102,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionConfig, group: 'navigation', order: 0, - when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatLocal), + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatLocal, IsActiveSessionLocal), }], }); } @@ -118,7 +119,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionConfig, group: 'navigation', order: 1, - when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatClaudeCode, IsActiveSessionCopilotChatLocal), + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatClaudeCode, IsActiveSessionCopilotChatLocal, IsActiveSessionLocal), }], }); } @@ -152,7 +153,7 @@ registerAction2(class extends Action2 { id: Menus.NewSessionControl, group: 'navigation', order: 1, - when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatLocal), + when: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionCopilotChatLocal, IsActiveSessionLocal), }], }); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 7ae7a46c8883d..d99450d105200 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -22,7 +22,7 @@ import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/bro import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; -import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset, IChatCheckpoints } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; @@ -42,7 +42,6 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; @@ -51,13 +50,6 @@ import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSess import { createChangesets } from './copilotChatSessionsChangesets.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; -/** Local session type — in-process VS Code chat, no background agent or worktree. */ -export const LocalSessionType: ISessionType = { - id: 'local', - label: localize('localSession', "Local"), - icon: Codicon.vm, -}; - /** Claude Code session type — local agent powered by Claude. */ export const ClaudeCodeSessionType: ISessionType = { id: 'claude-code', @@ -156,19 +148,16 @@ export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSess /** Setting key controlling whether Claude agent sessions are available. */ export const CLAUDE_CODE_ENABLED_SETTING = 'sessions.chat.claudeAgent.enabled'; -/** Setting key controlling whether Local VS Code chat sessions are available in the Agents app. */ -export const LOCAL_SESSION_ENABLED_SETTING = 'sessions.chat.localAgent.enabled'; - const REPOSITORY_OPTION_ID = 'repository'; const PARENT_SESSION_OPTION_ID = 'parentSessionId'; const BRANCH_OPTION_ID = 'branch'; const ISOLATION_OPTION_ID = 'isolation'; const AGENT_OPTION_ID = 'agent'; -type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession | LocalNewSession; +type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession; function isNewSession(session: ICopilotChatSession): session is NewSession { - return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession || session instanceof LocalNewSession; + return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession; } /** @@ -771,287 +760,6 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession update(_session: IAgentSession): void { } } -/** - * New session for local (in-process VS Code chat) sessions. - * Implements {@link ICopilotChatSession} (session facade) for local sessions - * that run in-process without worktrees or remote agents. - * Keeps the underlying chat model alive by retaining the - * {@link IChatModelReference} returned from `startNewLocalSession` for the - * lifetime of this object. - */ -class LocalNewSession extends Disposable implements ICopilotChatSession { - - // -- ISessionData fields -- - - readonly resource: URI; - readonly sessionId: string; - readonly providerId: string; - readonly sessionType: typeof SessionType.Local; - readonly icon: ThemeIcon; - readonly createdAt: Date; - - private readonly _title = observableValue(this, ''); - readonly title: IObservable = this._title; - - private readonly _updatedAt = observableValue(this, new Date()); - readonly updatedAt: IObservable = this._updatedAt; - - private readonly _status = observableValue(this, SessionStatus.Untitled); - readonly status: IObservable = this._status; - - private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); - readonly permissionLevel: IObservable = this._permissionLevel; - - private readonly _workspaceData = observableValue(this, undefined); - readonly workspace: IObservable = this._workspaceData; - - readonly checkpoints: IObservable = constObservable(undefined); - - private readonly _changes = observableValue(this, []); - readonly changes: IObservable = this._changes; - - private readonly _modelIdObservable = observableValue(this, undefined); - readonly modelId: IObservable = this._modelIdObservable; - - private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); - readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable; - - readonly loading: IObservable = observableValue(this, false); - - private readonly _isArchived = observableValue(this, false); - readonly isArchived: IObservable = this._isArchived; - readonly isRead: IObservable = observableValue(this, true); - readonly description: IObservable = constObservable(undefined); - readonly lastTurnEnd: IObservable = constObservable(undefined); - readonly gitHubInfo: IObservable = constObservable(undefined); - readonly branch: IObservable = constObservable(undefined); - readonly isolationMode: IObservable = constObservable(undefined); - readonly branches: IObservable = constObservable([]); - readonly gitRepository?: IGitRepository | undefined; - - readonly mainChat: ISettableObservable; - - // -- New session configuration fields -- - - private _modelId: string | undefined; - private _mode: IChatMode | undefined; - - readonly target = AgentSessionProviders.Local; - readonly selectedOptions = new Map(); - - get selectedModelId(): string | undefined { return this._modelId; } - get chatMode(): IChatMode | undefined { return this._mode; } - get query(): string | undefined { return undefined; } - get attachedContext(): IChatRequestVariableEntry[] | undefined { return undefined; } - get disabled(): boolean { return false; } - - constructor( - // readonly resource: URI, - readonly sessionWorkspace: ISessionWorkspace, - providerId: string, - @IGitService private readonly gitService: IGitService, - @IChatService private readonly chatService: IChatService, - @IFileService private readonly fileService: IFileService, - ) { - super(); - - // Create a real local chat model upfront so the chat service has - // a model registered for our resource. This avoids the - // contributed-session path (which would require a content - // provider for the 'local' chat session type). - const modelRef = this._register(this.chatService.startNewLocalSession( - ChatAgentLocation.Chat, - { debugOwner: 'CopilotChatSessionsProvider#createNewSession.local' }, - )); - if (sessionWorkspace.folders.length > 0) { - modelRef.object.setWorkingDirectory(sessionWorkspace.folders[0]?.root); - } - this.resource = modelRef.object.sessionResource; - - this.sessionId = toSessionId(providerId, this.resource); - this.providerId = providerId; - this.sessionType = AgentSessionProviders.Local; - this.icon = LocalSessionType.icon; - this.createdAt = new Date(); - - this._workspaceData.set(sessionWorkspace, undefined); - - this.mainChat = observableValue(this, buildChatFromSession(this)); - - // Resolve git state asynchronously so the Changes view has - // branch names, uncommitted counts, etc. without needing - // an agent session in agentSessionsService. - this._resolveGitState(); - } - - private async _resolveGitState(): Promise { - const repoUri = this.sessionWorkspace.folders[0]?.root; - if (!repoUri) { - return; - } - - try { - const repo = await this.gitService.openRepository(repoUri); - if (!repo) { - return; - } - - const folder = this.sessionWorkspace.folders[0]; - const baseGitRepo: ISessionGitRepository = folder.gitRepository ?? { - uri: folder.root, - workTreeUri: undefined, - baseBranchName: undefined, - gitHubInfo: constObservable(undefined), - }; - - this._register(autorun((reader) => { - const state = repo.state.read(reader); - const head = state.HEAD; - const branchName = head?.commit ? head.name : undefined; - const upstreamBranchName = head?.upstream - ? `${head.upstream.remote}/${head.upstream.name}` - : undefined; - const uncommittedChanges = state.workingTreeChanges.length + state.untrackedChanges.length + state.indexChanges.length; - - this._workspaceData.set({ - ...this.sessionWorkspace, - folders: [{ - ...folder, - gitRepository: { - ...baseGitRepo, - branchName, - upstreamBranchName, - uncommittedChanges, - }, - }], - }, undefined); - - // Capture all known changed files from the current state snapshot - // so we can fill in any that diffBetweenWithStats2 misses - // (e.g. untracked files regardless of the git.untrackedChanges setting) - const allStateChanges = [...state.workingTreeChanges, ...state.untrackedChanges, ...state.indexChanges]; - - // Fetch real line-level diff stats asynchronously - repo.diffBetweenWithStats2('HEAD').then(async diffChanges => { - if (this._store.isDisposed) { - return; - } - // diffBetweenWithStats2 only covers tracked changes against HEAD; - // append any files from the git state that it missed - // (e.g. untracked/new files not yet staged) with real line counts - const trackedUris = new Set(diffChanges.map(el => el.uri.toString())); - const changes: IChatSessionFileChange2[] = diffChanges.map(el => ({ - uri: el.uri, - originalUri: el.originalUri, - modifiedUri: el.modifiedUri ?? el.uri, - insertions: el.insertions, - deletions: el.deletions, - })); - const untrackedFiles = allStateChanges.filter(el => !trackedUris.has(el.uri.toString())); - const lineCountPromises = untrackedFiles.map(async el => { - let insertions = 0; - try { - const stat = await this.fileService.stat(el.uri); - if (!stat.isDirectory) { - const content = await this.fileService.readFile(el.uri); - // Count newlines; add 1 for the last line if file is non-empty - const text = content.value.toString(); - insertions = text.length > 0 ? text.split('\n').length : 0; - } - } catch { - // File may have been deleted between state snapshot and read - } - return { - uri: el.uri, - originalUri: undefined, - modifiedUri: el.modifiedUri ?? el.uri, - insertions, - deletions: 0, - } satisfies IChatSessionFileChange2; - }); - const untrackedChanges = await Promise.all(lineCountPromises); - if (this._store.isDisposed) { - return; - } - changes.push(...untrackedChanges); - this._changes.set(changes, undefined); - }, () => { - // Diff computation failed — fall back to zero stats - if (this._store.isDisposed) { - return; - } - this._changes.set(allStateChanges.map(el => ({ - uri: el.uri, - originalUri: el.originalUri, - modifiedUri: el.modifiedUri ?? el.uri, - insertions: 0, - deletions: 0, - })), undefined); - }); - })); - - } catch { - // No git repository available — workspace stays as-is - } - } - - setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { - if (typeof value === 'string') { - this.selectedOptions.set(optionId, { id: value, name: value }); - } else { - this.selectedOptions.set(optionId, value); - } - } - - setPermissionLevel(level: ChatPermissionLevel): void { - this._permissionLevel.set(level, undefined); - } - - setIsolationMode(_mode: IsolationMode): void { - // No-op — local sessions do not use isolation - } - - setBranch(_branch: string | undefined): void { - // No-op — local sessions do not manage branches - } - - setModelId(modelId: string | undefined): void { - this._modelId = modelId; - this._modelIdObservable.set(modelId, undefined); - } - - setTitle(title: string): void { - this._title.set(title, undefined); - } - - setStatus(status: SessionStatus): void { - this._status.set(status, undefined); - } - - setArchived(archived: boolean): void { - this._isArchived.set(archived, undefined); - } - - setMode(mode: IChatMode | undefined): void { - this._mode = mode; - if (mode) { - this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined); - } else { - this._modeObservable.set(undefined, undefined); - } - } - - update(session: IAgentSession): void { - transaction(tx => { - this._title.set(session.label, tx); - const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created; - this._updatedAt.set(new Date(updatedTime), tx); - this._status.set(toSessionStatus(session.status), tx); - this._isArchived.set(session.isArchived(), tx); - }); - } -} - /** * New session for Claude agent sessions. * Implements {@link ICopilotChatSession} (session facade) and provides @@ -1656,9 +1364,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions get sessionTypes(): readonly ISessionType[] { const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; - if (this._localSessionEnabled) { - types.push(LocalSessionType); - } if (this._claudeEnabled) { types.push(ClaudeCodeSessionType); } @@ -1675,7 +1380,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; /** Cache of adapted sessions, keyed by resource URI string. */ - private readonly _sessionCache = new Map(); + private readonly _sessionCache = new Map(); /** Cache of ISession wrappers, keyed by session group ID. */ private readonly _sessionGroupCache = new Map(); @@ -1697,7 +1402,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _multiChatEnabled: boolean; private _claudeEnabled: boolean; - private _localSessionEnabled: boolean; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; @@ -1721,7 +1425,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._multiChatEnabled = this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; this._claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING); - this._localSessionEnabled = this.configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) { @@ -1732,14 +1435,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._refreshSessionCache(); } } - if (e.affectsConfiguration(LOCAL_SESSION_ENABLED_SETTING)) { - const localSessionEnabled = this.configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING); - if (this._localSessionEnabled !== localSessionEnabled) { - this._localSessionEnabled = localSessionEnabled; - this._onDidChangeSessionTypes.fire(); - this._refreshSessionCache(); - } - } })); this.browseActions = [ @@ -1765,9 +1460,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return [CopilotCloudSessionType]; } const types: ISessionType[] = [CopilotCLISessionType]; - if (this._localSessionEnabled) { - types.push(LocalSessionType); - } if (this._claudeEnabled) { types.push(ClaudeCodeSessionType); } @@ -1852,13 +1544,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSession(session); } - if (sessionTypeId === LocalSessionType.id) { - const session = this.instantiationService.createInstance(LocalNewSession, workspace, this.id); - session.setPermissionLevel(this._defaultPermissionLevel()); - this._currentNewSession.value = session; - return this._chatToSession(session); - } - if (sessionTypeId !== CopilotCLISessionType.id) { throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); } @@ -2072,8 +1757,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } (await this._createChatSession(newItem.resource, session)).dispose(); newChat = this._toChat(session, newItem.resource); - } else if (session instanceof LocalNewSession) { - newChat = this._toChat(session); } else { (await this._createChatSession(session.resource, session)).dispose(); newChat = this._toChat(session); @@ -2169,7 +1852,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._sendExistingChat(sessionId, chatSession, options); } - private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession | LocalNewSession, chatResource: URI, options: ISendRequestOptions): Promise { + private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, chatResource: URI, options: ISendRequestOptions): Promise { const { query, attachedContext } = options; @@ -2734,8 +2417,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions for (const session of this.agentSessionsService.model.sessions) { if (session.providerType !== AgentSessionProviders.Background && session.providerType !== AgentSessionProviders.Cloud - && session.providerType !== AgentSessionProviders.Claude - && session.providerType !== AgentSessionProviders.Local) { + && session.providerType !== AgentSessionProviders.Claude) { continue; } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index a1906d4460f53..8d788b195f05e 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -384,7 +384,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); - test('getSessions includes Background and Local sessions', () => { + test('getSessions excludes Local sessions (now owned by LocalChatSessionsProvider)', () => { const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' }); const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' }); model.addSession(createMockAgentSession(bgResource)); @@ -393,7 +393,7 @@ suite('CopilotChatSessionsProvider', () => { const provider = createProvider(disposables, model); const sessions = provider.getSessions(); - assert.strictEqual(sessions.length, 2); + assert.strictEqual(sessions.length, 1); }); test('getSessions includes Claude agent sessions when enabled', () => { diff --git a/src/vs/sessions/contrib/providers/localChatSessions/LOCAL_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/localChatSessions/LOCAL_CHAT_SESSIONS_PROVIDER.md new file mode 100644 index 0000000000000..6776104766cbd --- /dev/null +++ b/src/vs/sessions/contrib/providers/localChatSessions/LOCAL_CHAT_SESSIONS_PROVIDER.md @@ -0,0 +1,134 @@ +# LocalChatSessionsProvider — Local In-Process Chat Sessions + +**File:** `src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts` + +The local sessions provider, registered with ID `'local-chat'`. Wraps local in-process VS Code chat sessions (created via `IChatService.startNewLocalSession()`) into the extensible sessions provider model. Owns its own session list — does not rely on the chat history APIs at runtime. + +## Identity + +| Property | Value | +|----------|-------| +| `id` | `'local-chat'` | +| `label` | `'Local Chat'` | +| `icon` | `Codicon.vm` | +| `sessionTypes` | `[LocalSessionType]` (static — provider is only registered when `sessions.chat.localAgent.enabled` is true at startup) | +| `supportsLocalWorkspaces` | `true` | +| `browseActions` | `[]` (uses the workspace picker's built-in folder browser) | + +`LocalSessionType` is defined as: + +```typescript +{ id: 'local', label: 'Local', icon: Codicon.vm } +``` + +## Registration + +Registration is gated by the `sessions.chat.localAgent.enabled` setting, read once at workbench startup in `localChatSessions.contribution.ts`. If the setting is `false`, the provider is not created or registered at all. **Toggling the setting requires a window reload to take effect** — the provider does not listen for runtime configuration changes. + +## Architecture + +### Single `LocalSession` class + +Local sessions are represented by a single `LocalSession` class with two construction paths controlled by a `detail: IChatDetail | undefined` constructor parameter: + +- **`detail === undefined`** → new session: creates a fresh chat model via `IChatService.startNewLocalSession(ChatAgentLocation.Chat)`, resolves git state for the workspace via `IGitService`, and retains the model reference for the session's lifetime. +- **`detail !== undefined`** → restored session: built from a persisted `IChatDetail` snapshot. Does not own a chat model reference; the model is loaded on demand via `IChatService.acquireOrLoadSession` when the user opens the session. + +The class exposes the standard `ISession` observable surface plus pre-send configuration (`setModelId`, `setMode`, `setPermissionLevel`, `setTitle`, `setStatus`, `setArchived`) and `trackModel(model, onChange)` for reactive status binding. + +### Provider state + +- **`_currentNewSession: MutableDisposable`** — holds the session being composed before its first `sendRequest`. +- **`_sessionCache: Map`** — keyed by resource URI string. Populated when a session is sent for the first time or when persisted sessions are loaded on startup. + +A `LocalSession` moves from `_currentNewSession` → `_sessionCache` when `sendRequest` succeeds. The resource never changes — the same `LocalSession` instance is kept. + +### Persistence + +Local sessions are persisted in `IStorageService` profile-scoped, machine-target storage under the key `sessions.localChat.sessions`. Each entry is an `IStoredLocalSession`: + +```typescript +interface IStoredLocalSession { + readonly uri: UriComponents; + readonly title: string; + readonly createdAt: number; + readonly lastMessageDate: number; + readonly workingDirectory: UriComponents; // mandatory + readonly archived?: boolean; +} +``` + +- **Add** (`_addStoredSession`) — called after the first `sendRequest`. Refuses to persist if no working directory is available. +- **Update** (`_updateStoredSession`) — called on rename, archive, response-completion, or `onDidSubmitRequest`-driven sync. +- **Remove** (`_removeStoredSession`) — called from `deleteSession`. +- **Load** (`_loadPersistedSessions`) — on provider construction; reads stored entries and creates `LocalSession` instances from them. + +Storage is self-contained: no `IChatService` calls are needed to list sessions, since title and timing are stored inline. + +### One-time migration + +On first run, `_migrateFromHistory()` reads `IChatService.getLocalSessionHistory()` once, imports each session with a working directory into our storage (skipping anything already stored), and sets the `sessions.localChat.migrated` flag. This brings forward existing local chat history when users upgrade. + +### Live model tracking + +When `onDidSubmitRequest` fires for a session in `_sessionCache`, `_syncSessionFromModel` calls `LocalSession.trackModel(model, onChange)`: + +- Subscribes to `model.requestInProgress` via `autorun` +- Maps `true` → `SessionStatus.InProgress`, `false` → `SessionStatus.Completed` +- Calls `onChange()` so the provider updates title, timing, persisted storage, and fires `onDidChangeSessions` + +A `MutableDisposable` on `LocalSession` ensures repeated `trackModel` calls don't accumulate listeners. + +## Lifecycle Flow + +### Creating a new session + +``` +1. User selects a folder in the workspace picker → createNewSession(folderUri, 'local') + → resolveWorkspace produces ISessionWorkspace + → LocalSession constructed with detail=undefined + → startNewLocalSession() creates chat model, resource is captured + → git state resolution scheduled + → _currentNewSession.value = session + → returns ISession + +2. User types a message → sendRequest(sessionId, chatResource, options) + → Sets title from first line of query, status InProgress + → Builds IChatSendRequestOptions (mode, model, permissions, attachedContext) + → _updateChatSessionState applies model/mode/permission to chat model + → chatService.sendRequest(chatResource, query, options) + → On success: session moves to _sessionCache, _currentNewSession cleared, + _addStoredSession persists the URI + metadata + → responseCompletePromise updates status to Completed and syncs from model +``` + +### Restoring sessions on startup + +``` +1. Provider constructor runs +2. _migrateFromHistory (idempotent) imports any pre-existing local chat history +3. _loadPersistedSessions reads IStoredLocalSession[] and creates LocalSession + instances directly from stored metadata — no chat models loaded yet +4. Sessions appear in getSessions(); chat model is loaded lazily by the + chat view pane when the user clicks a session +``` + +### Actions + +- **`archiveSession` / `unarchiveSession`** — toggles `isArchived` on the cached session and persists. +- **`deleteSession`** — calls `chatService.removeHistoryEntry()`, removes from cache, removes from storage. +- **`renameChat`** — calls `chatService.setSessionTitle()`, updates session title, persists. +- **`setModel`** — only meaningful for the current new session before send; updates pre-send model id. +- **`createNewChat`** — for the current new session, returns the already-prepared `IChat` and updates `mainChat`. + +## Picker Contributions + +Local sessions reuse the Copilot provider's pickers (`ModePicker`, `SessionModelPicker`, `PermissionPicker`) via `when` clauses that match `ActiveSessionTypeContext === 'local'`. The picker actions in `copilotChatSessionsActions.ts` include `IsActiveSessionLocal` in their `when` expressions so the same widgets surface for both the copilot CLI provider and this local provider. + +## Differences from `CopilotChatSessionsProvider` + +- **No `IAgentSessionsService` dependency.** Uses `IChatService` directly. +- **No untitled→committed URI swap.** Local session resources never change. +- **No multi-chat support.** Each local session has exactly one chat. +- **Self-managed session list.** Storage owns the source of truth, not the chat history. +- **No worktree / branch / isolation.** Local sessions run in-process against the workspace folder as-is. diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts new file mode 100644 index 0000000000000..c9bc79984c5d7 --- /dev/null +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { LocalChatSessionsProvider, LOCAL_SESSION_ENABLED_SETTING, LocalSessionType } from './localChatSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { localize } from '../../../../../nls.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ForkConversationAction } from '../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; +import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { raceTimeout } from '../../../../../base/common/async.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [LOCAL_SESSION_ENABLED_SETTING]: { + type: 'boolean', + default: true, + tags: ['experimental'], + experiment: { mode: 'startup' }, + description: localize('sessions.chat.localAgent.enabled', "Enable Local VS Code chat sessions in the Agents Window. Reload the window for changes to take effect."), + }, + }, +}); + +class LocalSessionsProviderContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'sessions.localSessionsProvider'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + // Only register the provider when enabled. The setting is read once + // at startup; toggling it requires a window reload. + if (!configurationService.getValue(LOCAL_SESSION_ENABLED_SETTING)) { + return; + } + + const provider = this._register(instantiationService.createInstance(LocalChatSessionsProvider)); + this._register(sessionsProvidersService.registerProvider(provider)); + } +} + +registerWorkbenchContribution2(LocalSessionsProviderContribution.ID, LocalSessionsProviderContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class extends ForkConversationAction { + protected override _openForkedSession(instantiationService: IInstantiationService, parentSessionResource: URI, forkedSessionResource: URI): Promise { + return instantiationService.invokeFunction(async accessor => { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const logService = accessor.get(ILogService); + + const parentSession = sessionsManagementService.getSession(parentSessionResource); + if (!parentSession) { + logService.error(`Parent session ${parentSessionResource.toString()} not found when forking conversation`); + return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); + } + + if (parentSession.sessionType !== LocalSessionType.id) { + return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource); + } + + // Local sessions — wait for the forked session to appear, but + // bound the wait so a missing session does not hang forever. + if (!sessionsManagementService.getSession(forkedSessionResource)) { + let listener: IDisposable | undefined; + const appeared = await raceTimeout(new Promise(resolve => { + listener = sessionsManagementService.onDidChangeSessions(() => { + if (sessionsManagementService.getSession(forkedSessionResource)) { + resolve(true); + } + }); + }), 30_000); + listener?.dispose(); + + if (!appeared) { + logService.error(`Forked session ${forkedSessionResource.toString()} did not appear within timeout`); + return; + } + } + await sessionsManagementService.openSession(forkedSessionResource); + + }); + } +}); + diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts new file mode 100644 index 0000000000000..ac833039b6890 --- /dev/null +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts @@ -0,0 +1,925 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { autorun, constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI, UriComponents } from '../../../../../base/common/uri.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatService, IChatSendRequestOptions, IChatDetail, convertLegacyChatSessionTiming } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionFileChange2, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, ISessionType, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, IChatCheckpoints } from '../../../../services/sessions/common/session.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; +import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { isBuiltinChatMode, IChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; +import { IChatModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; +import { createChangesets } from '../../copilotChatSessions/browser/copilotChatSessionsChangesets.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; + +/** Local session type — in-process VS Code chat, no background agent or worktree. */ +export const LocalSessionType: ISessionType = { + id: 'local', + label: localize('localSession', "Local"), + icon: Codicon.vm, +}; + +/** Setting key controlling whether Local VS Code chat sessions are available in the Agents app. */ +export const LOCAL_SESSION_ENABLED_SETTING = 'sessions.chat.localAgent.enabled'; + +const LOCAL_PROVIDER_ID = 'local-chat'; +const STORAGE_KEY_SESSIONS = 'sessions.localChat.sessions'; +const STORAGE_KEY_MIGRATED = 'sessions.localChat.migrated'; + +interface IStoredLocalSession { + readonly uri: UriComponents; + readonly title: string; + readonly createdAt: number; + readonly lastMessageDate: number; + readonly workingDirectory: UriComponents; + readonly archived?: boolean; +} + +/** + * Builds an {@link IChat} snapshot from a {@link LocalSession}. + */ +function buildChat(session: LocalSession): IChat { + return { + resource: session.resource, + createdAt: session.createdAt, + title: session.title, + updatedAt: session.updatedAt, + status: session.status, + changes: session.changes, + checkpoints: session.checkpoints, + modelId: session.modelId, + mode: session.mode, + isArchived: session.isArchived, + isRead: session.isRead, + description: session.description, + lastTurnEnd: session.lastTurnEnd, + }; +} + +/** + * A local chat session. Manages observable state and provides mutation + * methods used by the provider. + * + * Constructed in two ways: + * - **New session** (`detail` is `undefined`): creates a fresh chat model + * through {@link IChatService.startNewLocalSession} and resolves git state. + * - **History session** (`detail` is provided): restores from a persisted + * {@link IChatDetail} without owning a chat model reference. + */ +class LocalSession extends Disposable { + + readonly resource: URI; + readonly sessionId: string; + readonly providerId: string; + readonly sessionType = SessionType.Local; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _title = observableValue(this, ''); + readonly title: IObservable = this._title; + + private readonly _updatedAt = observableValue(this, new Date()); + readonly updatedAt: IObservable = this._updatedAt; + + private readonly _status = observableValue(this, SessionStatus.Untitled); + readonly status: IObservable = this._status; + + private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); + readonly permissionLevel: IObservable = this._permissionLevel; + + private readonly _workspaceData = observableValue(this, undefined); + readonly workspace: IObservable = this._workspaceData; + + readonly checkpoints: IObservable = constObservable(undefined); + + private readonly _changes = observableValue(this, []); + readonly changes: IObservable = this._changes; + + private readonly _modelIdObservable = observableValue(this, undefined); + readonly modelId: IObservable = this._modelIdObservable; + + private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable; + + readonly loading: IObservable = constObservable(false); + + private readonly _isArchived = observableValue(this, false); + readonly isArchived: IObservable = this._isArchived; + readonly isRead: IObservable = constObservable(true); + readonly description: IObservable = constObservable(undefined); + + private readonly _lastTurnEnd = observableValue(this, undefined); + readonly lastTurnEnd: IObservable = this._lastTurnEnd; + + readonly mainChat: ISettableObservable; + + // -- Pre-send configuration -- + + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + + readonly selectedOptions = new Map(); + + get selectedModelId(): string | undefined { return this._modelId; } + get chatMode(): IChatMode | undefined { return this._mode; } + + /** + * Creates a session from persisted chat history. + */ + static fromHistory( + detail: IChatDetail, + providerId: string, + workspace: ISessionWorkspace | undefined, + instantiationService: IInstantiationService, + ): LocalSession { + return instantiationService.createInstance(LocalSession, detail, workspace, providerId); + } + + constructor( + detail: IChatDetail | undefined, + workspace: ISessionWorkspace | undefined, + providerId: string, + @IGitService private readonly gitService: IGitService, + @IChatService private readonly chatService: IChatService, + @IFileService private readonly fileService: IFileService, + ) { + super(); + + this.providerId = providerId; + this.icon = LocalSessionType.icon; + + if (detail) { + // History session — restore from persisted data + const timing = convertLegacyChatSessionTiming(detail.timing); + this.resource = detail.sessionResource; + this.createdAt = new Date(timing.created); + + const lastUpdate = detail.lastMessageDate || timing.lastRequestEnded || timing.lastRequestStarted || timing.created; + this._title.set(detail.title, undefined); + this._updatedAt.set(new Date(lastUpdate), undefined); + this._status.set(detail.isActive ? SessionStatus.InProgress : SessionStatus.Completed, undefined); + this._lastTurnEnd.set(timing.lastRequestEnded ? new Date(timing.lastRequestEnded) : undefined, undefined); + + if (workspace) { + this._workspaceData.set(workspace, undefined); + } + } else { + // New session — create a fresh chat model + const modelRef = this._register(this.chatService.startNewLocalSession( + ChatAgentLocation.Chat, + { debugOwner: 'LocalChatSessionsProvider#createNewSession' }, + )); + if (workspace && workspace.folders.length > 0) { + modelRef.object.setWorkingDirectory(workspace.folders[0]?.root); + } + this.resource = modelRef.object.sessionResource; + this.createdAt = new Date(); + + if (workspace) { + this._workspaceData.set(workspace, undefined); + this._resolveGitState(workspace); + } + } + + this.sessionId = toSessionId(providerId, this.resource); + this.mainChat = observableValue(this, buildChat(this)); + } + + private async _resolveGitState(workspace: ISessionWorkspace): Promise { + const repoUri = workspace.folders[0]?.root; + if (!repoUri) { + return; + } + + try { + const repo = await this.gitService.openRepository(repoUri); + if (!repo) { + return; + } + + const folder = workspace.folders[0]; + const baseGitRepo: ISessionGitRepository = folder.gitRepository ?? { + uri: folder.root, + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable(undefined), + }; + + // Monotonically increasing version used to discard stale diff results. + let diffVersion = 0; + + this._register(autorun((reader) => { + const state = repo.state.read(reader); + const head = state.HEAD; + const branchName = head?.commit ? head.name : undefined; + const upstreamBranchName = head?.upstream + ? `${head.upstream.remote}/${head.upstream.name}` + : undefined; + const uncommittedChanges = state.workingTreeChanges.length + state.untrackedChanges.length + state.indexChanges.length; + + this._workspaceData.set({ + ...workspace, + folders: [{ + ...folder, + gitRepository: { + ...baseGitRepo, + branchName, + upstreamBranchName, + uncommittedChanges, + }, + }], + }, undefined); + + const allStateChanges = [...state.workingTreeChanges, ...state.untrackedChanges, ...state.indexChanges]; + + const version = ++diffVersion; + repo.diffBetweenWithStats2('HEAD').then(async diffChanges => { + if (this._store.isDisposed || version !== diffVersion) { + return; + } + const trackedUris = new Set(diffChanges.map(el => el.uri.toString())); + const changes: IChatSessionFileChange2[] = diffChanges.map(el => ({ + uri: el.uri, + originalUri: el.originalUri, + modifiedUri: el.modifiedUri ?? el.uri, + insertions: el.insertions, + deletions: el.deletions, + })); + const untrackedFiles = allStateChanges.filter(el => !trackedUris.has(el.uri.toString())); + const lineCountPromises = untrackedFiles.map(async el => { + let insertions = 0; + try { + const stat = await this.fileService.stat(el.uri); + if (!stat.isDirectory) { + const content = await this.fileService.readFile(el.uri); + const text = content.value.toString(); + insertions = text.length > 0 ? text.split('\n').length : 0; + } + } catch { + // File may have been deleted between state snapshot and read + } + return { + uri: el.uri, + originalUri: undefined, + modifiedUri: el.modifiedUri ?? el.uri, + insertions, + deletions: 0, + } satisfies IChatSessionFileChange2; + }); + const untrackedChanges = await Promise.all(lineCountPromises); + if (this._store.isDisposed || version !== diffVersion) { + return; + } + changes.push(...untrackedChanges); + this._changes.set(changes, undefined); + }, () => { + if (this._store.isDisposed || version !== diffVersion) { + return; + } + this._changes.set(allStateChanges.map(el => ({ + uri: el.uri, + originalUri: el.originalUri, + modifiedUri: el.modifiedUri ?? el.uri, + insertions: 0, + deletions: 0, + })), undefined); + }); + })); + } catch { + // No git repository available — workspace stays as-is + } + } + + setPermissionLevel(level: ChatPermissionLevel): void { + this._permissionLevel.set(level, undefined); + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + this._modelIdObservable.set(modelId, undefined); + } + + setTitle(title: string): void { + this._title.set(title, undefined); + } + + setUpdatedAt(date: Date): void { + this._updatedAt.set(date, undefined); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } + + setArchived(archived: boolean): void { + this._isArchived.set(archived, undefined); + } + + private readonly _modelTracker = this._register(new MutableDisposable()); + + /** + * Subscribe to live updates from the given chat model. Subsequent calls + * replace any prior subscription. Disposed automatically with the session. + */ + trackModel(model: IChatModel, onChange: () => void): void { + this._modelTracker.value = autorun(reader => { + const inProgress = model.requestInProgress.read(reader); + this._status.set(inProgress ? SessionStatus.InProgress : SessionStatus.Completed, undefined); + onChange(); + }); + } + + setMode(mode: IChatMode | undefined): void { + this._mode = mode; + if (mode) { + this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined); + } else { + this._modeObservable.set(undefined, undefined); + } + } + + /** + * Update this session from a persisted history detail. + */ + updateFromHistory(detail: IChatDetail): void { + const timing = convertLegacyChatSessionTiming(detail.timing); + const lastUpdate = detail.lastMessageDate || timing.lastRequestEnded || timing.lastRequestStarted || timing.created; + transaction(tx => { + this._title.set(detail.title, tx); + this._updatedAt.set(new Date(lastUpdate), tx); + this._status.set(detail.isActive ? SessionStatus.InProgress : SessionStatus.Completed, tx); + this._lastTurnEnd.set(timing.lastRequestEnded ? new Date(timing.lastRequestEnded) : undefined, tx); + }); + } +} + +/** + * Sessions provider that wraps local in-process chat sessions + * (using {@link IChatService} directly) into the {@link ISessionsProvider} interface. + */ +export class LocalChatSessionsProvider extends Disposable implements ISessionsProvider { + + readonly id = LOCAL_PROVIDER_ID; + readonly label = localize('localChatSessionsProvider', "Local Chat"); + readonly icon = Codicon.vm; + readonly browseActions: readonly [] = []; + readonly supportsLocalWorkspaces = true; + + readonly sessionTypes: readonly ISessionType[] = [LocalSessionType]; + readonly onDidChangeSessionTypes: Event = Event.None; + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + /** Cache of sessions, keyed by resource URI string. */ + private readonly _sessionCache = new Map(); + + private readonly _currentNewSession = this._register(new MutableDisposable()); + + constructor( + @IChatService private readonly chatService: IChatService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILabelService private readonly labelService: ILabelService, + @ILogService private readonly logService: ILogService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + // Track requests on our sessions to update last message date, + // title, and persisted metadata when the chat widget sends + // subsequent messages directly (not via our sendRequest). + this._register(this.chatService.onDidSubmitRequest(e => { + const session = this._sessionCache.get(e.chatSessionResource.toString()); + if (session) { + this._syncSessionFromModel(session); + } + })); + + // One-time migration: import existing local chat history into our storage + this._migrateFromHistory().finally(() => { + // Load persisted local sessions on initialization + this._loadPersistedSessions(); + }); + } + + /** + * One-time migration that imports existing local chat sessions from + * {@link IChatService.getLocalSessionHistory} into our own persisted + * storage. Only sessions with a working directory are migrated, since + * a working directory is mandatory for {@link LocalSession}. Sessions + * that are already in our storage are skipped. + */ + private async _migrateFromHistory(): Promise { + if (this.storageService.getBoolean(STORAGE_KEY_MIGRATED, StorageScope.PROFILE, false)) { + return; + } + + try { + const history = await this.chatService.getLocalSessionHistory(); + const sessions = this._readStoredSessions(); + const existingKeys = new Set(sessions.map(s => URI.revive(s.uri).toString())); + let changed = false; + + for (const detail of history) { + if (!detail.workingDirectory) { + continue; + } + const key = detail.sessionResource.toString(); + if (existingKeys.has(key)) { + continue; + } + const timing = convertLegacyChatSessionTiming(detail.timing); + const lastUpdate = detail.lastMessageDate || timing.lastRequestEnded || timing.lastRequestStarted || timing.created; + sessions.push({ + uri: detail.sessionResource.toJSON(), + title: detail.title, + createdAt: timing.created, + lastMessageDate: lastUpdate, + workingDirectory: detail.workingDirectory.toJSON(), + }); + changed = true; + } + + if (changed) { + this._writeStoredSessions(sessions); + } + this.storageService.store(STORAGE_KEY_MIGRATED, true, StorageScope.PROFILE, StorageTarget.MACHINE); + } catch (e) { + this.logService.error('[LocalChatSessionsProvider] Failed to migrate local chat history', e); + // Do not mark migration complete on failure so it can be retried next time. + } + } + + /** + * Reads current title/timing from the live chat model, updates the + * cached session, persists changes, and sets up reactive tracking + * so subsequent status changes propagate automatically. + */ + private _syncSessionFromModel(session: LocalSession): void { + const model = this.chatService.getSession(session.resource); + if (!model) { + return; + } + session.trackModel(model, () => { + const timing = model.timing; + const lastUpdate = timing.lastRequestEnded ?? timing.lastRequestStarted ?? timing.created; + session.setTitle(model.title); + session.setUpdatedAt(new Date(lastUpdate)); + this._updateStoredSession(session); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._toISession(session)] }); + }); + } + + // -- Session types -- + + getSessionTypes(_workspaceUri: URI): ISessionType[] { + return [LocalSessionType]; + } + + // -- Sessions -- + + getSessions(): ISession[] { + return Array.from(this._sessionCache.values()).map(session => this._toISession(session)); + } + + /** + * Loads sessions from our own persisted storage. No calls to + * {@link IChatService} are needed — all metadata is stored inline. + */ + private _loadPersistedSessions(): void { + const storedSessions = this._readStoredSessions(); + if (storedSessions.length === 0) { + return; + } + + const added: ISession[] = []; + + for (const stored of storedSessions) { + const uri = URI.revive(stored.uri); + const key = uri.toString(); + if (this._sessionCache.has(key)) { + continue; + } + + const workingDirectory = URI.revive(stored.workingDirectory); + const detail: IChatDetail = { + sessionResource: uri, + title: stored.title, + lastMessageDate: stored.lastMessageDate, + timing: { created: stored.createdAt, lastRequestStarted: undefined, lastRequestEnded: stored.lastMessageDate }, + isActive: false, + lastResponseState: 0 /* ResponseModelState.Complete */, + workingDirectory, + }; + + const workspace = this.resolveWorkspace(workingDirectory); + const session = LocalSession.fromHistory(detail, this.id, workspace, this.instantiationService); + if (stored.archived) { + session.setArchived(true); + } + this._sessionCache.set(key, session); + added.push(this._toISession(session)); + } + + if (added.length > 0) { + this._onDidChangeSessions.fire({ added, removed: [], changed: [] }); + } + } + + // -- Storage helpers -- + + private _readStoredSessions(): IStoredLocalSession[] { + const raw = this.storageService.get(STORAGE_KEY_SESSIONS, StorageScope.PROFILE); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private _addStoredSession(session: LocalSession): void { + const sessions = this._readStoredSessions(); + const key = session.resource.toString(); + if (sessions.some(s => URI.revive(s.uri).toString() === key)) { + return; + } + const workingDirectory = session.workspace.get()?.folders[0]?.root; + if (!workingDirectory) { + this.logService.warn(`[LocalChatSessionsProvider] Cannot persist session ${key} — no working directory`); + return; + } + sessions.push({ + uri: session.resource.toJSON(), + title: session.title.get(), + createdAt: session.createdAt.getTime(), + lastMessageDate: session.updatedAt.get().getTime(), + workingDirectory: workingDirectory.toJSON(), + }); + this._writeStoredSessions(sessions); + } + + private _updateStoredSession(session: LocalSession): void { + const sessions = this._readStoredSessions(); + const key = session.resource.toString(); + const idx = sessions.findIndex(s => URI.revive(s.uri).toString() === key); + if (idx >= 0) { + sessions[idx] = { + ...sessions[idx], + title: session.title.get(), + lastMessageDate: session.updatedAt.get().getTime(), + archived: session.isArchived.get(), + }; + this._writeStoredSessions(sessions); + } + } + + private _removeStoredSession(resource: URI): void { + const sessions = this._readStoredSessions(); + const key = resource.toString(); + const filtered = sessions.filter(s => URI.revive(s.uri).toString() !== key); + if (filtered.length !== sessions.length) { + this._writeStoredSessions(filtered); + } + } + + private _writeStoredSessions(sessions: IStoredLocalSession[]): void { + this.storageService.store( + STORAGE_KEY_SESSIONS, + JSON.stringify(sessions), + StorageScope.PROFILE, + StorageTarget.MACHINE, + ); + } + + // -- Workspace -- + + resolveWorkspace(uri: URI): ISessionWorkspace | undefined { + if (uri.scheme !== Schemas.file) { + return undefined; + } + const folder: ISessionFolder = { + root: uri, + workingDirectory: uri, + name: basename(uri), + description: undefined, + gitRepository: undefined, + }; + return { + uri, + label: basename(uri), + description: this.labelService.getUriLabel(dirname(uri), { relative: false }), + group: SESSION_WORKSPACE_GROUP_LOCAL, + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: true, + isVirtualWorkspace: false, + }; + } + + // -- Session Lifecycle -- + + createNewSession(workspaceUri: URI, sessionTypeId: string): ISession { + if (sessionTypeId !== LocalSessionType.id) { + throw new Error(`Unsupported session type '${sessionTypeId}' for local provider`); + } + + const workspace = this.resolveWorkspace(workspaceUri); + if (!workspace) { + throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); + } + + const session = this.instantiationService.createInstance(LocalSession, undefined, workspace, this.id); + session.setPermissionLevel(this._defaultPermissionLevel()); + this._currentNewSession.value = session; + return this._toISession(session); + } + + setModel(sessionId: string, modelId: string): void { + if (this._currentNewSession.value?.sessionId === sessionId) { + this._currentNewSession.value.setModelId(modelId); + } + } + + // -- Session Actions -- + + async archiveSession(sessionId: string): Promise { + const session = this._findSession(sessionId); + if (session) { + session.setArchived(true); + this._updateStoredSession(session); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._toISession(session)] }); + } + } + + async unarchiveSession(sessionId: string): Promise { + const session = this._findSession(sessionId); + if (session) { + session.setArchived(false); + this._updateStoredSession(session); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._toISession(session)] }); + } + } + + async deleteSession(sessionId: string): Promise { + const session = this._findSession(sessionId); + if (!session) { + return; + } + + await this.chatService.removeHistoryEntry(session.resource); + this._sessionCache.delete(session.resource.toString()); + this._removeStoredSession(session.resource); + if (this._currentNewSession.value?.sessionId === sessionId) { + this._currentNewSession.clear(); + } + this._onDidChangeSessions.fire({ added: [], removed: [this._toISession(session)], changed: [] }); + session.dispose(); + } + + async deleteChat(sessionId: string, _chatUri: URI): Promise { + // Local sessions have a single chat — deleting the chat deletes the session + return this.deleteSession(sessionId); + } + + async renameChat(_sessionId: string, chatUri: URI, title: string): Promise { + this.chatService.setSessionTitle(chatUri, title); + const session = this._findSessionByResource(chatUri); + if (session) { + session.setTitle(title); + this._updateStoredSession(session); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._toISession(session)] }); + } + } + + async createNewChat(sessionId: string, _prompt?: string): Promise { + if (this._currentNewSession.value?.sessionId === sessionId) { + const session = this._currentNewSession.value; + const chat = buildChat(session); + session.mainChat.set(chat, undefined); + return chat; + } + throw new Error(`Session '${sessionId}' not found or is not the current new session`); + } + + // -- Send Request -- + + async sendRequest(sessionId: string, chatResource: URI, options: ISendRequestOptions): Promise { + const newSession = this._currentNewSession.value; + if (!newSession || newSession.sessionId !== sessionId) { + throw new Error(`Session '${sessionId}' not found`); + } + if (chatResource.toString() !== newSession.resource.toString()) { + throw new Error(`Chat resource ${chatResource.toString()} does not match session resource ${newSession.resource.toString()}`); + } + + const { query, attachedContext } = options; + + newSession.setTitle(query.split('\n')[0].substring(0, 100) || localize('newSession', "New Session")); + newSession.setStatus(SessionStatus.InProgress); + + const newISession = this._toISession(newSession); + this._onDidChangeSessions.fire({ added: [newISession], removed: [], changed: [] }); + + // Resolve mode + const modeKind = newSession.chatMode?.kind ?? ChatModeKind.Agent; + const modeIsBuiltin = newSession.chatMode ? isBuiltinChatMode(newSession.chatMode) : true; + const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = modeIsBuiltin ? modeKind : 'custom'; + + const rawModeInstructions = newSession.chatMode?.modeInstructions?.get(); + const modeInstructions = rawModeInstructions ? { + name: newSession.chatMode!.name.get(), + content: rawModeInstructions.content, + toolReferences: this.toolsService.toToolReferences(rawModeInstructions.toolReferences), + metadata: rawModeInstructions.metadata, + } : undefined; + + const permissionLevel = newSession.permissionLevel.get(); + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: newSession.selectedModelId, + modeInfo: { + kind: modeKind, + isBuiltin: modeIsBuiltin, + modeInstructions, + modeId, + applyCodeBlockSuggestionId: undefined, + permissionLevel, + }, + attachedContext, + }; + + // Set model/mode/permission state on the chat model before sending + const modelRef = await this._updateChatSessionState(chatResource, newSession); + this.logService.debug(`[LocalChatSessionsProvider] Sending request for session ${newSession.sessionId}`); + + try { + const result = await this.chatService.sendRequest(chatResource, query, sendOptions); + if (result.kind === 'rejected') { + this._currentNewSession.clearAndLeak(); + this._onDidChangeSessions.fire({ added: [], removed: [newISession], changed: [] }); + newSession.dispose(); + throw new Error(`[LocalChatSessionsProvider] sendRequest rejected: ${result.reason}`); + } + + // Put the new session into the cache and persist its URI. + this._sessionCache.set(newSession.resource.toString(), newSession); + this._addStoredSession(newSession); + this._currentNewSession.clearAndLeak(); + + // Track response completion to update session status and persist title + if (result.kind === 'sent') { + result.data.responseCompletePromise.then(() => { + newSession.setStatus(SessionStatus.Completed); + this._syncSessionFromModel(newSession); + }, error => { + // Response failed — still mark session completed so it doesn't appear stuck. + this.logService.error(`[LocalChatSessionsProvider] Response failed for session ${newSession.sessionId}:`, error); + newSession.setStatus(SessionStatus.Completed); + this._updateStoredSession(newSession); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newISession] }); + }); + } + + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newISession] }); + return newISession; + } catch (error) { + this.logService.error(`[LocalChatSessionsProvider] Failed to send request for session ${newSession.sessionId}:`, error); + throw error; + } finally { + modelRef?.dispose(); + } + } + + // -- Private helpers -- + + override dispose(): void { + for (const session of this._sessionCache.values()) { + session.dispose(); + } + this._sessionCache.clear(); + super.dispose(); + } + + /** + * Resolves the initial permission level for a brand-new session from + * `chat.permissions.default`, clamped to `Default` when enterprise policy + * disables global auto-approval. + */ + private _defaultPermissionLevel(): ChatPermissionLevel { + const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + if (policyRestricted) { + return ChatPermissionLevel.Default; + } + const level = this.configurationService.getValue(ChatConfiguration.DefaultPermissionLevel); + return isChatPermissionLevel(level) ? level : ChatPermissionLevel.Default; + } + + /** + * Updates the chat model state (model, mode, permission level) before sending. + */ + private async _updateChatSessionState(resource: URI, session: LocalSession): Promise<{ dispose(): void } | undefined> { + const modelRef = await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + if (!modelRef) { + return undefined; + } + const model = modelRef.object; + if (session.selectedModelId) { + const languageModel = this.languageModelsService.lookupLanguageModel(session.selectedModelId); + if (languageModel) { + model.inputModel.setState({ selectedModel: { identifier: session.selectedModelId, metadata: languageModel } }); + } + } + if (session.chatMode) { + model.inputModel.setState({ mode: { id: session.chatMode.id, kind: session.chatMode.kind } }); + } + const permissionLevel = session.permissionLevel.get(); + if (permissionLevel) { + model.inputModel.setState({ permissionLevel }); + } + return modelRef; + } + + private _findSession(sessionId: string): LocalSession | undefined { + if (this._currentNewSession.value?.sessionId === sessionId) { + return this._currentNewSession.value; + } + for (const session of this._sessionCache.values()) { + if (session.sessionId === sessionId) { + return session; + } + } + return undefined; + } + + private _findSessionByResource(resource: URI): LocalSession | undefined { + const cached = this._sessionCache.get(resource.toString()); + if (cached) { + return cached; + } + if (this._currentNewSession.value?.resource.toString() === resource.toString()) { + return this._currentNewSession.value; + } + return undefined; + } + + private _toISession(session: LocalSession): ISession { + const mainChat = session.mainChat; + const chatsObs = mainChat.map(c => [c] as readonly IChat[]); + const changesets = createChangesets(session.sessionType, session.workspace, chatsObs, this.instantiationService); + + return { + sessionId: session.sessionId, + resource: session.resource, + providerId: session.providerId, + sessionType: session.sessionType, + icon: session.icon, + createdAt: session.createdAt, + workspace: session.workspace, + title: session.title, + updatedAt: session.updatedAt, + status: session.status, + changesets, + changes: session.changes, + modelId: session.modelId, + mode: session.mode, + loading: session.loading, + isArchived: session.isArchived, + isRead: session.isRead, + description: session.description, + lastTurnEnd: session.lastTurnEnd, + chats: chatsObs, + mainChat, + capabilities: { + supportsMultipleChats: false, + }, + }; + } +} diff --git a/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts new file mode 100644 index 0000000000000..445cab30673aa --- /dev/null +++ b/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; +import { ChatAgentLocation } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { IChatModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IChatModelReference, IChatService, IChatSessionStartOptions, IChatSessionTiming } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelToolsService } from '../../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; +import { IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { LocalChatSessionsProvider, LocalSessionType, LOCAL_SESSION_ENABLED_SETTING } from '../../browser/localChatSessionsProvider.js'; + +// ---- Mock chat service ---------------------------------------------------- + +function createMockModel(sessionResource: URI, opts?: { title?: string; requestInProgress?: IObservable; timing?: IChatSessionTiming }): IChatModel { + let workingDirectory: URI | undefined; + const requestInProgress = opts?.requestInProgress ?? observableValue('requestInProgress', false); + const timing: IChatSessionTiming = opts?.timing ?? { created: 1_000, lastRequestStarted: undefined, lastRequestEnded: undefined }; + return new class extends mock() { + override readonly sessionResource = sessionResource; + override readonly title = opts?.title ?? 'Test Session'; + override readonly timing = timing; + override readonly requestInProgress = requestInProgress; + override get workingDirectory() { return workingDirectory; } + override setWorkingDirectory(uri: URI | undefined): void { workingDirectory = uri; } + }(); +} + +class MockChatService extends Disposable { + private readonly _models = new Map(); + private _counter = 0; + + readonly sendRequestCalls: { resource: URI; query: string }[] = []; + + private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResources: readonly URI[]; readonly reason: 'cleared' }>()); + readonly onDidDisposeSession = this._onDidDisposeSession.event; + + private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>()); + readonly onDidSubmitRequest = this._onDidSubmitRequest.event; + + startNewLocalSession(_location: ChatAgentLocation, _options?: IChatSessionStartOptions): IChatModelReference { + const resource = URI.parse(`vscode-local-chat://chat/${++this._counter}`); + const model = createMockModel(resource); + this._models.set(resource.toString(), model); + return { object: model, dispose: () => { } }; + } + + getSession(resource: URI): IChatModel | undefined { + return this._models.get(resource.toString()); + } + + registerModel(model: IChatModel): void { + this._models.set(model.sessionResource.toString(), model); + } + + async acquireOrLoadSession(): Promise { + return undefined; + } + + async sendRequest(resource: URI, query: string) { + this.sendRequestCalls.push({ resource, query }); + return { kind: 'sent', data: { responseCompletePromise: Promise.resolve(), responseCreatedPromise: Promise.resolve({}) } }; + } + + async getLocalSessionHistory() { return []; } + async removeHistoryEntry(_resource: URI): Promise { } + setSessionTitle(_resource: URI, _title: string): void { } + + fireSubmitRequest(resource: URI): void { + this._onDidSubmitRequest.fire({ chatSessionResource: resource }); + } +} + +// ---- Test fixture ---------------------------------------------------------- + +interface ITestFixture { + instantiationService: TestInstantiationService; + chatService: MockChatService; + storage: TestStorageService; + config: TestConfigurationService; +} + +function createFixture(store: DisposableStore): ITestFixture { + const instantiationService = store.add(new TestInstantiationService()); + const chatService = store.add(new MockChatService()); + const storage = store.add(new TestStorageService()); + const config = new TestConfigurationService(); + config.setUserConfiguration(LOCAL_SESSION_ENABLED_SETTING, true); + + instantiationService.stub(IChatService, chatService as unknown as IChatService); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(IConfigurationService, config); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ILabelService, new class extends mock() { + override getUriLabel(uri: URI): string { return uri.fsPath; } + }()); + instantiationService.stub(ILanguageModelsService, new class extends mock() { }()); + instantiationService.stub(ILanguageModelToolsService, new class extends mock() { }()); + instantiationService.stub(IGitService, new class extends mock() { + override async openRepository() { return undefined; } + }()); + instantiationService.stub(IFileService, new class extends mock() { }()); + instantiationService.stub(IInstantiationService, instantiationService); + return { instantiationService, chatService, storage, config }; +} + +const TEST_FOLDER = URI.file('/test/folder'); + +async function commitNewSession(provider: LocalChatSessionsProvider): Promise { + const newSession = provider.createNewSession(TEST_FOLDER, LocalSessionType.id); + const chat = await provider.createNewChat(newSession.sessionId); + await provider.sendRequest(newSession.sessionId, chat.resource, { query: 'hello' }); + return newSession; +} + +// ---- Suite ---------------------------------------------------------------- + +suite('LocalChatSessionsProvider', () => { + const leaks = ensureNoDisposablesAreLeakedInTestSuite(); + + test('declares Local session type', () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + assert.deepStrictEqual(provider.sessionTypes.map(t => t.id), [LocalSessionType.id]); + }); + + test('resolveWorkspace handles only file uris', () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + + assert.strictEqual(provider.resolveWorkspace(URI.parse('http://example.com')), undefined); + + const ws = provider.resolveWorkspace(TEST_FOLDER); + assert.ok(ws); + assert.strictEqual(ws!.folders[0].root.toString(), TEST_FOLDER.toString()); + }); + + test('createNewSession returns a session but does not show in getSessions until first send', async () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + + const newSession = provider.createNewSession(TEST_FOLDER, LocalSessionType.id); + assert.strictEqual(newSession.providerId, provider.id); + assert.strictEqual(provider.getSessions().length, 0); + + const chat = await provider.createNewChat(newSession.sessionId); + await provider.sendRequest(newSession.sessionId, chat.resource, { query: 'hi' }); + assert.strictEqual(provider.getSessions().length, 1); + }); + + test('createNewSession rejects unknown session types', () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + + assert.throws(() => provider.createNewSession(TEST_FOLDER, 'bogus')); + }); + + test('persists committed sessions and restores them on next provider instance', async () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + const session = await commitNewSession(provider); + + const provider2 = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + await Event.toPromise(provider2.onDidChangeSessions); + const restored = provider2.getSessions(); + assert.strictEqual(restored.length, 1); + assert.strictEqual(restored[0].resource.toString(), session.resource.toString()); + }); + + test('deleteSession removes session from cache and storage', async () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + const session = await commitNewSession(provider); + + await provider.deleteSession(session.sessionId); + assert.strictEqual(provider.getSessions().length, 0); + + const provider2 = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + // Wait one microtask tick for the async migration/load to complete (no event fires when empty) + await Promise.resolve(); + await Promise.resolve(); + assert.strictEqual(provider2.getSessions().length, 0); + }); + + test('archiveSession and unarchiveSession toggle isArchived and persist', async () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + const session = await commitNewSession(provider); + + await provider.archiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), true); + + await provider.unarchiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), false); + + await provider.archiveSession(session.sessionId); + const provider2 = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + await Event.toPromise(provider2.onDidChangeSessions); + assert.strictEqual(provider2.getSessions()[0].isArchived.get(), true); + }); + + test('renameChat updates session title and persists it', async () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService } = createFixture(store); + + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + const session = await commitNewSession(provider); + + await provider.renameChat(session.sessionId, session.resource, 'Custom Title'); + assert.strictEqual(provider.getSessions()[0].title.get(), 'Custom Title'); + + const provider2 = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + await Event.toPromise(provider2.onDidChangeSessions); + assert.strictEqual(provider2.getSessions()[0].title.get(), 'Custom Title'); + }); + + test('status follows model.requestInProgress after a submit event', async () => { + const store = leaks.add(new DisposableStore()); + const { instantiationService, chatService } = createFixture(store); + + const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); + const session = await commitNewSession(provider); + + // Replace the registered model with a controllable one for tracking + const inProgress = observableValue('inProgress', false); + chatService.registerModel(createMockModel(session.resource, { requestInProgress: inProgress })); + + chatService.fireSubmitRequest(session.resource); + assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed); + + inProgress.set(true, undefined); + assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.InProgress); + + inProgress.set(false, undefined); + assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed); + }); + + test('Event.None and exports remain stable', () => { + assert.strictEqual(LocalSessionType.id, 'local'); + assert.strictEqual(LOCAL_SESSION_ENABLED_SETTING, 'sessions.chat.localAgent.enabled'); + assert.ok(Event.None); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 2b852efa05c9e..b4f7378878e95 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -52,7 +52,7 @@ padding: 0 4px; border-radius: 4px; min-width: 0; - max-width: 600px; + max-width: 100%; cursor: pointer; touch-action: manipulation; } @@ -88,9 +88,9 @@ color: var(--vscode-icon-foreground); } -/* Label (title) */ +/* Label (title) — shrinks first so folder/branch/diff stats stay visible. */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-label { - flex: 0 1 auto; + flex: 0 999 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -98,11 +98,11 @@ font-weight: 500; } -/* Repository/worktree metadata shrinks before the primary title. */ +/* Repository/worktree metadata shrinks after the primary title. */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-details { display: flex; align-items: center; - flex: 0 999 auto; + flex: 0 1 auto; gap: 6px; min-width: 0; overflow: hidden; @@ -127,6 +127,8 @@ display: flex; align-items: center; gap: 2px; + flex-shrink: 0; + white-space: nowrap; .codicon { font-size: 13px; @@ -135,12 +137,22 @@ } } -/* Provider label (shown for untitled sessions) */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-provider { +/* Diff stats (+insertions -deletions) shown for sessions with pending changes. */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-diff { display: flex; align-items: center; + gap: 2px; flex-shrink: 0; - opacity: 0.7; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-diff-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-diff-removed { + color: var(--vscode-chat-linesRemovedForeground); } /* Sidebar toggle unread badge */ diff --git a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts new file mode 100644 index 0000000000000..f330fe0f06c34 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { localize } from '../../../../nls.js'; +import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; +import { chatLinesAddedForeground, chatLinesRemovedForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISession } from '../../../services/sessions/common/session.js'; + +/** + * Aggregated insertions/deletions across all of a session's changes, + * or `undefined` when the session has no pending changes. + */ +export function getSessionDiffStats(session: ISession): { files: number; insertions: number; deletions: number } | undefined { + const changes = session.changes.get(); + if (changes.length === 0) { + return undefined; + } + let insertions = 0; + let deletions = 0; + for (const change of changes) { + insertions += change.insertions; + deletions += change.deletions; + } + if (insertions === 0 && deletions === 0) { + return undefined; + } + return { files: changes.length, insertions, deletions }; +} + +/** + * Build a compact, reusable hover markdown describing a session. + * + * Layout: + * Line 1: **session icon + title** + * Line 2: folder icon + folder path · git branch + * Line 3: "N files changed" · colored diff stats + * Line 4: provider label + */ +export function buildSessionHoverContent( + session: ISession, + sessionsProvidersService: ISessionsProvidersService, +): IMarkdownString { + // Note: `isTrusted` is intentionally left undefined. The hover renders + // untrusted, workspace-derived values (folder paths, branch names, session + // titles), so it must not enable command-link execution. User-controlled + // text is always appended via `appendText` so markdown characters are escaped. + const md = new MarkdownString('', { supportThemeIcons: true, supportHtml: true }); + + // Line 1: session icon + bold title + const title = session.title.get() || localize('agentSessions.newSession', "New Session"); + if (session.icon) { + md.appendMarkdown(`$(${session.icon.id}) `); + } + md.appendMarkdown(`**`); + md.appendText(title); + md.appendMarkdown(`**`); + md.appendText('\n'); + + // Line 2: folder icon + folder path · git branch + const workspace = session.workspace.get(); + const folder = workspace?.folders[0]; + const branch = folder?.gitRepository?.branchName?.trim(); + let appendedDetails = false; + + if (folder && workspace) { + const isWorkspaceSession = workspace.folders.length > 0 && workspace.folders[0]?.gitRepository?.workTreeUri === undefined; + const folderIcon = workspace.isVirtualWorkspace ? Codicon.cloud : isWorkspaceSession ? Codicon.folder : Codicon.worktree; + md.appendMarkdown(`$(${folderIcon.id}) `); + md.appendText(folder.root.fsPath); + appendedDetails = true; + } + + if (branch) { + if (appendedDetails) { + md.appendMarkdown(' · '); + } + md.appendMarkdown('$(git-branch) '); + md.appendText(branch); + appendedDetails = true; + } + + if (appendedDetails) { + md.appendText('\n'); + } + + // Line 3: file count · diff stats + const diffStats = getSessionDiffStats(session); + if (diffStats) { + const fileText = diffStats.files === 1 + ? localize('agentSessions.fileChanged', "1 file changed") + : localize('agentSessions.filesChanged', "{0} files changed", diffStats.files); + md.appendMarkdown(`${fileText} · +${diffStats.insertions} -${diffStats.deletions}`); + md.appendText('\n'); + } + + // Line 4: provider name + const provider = sessionsProvidersService.getProvider(session.providerId); + if (provider) { + md.appendText(provider.label); + } + + return md; +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 9deabeffc3d1f..03c8840c46883 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -14,6 +14,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; @@ -129,7 +130,7 @@ registerAction2(class GoBackAction extends Action2 { win: { primary: KeyMod.Alt | KeyCode.LeftArrow }, mac: { primary: KeyMod.WinCtrl | KeyCode.Minus }, linux: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Minus }, - when: IsSessionsWindowContext, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorContextKeys.editorTextFocus.toNegated()), }, menu: [{ id: Menus.TitleBarLeftLayout, @@ -169,7 +170,7 @@ registerAction2(class GoForwardAction extends Action2 { win: { primary: KeyMod.Alt | KeyCode.RightArrow }, mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Minus }, linux: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Minus }, - when: IsSessionsWindowContext, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorContextKeys.editorTextFocus.toNegated()), }, menu: [{ id: Menus.TitleBarLeftLayout, diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 9dd9a66149164..b2c632b122d99 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -9,8 +9,9 @@ import { Separator } from '../../../../base/common/actions.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { localize } from '../../../../nls.js'; +import { HoverStyle } from '../../../../base/browser/ui/hover/hover.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMenuService, MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; @@ -30,6 +31,8 @@ import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { IsSessionArchivedContext, IsSessionPinnedContext, IsSessionReadContext, SessionItemContextMenuId, SessionItemHasBranchNameContext } from './views/sessionsList.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { buildSessionHoverContent, getSessionDiffStats } from './sessionHoverContent.js'; const titleBarContextKeys = new Set([IsNewChatSessionContext.key]); @@ -79,6 +82,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { sessionData.title.read(reader); sessionData.status.read(reader); sessionData.workspace.read(reader); + sessionData.changes.read(reader); } this._lastRenderState = undefined; this._render(); @@ -150,9 +154,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); const repoBranchLabel = this._getRepositoryBranchLabel(); + const diffStats = this._getDiffStats(); // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${repoBranchLabel ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${repoBranchLabel ?? ''}|${diffStats ? `${diffStats.insertions}/${diffStats.deletions}` : ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -204,6 +209,20 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { detailsEl.appendChild(branchEl); } + if (diffStats) { + const separatorEl = $('div.agent-sessions-titlebar-separator'); + detailsEl.appendChild(separatorEl); + + const diffEl = $('div.agent-sessions-titlebar-diff'); + const addedEl = $('span.agent-sessions-titlebar-diff-added'); + addedEl.textContent = `+${diffStats.insertions}`; + diffEl.appendChild(addedEl); + const removedEl = $('span.agent-sessions-titlebar-diff-removed'); + removedEl.textContent = `-${diffStats.deletions}`; + diffEl.appendChild(removedEl); + detailsEl.appendChild(diffEl); + } + centerGroup.appendChild(detailsEl); } @@ -227,13 +246,13 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._container.appendChild(sessionPill); - // Hover - const hover = `${label}${repoLabel ? `, ${repoLabel}` : ''}${repoBranchLabel ? `, ${repoBranchLabel}` : ''}`; - this._dynamicDisposables.add(this.hoverService.setupManagedHover( - getDefaultHoverDelegate('mouse'), - sessionPill, - hover - )); + // Beacon-style hover with session details, positioned below center + this._dynamicDisposables.add(this.hoverService.setupDelayedHover(sessionPill, () => ({ + content: this._buildSessionHoverContent(), + style: HoverStyle.Pointer, + position: { hoverPosition: HoverPosition.BELOW }, + persistence: { hideOnHover: false }, + }))); // Keyboard handler this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { @@ -270,6 +289,17 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return undefined; } + /** + * Build a compact hover markdown for the active session. + */ + private _buildSessionHoverContent(): IMarkdownString { + const sessionData = this.sessionsManagementService.activeSession.get(); + if (!sessionData) { + return new MarkdownString(''); + } + return buildSessionHoverContent(sessionData, this.sessionsProvidersService); + } + /** * Get the repository label for the active session. */ @@ -292,6 +322,15 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return sessionData?.workspace.get()?.folders[0]?.gitRepository?.branchName?.trim() || undefined; } + /** + * Get the aggregated insertions/deletions for the active session, or + * undefined when there are no changes. + */ + private _getDiffStats(): { insertions: number; deletions: number } | undefined { + const sessionData = this.sessionsManagementService.activeSession.get(); + return sessionData ? getSessionDiffStats(sessionData) : undefined; + } + private _showContextMenu(e: MouseEvent): void { const sessionData = this.sessionsManagementService.activeSession.get(); if (!sessionData) { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 270bc8aa03b50..b3e5a19c16824 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -47,6 +47,8 @@ import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/bro import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ISessionsListModelService } from './sessionsListModelService.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { buildSessionHoverContent } from '../sessionHoverContent.js'; const $ = DOM.$; @@ -200,6 +202,7 @@ class SessionItemRenderer implements ITreeRenderer ({ + content: buildSessionHoverContent(element, this.sessionsProvidersService), + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT, forcePosition: true }, + persistence: { hideOnHover: false }, + }), { groupId: 'sessions-list' })); + // Toolbar context template.titleToolbar.context = element; @@ -826,6 +838,7 @@ export class SessionsList extends Disposable implements ISessionsList { const hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService)); const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); const accessibilityService = instantiationService.invokeFunction(accessor => accessor.get(IAccessibilityService)); + const sessionsProvidersService = instantiationService.invokeFunction(accessor => accessor.get(ISessionsProvidersService)); const sessionRenderer = new SessionItemRenderer( { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s) }, approvalModel, @@ -835,6 +848,7 @@ export class SessionsList extends Disposable implements ISessionsList { hoverService, agentSessionsService, accessibilityService, + sessionsProvidersService, ); const showMoreRenderer = new SessionShowMoreRenderer(); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 4846d408775b8..57aa04e95d116 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -453,6 +453,7 @@ import './contrib/providers/agentHost/browser/exportDebugLogsAction.js'; import './contrib/providers/agentHost/browser/agentHostSessionConfigPicker.js'; import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/providers/copilotChatSessions/browser/copilotChatSessions.contribution.js'; +import './contrib/providers/localChatSessions/browser/localChatSessions.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/views/sessionsListModelService.js'; import './services/agentHostFilter/browser/agentHostFilterService.js'; diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index b4e787340c088..46bd0dc8f0bd1 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -14,7 +14,7 @@ z-index: 2540; /* Never allow content to escape above the title bar */ overflow: hidden; - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.5); .modal-editor-resizable { position: absolute; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts index aa2a6e39947b6..7080c413165c8 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts @@ -462,9 +462,7 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { this._toolbar.refresh(); this._syncContextKeys(model.device, this._screen); this._updateSashState(); - if (model.device) { - this._setToolbarVisible(true); - } + this._setToolbarVisible(!!model.device); store.add(model.onDidChangeDevice(device => { this._updateSashState(); // Turning emulation off discards any in-progress screen overrides so @@ -477,11 +475,7 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { } this._toolbar.refresh(); this._syncContextKeys(device, this._screen); - if (device && !this._toolbar.isVisible) { - this._setToolbarVisible(true); - } else if (!device && this._toolbar.isVisible) { - this._setToolbarVisible(false); - } + this._setToolbarVisible(!!device); this.editor.layoutBrowserContainer(); })); } @@ -493,6 +487,7 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { this._screenInflight = undefined; this._toolbar.refresh(); this._syncContextKeys(undefined, undefined); + this._setToolbarVisible(false); } // -- Public API consumed by toolbar + actions -------------------------- diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index 6bbd37e020a2a..f73980dbefbe2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -10,7 +10,7 @@ import { ResourceMap } from '../../../../../../base/common/map.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationStatus, StateComponents, type SessionCustomization, type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; +import { ICustomizationAgentRef, ICustomizationItem, ICustomizationItemAction, ICustomizationItemProvider } from '../../../common/customizationHarnessService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -83,8 +83,8 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return rootState.agents.find(agent => agent.provider === this._agentInfo.provider)?.customizations; } - private toRemoteUri(customization: CustomizationRef): URI { - const original = URI.parse(customization.uri); + private toRemoteUri(customizationUri: string): URI { + const original = URI.parse(customizationUri); // The synthetic synced-customization bundle lives in the client's // in-memory filesystem. Don't wrap it as an agent-host:// URI — // the server doesn't have this scheme registered, so wrapping it @@ -110,7 +110,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto private toItem(customization: CustomizationRef, source: AICustomizationSource, sessionCustomization?: SessionCustomization): ICustomizationItem { const clientId = sessionCustomization?.clientId; // set if the configuration came from the client const badge = this.toBadge(customization, clientId !== undefined); - const uri = this.toRemoteUri(customization); + const uri = this.toRemoteUri(customization.uri); return { itemKey: customizationItemKey(customization, clientId), uri: uri, @@ -135,6 +135,22 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return AgentSession.uri(this._agentInfo.provider, rawId); } + private getSessionCustomizations(sessionResource: URI): readonly SessionCustomization[] { + const sessionUri = this._resolveSessionUri(sessionResource); + const sessionState = this._connection.getSubscriptionUnmanaged(StateComponents.Session, sessionUri)?.value; + return sessionState && !(sessionState instanceof Error) ? sessionState.customizations ?? [] : []; + } + + async provideCustomAgents(sessionResource: URI): Promise { + const sessionCustomizations = this.getSessionCustomizations(sessionResource); + const agents = sessionCustomizations.flatMap(c => c.agents ?? []); + return agents.map(agent => ({ + uri: this.toRemoteUri(agent.uri), + name: agent.name, + description: agent.description, + })); + } + async provideChatSessionCustomizations(sessionResource: URI, token: CancellationToken): Promise { const items = new Map(); @@ -157,10 +173,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto plugins.push(pluginMeta); expandPromises.push(this._expandPluginContents(pluginMeta, token)); } - const sessionUri = this._resolveSessionUri(sessionResource); - const sessionState = this._connection.getSubscriptionUnmanaged(StateComponents.Session, sessionUri)?.value; - const sessionCustomizations = sessionState && !(sessionState instanceof Error) ? sessionState.customizations ?? [] : []; - for (const sessionCustomization of sessionCustomizations) { + for (const sessionCustomization of this.getSessionCustomizations(sessionResource)) { const isBundleItem = isSyntheticBundle(sessionCustomization.customization); const isClientSynced = sessionCustomization.clientId !== undefined; const childGroupKey = isClientSynced ? REMOTE_CLIENT_GROUP : REMOTE_HOST_GROUP; @@ -176,7 +189,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto items.set(customizationItemKey(sessionCustomization.customization, sessionCustomization.clientId), item); } else { // create a dummy parent item for the synthetic bundle, it does not go into the items map, just need it to expand. - item = { uri: this.toRemoteUri(sessionCustomization.customization), type: 'plugin', source: AICustomizationSources.plugin, name: '', groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } satisfies ICustomizationItem; + item = { uri: this.toRemoteUri(sessionCustomization.customization.uri), type: 'plugin', source: AICustomizationSources.plugin, name: '', groupKey: childGroupKey, extensionId: undefined, pluginUri: undefined } satisfies ICustomizationItem; } const pluginMeta = { item, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 8aba67a213247..93c9dc20d788a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -9,8 +9,9 @@ import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promp import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter, AICustomizationSources, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { type AICustomizationSource, AICustomizationManagementSection, sectionToPromptType } from './aiCustomizationManagement.js'; -import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, type ICustomizationItem } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { IAICustomizationItemSource } from './aiCustomizationItemSource.js'; /** * Snapshot of the list widget's internal state, passed in to avoid coupling. @@ -34,7 +35,7 @@ export async function generateCustomizationDebugReport( promptsService: IPromptsService, workspaceService: IAICustomizationWorkspaceService, widgetState: IDebugWidgetState, - promptsServiceItemProvider: ICustomizationItemProvider, + itemSource: IAICustomizationItemSource, harnessService: ICustomizationHarnessService, agentPluginService: IAgentPluginService, ): Promise { @@ -81,8 +82,7 @@ export async function generateCustomizationDebugReport( // Stage 1: Provider output if (extensionProvider) { const providerLabel = 'Extension Provider'; - const sessionResource = harnessService.activeSessionResource.get(); - await appendProviderData(lines, extensionProvider, sessionResource, promptType, providerLabel); + await appendProviderData(lines, itemSource, promptType, providerLabel); } else { // Stage 2: Raw PromptsService data — always useful for diagnostics lines.push('--- Stage 1: No provider available ---'); @@ -127,20 +127,19 @@ async function getPromptFilesByStorage(promptsService: IPromptsService, promptTy return { localFiles, userFiles, extensionFiles }; } -async function appendProviderData(lines: string[], provider: ICustomizationItemProvider, sessionResource: URI, promptType: PromptsType, label: string): Promise { +async function appendProviderData(lines: string[], itemSource: IAICustomizationItemSource, promptType: PromptsType, label: string): Promise { lines.push(`--- Stage 1: Provider Output (${label}) ---`); - const allItems = await provider.provideChatSessionCustomizations(sessionResource, CancellationToken.None); - if (!allItems) { - lines.push(' Provider returned undefined'); - lines.push(''); - return; - } + const allItems = await itemSource.fetchProviderItems(); - lines.push(` Total items from provider: ${allItems.length}`); + if (allItems.length === 0) { + lines.push(` Total items from provider: 0 (or provider returned undefined and the item source normalized it to an empty array)`); + } else { + lines.push(` Total items from provider: ${allItems.length}`); + } // Group by type for summary - const byType = new Map(); + const byType = new Map(); for (const item of allItems) { const existing = byType.get(item.type) ?? []; existing.push(item); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 19421db0ddd83..01816a6e2b7ed 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -81,8 +81,9 @@ export interface IAICustomizationListItem { */ export interface IAICustomizationItemSource extends IDisposable { readonly sessionResource: URI; - readonly onDidChange: Event; - fetchItems(promptType: PromptsType): Promise; + readonly onDidAICustomizationItemsChange: Event; + fetchProviderItems(): Promise; + fetchAICustomizationItems(promptType: PromptsType): Promise; } // #endregion @@ -257,7 +258,7 @@ export class AICustomizationItemNormalizer { */ export class ItemProviderItemSource extends Disposable implements IAICustomizationItemSource { - readonly onDidChange: Event; + readonly onDidAICustomizationItemsChange: Event; private cachedPromise: Promise | undefined; constructor( @@ -270,20 +271,18 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati private readonly itemNormalizer: AICustomizationItemNormalizer, ) { super(); - this.onDidChange = Event.any( + this.onDidAICustomizationItemsChange = Event.any( this.itemProvider.onDidChange, this.promptsService.onDidChangeSkills ); // Invalidate cache when provider or skills change - this._register(this.itemProvider.onDidChange(() => { + this._register(this.onDidAICustomizationItemsChange(() => { this.cachedPromise = undefined; })); } - - async fetchItems(promptType: PromptsType): Promise { - // Use cached result if available + async fetchProviderItems(): Promise { if (!this.cachedPromise) { this.cachedPromise = this.itemProvider.provideChatSessionCustomizations(this.sessionResource, CancellationToken.None); } @@ -292,6 +291,11 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati if (cached !== this.cachedPromise || !allItems) { return []; } + return allItems; + } + + async fetchAICustomizationItems(promptType: PromptsType): Promise { + const allItems = await this.fetchProviderItems(); let providerItems: readonly ICustomizationItem[]; if (promptType === PromptsType.hook) { @@ -413,8 +417,8 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati export class PureItemProviderItemSource extends Disposable implements IAICustomizationItemSource { - readonly onDidChange: Event; - private cachedPromise: Promise | undefined; + readonly onDidAICustomizationItemsChange: Event; + private cachedPromise: Promise | undefined; constructor( readonly sessionResource: URI, @@ -422,28 +426,35 @@ export class PureItemProviderItemSource extends Disposable implements IAICustomi private readonly itemNormalizer: AICustomizationItemNormalizer, ) { super(); - this.onDidChange = this.itemProvider.onDidChange; + this.onDidAICustomizationItemsChange = this.itemProvider.onDidChange; - // Invalidate cache when provider or skills change + // Invalidate cache when the provider changes this._register(this.itemProvider.onDidChange(() => { this.cachedPromise = undefined; })); } - - async fetchItems(promptType: PromptsType): Promise { - // Use cached result if available + async fetchProviderItems(): Promise { if (!this.cachedPromise) { - this.cachedPromise = this.itemProvider.provideChatSessionCustomizations(this.sessionResource, CancellationToken.None).then(items => - items ? this.itemNormalizer.normalizeItems(items, promptType) : undefined - ); + const promise = this.itemProvider.provideChatSessionCustomizations(this.sessionResource, CancellationToken.None); + this.cachedPromise = promise; + promise.catch(() => { + if (this.cachedPromise === promise) { + this.cachedPromise = undefined; + } + }); } const cached = this.cachedPromise; const allItems = await cached; if (cached !== this.cachedPromise || !allItems) { return []; } - return allItems.filter(item => item.promptType === promptType); + return allItems; + } + + async fetchAICustomizationItems(promptType: PromptsType): Promise { + const allItems = await this.fetchProviderItems(); + return this.itemNormalizer.normalizeItems(allItems, promptType); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index b69158594d9b2..a7f6928a460bf 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -17,7 +16,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService, ICustomizationItemProvider, isPluginCustomizationItem } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, isPluginCustomizationItem } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; @@ -85,12 +84,6 @@ export interface IAICustomizationItemsModel { */ getPluginCount(): IObservable; - /** - * The fallback item provider used when the active descriptor has neither - * an `itemProvider` nor a `syncProvider`. Exposed for the debug report. - */ - getPromptsServiceItemProvider(): ICustomizationItemProvider; - /** * Resolves once the most recent fetch for `section` has settled. Useful for * tests / fixtures that need rendered output to reflect at least one fetch. @@ -184,7 +177,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this._register(autorun(reader => { const activeSessionResource = this.harnessService.activeSessionResource.read(reader); const source = this.getOrCreateSource(activeSessionResource); - sourceChangeListener.value = source.onDidChange(() => { + sourceChangeListener.value = source.onDidAICustomizationItemsChange(() => { this.scheduleRefetchObserved(source); }); this.scheduleRefetchObserved(source); @@ -218,10 +211,6 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz return this.getOrCreateSource(this.harnessService.activeSessionResource.get()); } - getPromptsServiceItemProvider(): ICustomizationItemProvider { - return this.promptsServiceItemProvider; - } - whenSectionLoaded(section: ItemsModelSection): Promise { this.markObserved(section); return this.perSectionPending.get(section) ?? Promise.resolve(); @@ -293,7 +282,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz this.fetchSeq.set(section, seq); const promptType = sectionToPromptType(section); const observable = this.perSection.get(section)!; - const pending = source.fetchItems(promptType).then(items => { + const pending = source.fetchAICustomizationItems(promptType).then(items => { if (this._store.isDisposed) { return; } @@ -317,16 +306,11 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz private refetchPluginCount(source: IAICustomizationItemSource): void { const seq = ++this.pluginFetchSeq; - const sessionRessource = this.harnessService.activeSessionResource.get(); - const descriptor = this.harnessService.getActiveDescriptor(); - const provider = descriptor.itemProvider; - const pending: Promise = provider - ? provider.provideChatSessionCustomizations(sessionRessource, CancellationToken.None).then(items => { - return (items ?? []) - .filter(item => isPluginCustomizationItem(item) && item.groupKey !== 'remote-client') - .map(item => item.name ?? ''); - }) - : Promise.resolve([]); + const pending = source.fetchProviderItems().then(items => { + return items + .filter(item => isPluginCustomizationItem(item) && item.groupKey !== 'remote-client') + .map(item => item.name ?? ''); + }); pending.then(names => { if (this._store.isDisposed) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index dc287430a33a7..45a9ef829b308 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -1601,7 +1601,7 @@ export class AICustomizationListWidget extends Disposable { this.promptsService, this.workspaceService, { allItems: this.allItems, displayEntries: this.displayEntries }, - this.itemsModel.getPromptsServiceItemProvider(), + this.itemsModel.getActiveItemSource(), this.harnessService, this.agentPluginService, ); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index f9e76c3372279..1efee27fd9759 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -21,7 +21,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Delayer } from '../../../../../base/common/async.js'; import { Action, IAction, Separator } from '../../../../../base/common/actions.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; @@ -41,6 +41,7 @@ import { ICustomizationHarnessService, isPluginCustomizationItem, type ICustomiz import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; +import { IAICustomizationItemsModel } from './aiCustomizationItemsModel.js'; const $ = DOM.$; @@ -469,6 +470,7 @@ export class PluginListWidget extends Disposable { @ILabelService private readonly labelService: ILabelService, @ICommandService private readonly commandService: ICommandService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -977,14 +979,12 @@ export class PluginListWidget extends Disposable { } private async getRemotePluginItems(query: string): Promise { - const provider = this.harnessService.getActiveDescriptor().itemProvider; - if (!provider) { + if (!this.harnessService.getActiveDescriptor().itemProvider) { return []; } - const sessionResource = this.harnessService.activeSessionResource.get(); try { - const provided = await provider.provideChatSessionCustomizations(sessionResource, CancellationToken.None) ?? []; + const provided = await this.itemsModel.getActiveItemSource().fetchProviderItems(); return provided.filter(item => isPluginCustomizationItem(item) && (!query diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index 01c2d6e34fcfa..e1bf9f2caa248 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -17,7 +17,7 @@ import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js' import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { ICustomizationAgentRef, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; import { getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; @@ -55,6 +55,11 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt return itemSets.flat(); } + async provideCustomAgents(sessionResource: URI, token: CancellationToken): Promise { + const agents = await this.promptsService.getCustomAgents(token); + return agents.map(agent => ({ uri: agent.uri, name: agent.name, description: agent.description } satisfies ICustomizationAgentRef)); + } + private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { const items: ICustomizationItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index ed6ed50940d3e..35dcedc7db2aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1706,16 +1706,7 @@ configurationRegistry.registerConfiguration({ tags: ['preview'], description: nls.localize('chat.customizations.structuredPreview.enabled', "Controls whether the Chat Customizations editor shows a structured preview for markdown customization files (agents, skills, instructions, prompts). When disabled, the editor always opens the raw markdown in the embedded code editor."), default: false, - }, - [ChatConfiguration.UseChatSessionCustomizationsForCustomAgents]: { - type: 'boolean', - description: nls.localize('chat.customizations.useChatSessionCustomizationsForCustomAgents', "When enabled, custom agents shown in the chat mode picker are sourced from the customization harness service (scoped per session type) instead of the prompts service."), - default: false, - tags: ['experimental', 'advanced'], - experiment: { - mode: 'auto' - } - }, + } } }); Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css index 2506be42f941e..98da5fd4940f0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/media/chatModelsWidget.css @@ -264,6 +264,10 @@ visibility: hidden; } +.models-widget .models-table-container .models-gutter-column .monaco-action-bar .action-item:has(.model-visibility-toggle) { + margin-right: 4px; +} + .models-widget .models-table-container .monaco-list-row.focused .monaco-table-td .models-gutter-column .model-visibility-toggle, .models-widget .models-table-container .monaco-list-row.selected .monaco-table-td .models-gutter-column .model-visibility-toggle, .models-widget .models-table-container .monaco-list-row:hover .monaco-table-td .models-gutter-column .model-visibility-toggle, diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index 351c2fcc5aa2c..60420df2f0efb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -6,9 +6,10 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { localize } from '../../../../nls.js'; import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; +import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; -import { ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { ChatConfiguration, ChatModeKind, OPEN_AGENTS_WINDOW_COMMAND_ID, OPEN_AGENTS_WINDOW_PRECONDITION, OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID } from '../common/constants.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; import { localChatSessionType } from '../common/chatSessionsService.js'; @@ -28,6 +29,13 @@ export const enum ChatTipTier { Qol = 'qol', } +/** + * Treatment names for tip messages overridable via the workbench assignment service. + */ +export const enum ChatTipExperiment { + OpenAgentsWindowTip = 'openagentswindowtip', +} + /** * Context provided to tip builders for dynamic message construction. */ @@ -36,6 +44,11 @@ export interface ITipBuildContext { * Keybinding service for looking up keyboard shortcuts. */ readonly keybindingService: IKeybindingService; + /** + * Experimental tip message overrides keyed by treatment name (see {@link ChatTipExperiment}). + * Builders should fall back to their default localized strings when a treatment is not set. + */ + readonly experimentalTipMessages: ReadonlyMap; } /** @@ -411,6 +424,27 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), excludeWhenToolsInvoked: ['listDebugEvents'], }, + { + id: 'tip.agentsWindow', + tier: ChatTipTier.Qol, + buildMessage(ctx) { + const defaultMessage = localize( + 'tip.agentsWindow', + "Work across multiple projects at once in the [Agents window](command:{0} \"Open Agents Window\").", + OPEN_AGENTS_WINDOW_COMMAND_ID + ); + const experimentalTemplate = ctx.experimentalTipMessages.get(ChatTipExperiment.OpenAgentsWindowTip); + const message = experimentalTemplate + ? experimentalTemplate.replace(/\{0\}/g, OPEN_AGENTS_WINDOW_COMMAND_ID) + : defaultMessage; + return new MarkdownString(message); + }, + when: ContextKeyExpr.and(IsWebContext.negate(), OPEN_AGENTS_WINDOW_PRECONDITION), + excludeWhenCommandsExecuted: [ + OPEN_AGENTS_WINDOW_COMMAND_ID, + OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID, + ], + }, { id: 'tip.copilotCli', tier: ChatTipTier.Qol, diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index f7f87e7c81699..5ad2fe60724e3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -23,8 +23,9 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet 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'; +import { ChatTipExperiment, ChatTipTier, extractCommandIds, ITipBuildContext, ITipDefinition, TIP_CATALOG } from './chatTipCatalog.js'; import { ChatTipStorageKeys, TipTrackingCommands } from './chatTipStorageKeys.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; type ChatTipEvent = { tipId: string; @@ -200,6 +201,7 @@ export class ChatTipService extends Disposable implements IChatTipService { private _thinkingPhrasesEverModified: boolean; private _tipsHiddenForSession = false; private readonly _tipCommandListener = this._register(new MutableDisposable()); + private readonly _experimentalTipMessages = new Map(); constructor( @IProductService private readonly _productService: IProductService, @@ -212,10 +214,13 @@ export class ChatTipService extends Disposable implements IChatTipService { @ICommandService private readonly _commandService: ICommandService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IWorkbenchAssignmentService private readonly _assignmentService: IWorkbenchAssignmentService, ) { super(); this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); this._createSlashCommandsUsageTracker = this._register(new CreateSlashCommandsUsageTracker(this._chatService, this._storageService, () => this._contextKeyService)); + this._fetchExperimentalTipMessages(); + this._register(this._assignmentService.onDidRefetchAssignments(() => this._fetchExperimentalTipMessages())); this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => { if (this._chatEntitlementService.quotas.chat?.percentRemaining === 0 && this._shownTip) { this.hideTip(); @@ -819,9 +824,17 @@ export class ChatTipService extends Disposable implements IChatTipService { return !!defaultChatAgent?.chatExtensionId; } + private _fetchExperimentalTipMessages(): void { + this._assignmentService.getTreatment(ChatTipExperiment.OpenAgentsWindowTip).then(value => { + if (typeof value === 'string' && value.length > 0) { + this._experimentalTipMessages.set(ChatTipExperiment.OpenAgentsWindowTip, value); + } + }); + } + private _createTip(tipDef: ITipDefinition): IChatTip { // Build the tip message with dynamic keybindings and command labels - const ctx: ITipBuildContext = { keybindingService: this._keybindingService }; + const ctx: ITipBuildContext = { keybindingService: this._keybindingService, experimentalTipMessages: this._experimentalTipMessages }; const rawMessage = tipDef.buildMessage(ctx); // Add "Tip:" prefix once here, avoiding duplication in individual tip definitions @@ -852,7 +865,7 @@ export class ChatTipService extends Disposable implements IChatTipService { this._tipCommandListener.clear(); // Build message to extract enabled commands dynamically - const ctx: ITipBuildContext = { keybindingService: this._keybindingService }; + const ctx: ITipBuildContext = { keybindingService: this._keybindingService, experimentalTipMessages: this._experimentalTipMessages }; const rawMessage = tip.buildMessage(ctx); const enabledCommands = extractCommandIds(rawMessage.value); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 62c48b9d2e511..b36704dda59a1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -1363,7 +1363,7 @@ export class ModelPickerWidget extends Disposable { getWidgetRole: () => 'menu' as const, }, { - footerText: localize('chat.tokens.costHint', "Larger size may increase cost in longer sessions"), + footerText: localize('chat.tokens.costHint', "Larger context may increase cost"), } ); } diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index f6f5dbb6a8fa7..4955841e0679e 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -21,7 +21,7 @@ import { ChatContextKeys } from './actions/chatContextKeys.js'; import { getChatSessionType, LocalChatSessionUri } from './model/chatUri.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; import { IHandOff } from './promptSyntax/promptFileParser.js'; -import { IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, ICustomAgentVisibility, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; import { ICustomizationHarnessService } from './customizationHarnessService.js'; import { PromptFileSource, Target } from './promptSyntax/promptTypes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -82,7 +82,6 @@ class ChatModes extends Disposable implements IChatModes { constructor( private readonly sessionResource: URI, - @IPromptsService private readonly promptsService: IPromptsService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IContextKeyService contextKeyService: IContextKeyService, @ILogService private readonly logService: ILogService, @@ -101,12 +100,9 @@ class ChatModes extends Disposable implements IChatModes { this.loadCachedModes(); this._pendingRefresh = this.refreshCustomPromptModes(true); - this._register(this.promptsService.onDidChangeCustomAgents(() => { - this._pendingRefresh = this.refreshCustomPromptModes(true); - })); // When the harness service is the source, also react to its change events for our session type. this._register(this.customizationHarnessService.onDidChangeCustomAgents(e => { - if (e.sessionType === sessionType && this.useChatSessionCustomizationsForCustomAgents()) { + if (e.sessionType === sessionType) { this._pendingRefresh = this.refreshCustomPromptModes(true); } })); @@ -117,10 +113,6 @@ class ChatModes extends Disposable implements IChatModes { if (e.affectsConfiguration(ChatConfiguration.AgentEnabled)) { this._onDidChange.fire(); } - if (e.affectsConfiguration(ChatConfiguration.UseChatSessionCustomizationsForCustomAgents)) { - // Source switched: re-fetch from the now-active provider. - this._pendingRefresh = this.refreshCustomPromptModes(true); - } })); let didHaveToolsAgent = this.chatAgentService.hasToolsAgent; this._register(this.chatAgentService.onDidChangeAgents(() => { @@ -212,22 +204,9 @@ class ChatModes extends Disposable implements IChatModes { } } - private useChatSessionCustomizationsForCustomAgents(): boolean { - return this.configurationService.getValue(ChatConfiguration.UseChatSessionCustomizationsForCustomAgents) === true; - } - - private async computeCustomAgents(): Promise { - const useHarness = this.useChatSessionCustomizationsForCustomAgents(); - if (useHarness) { - return await this.customizationHarnessService.getCustomAgents(this.sessionResource, CancellationToken.None); - } - const sessionType = getChatSessionType(this.sessionResource); - return (await this.promptsService.getCustomAgents(CancellationToken.None)).filter(mode => matchesSessionType(mode.sessionTypes, sessionType)); - } - private async refreshCustomPromptModes(fireChangeEvent?: boolean): Promise { try { - const customModes = await this.computeCustomAgents(); + const customModes = await this.customizationHarnessService.getCustomAgents(this.sessionResource, CancellationToken.None); // Create a new set of mode instances, reusing existing ones where possible const seenUris = new Set(); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 54cad0c22de49..ba55927c9ab64 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -70,7 +70,6 @@ export enum ChatConfiguration { ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', ChatCustomizationsStructuredPreviewEnabled = 'chat.customizations.structuredPreview.enabled', - UseChatSessionCustomizationsForCustomAgents = 'chat.customizations.useChatSessionCustomizationsForCustomAgents', AutopilotEnabled = 'chat.autopilot.enabled', PlanReviewInlineEditorEnabled = 'chat.planReview.inlineEditor.enabled', DefaultPermissionLevel = 'chat.permissions.default', diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 66cadfa9765be..4170dec089f4b 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -191,6 +191,14 @@ export interface ICustomizationItem { readonly actions?: readonly ICustomizationItemAction[]; } +export interface ICustomizationAgentRef { + readonly uri: URI; + /** Agent name (from frontmatter `name`, or file-derived) */ + readonly name: string; + /** Optional short description for UI preview (from frontmatter `description`) */ + readonly description?: string; +} + export function isPluginCustomizationItem(item: { readonly type: string }): boolean { return item.type === 'plugin' || item.type === AICustomizationManagementSection.Plugins; } @@ -213,6 +221,16 @@ export interface ICustomizationItemProvider { * this session. */ provideChatSessionCustomizations(sessionResource: URI, token: CancellationToken): Promise; + + /** + * Provide the custom agents this harness supports. + * + * @param sessionResource URI of the chat session whose + * customizations should be included. Providers that surface + * session-scoped state (e.g. an agent host) should read from + * this session. + */ + provideCustomAgents?(sessionResource: URI, token: CancellationToken): Promise; } /** @@ -695,6 +713,28 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer return allAgents.filter(agent => matchesSessionType(agent.sessionTypes, sessionType)); } + if (harness.itemProvider.provideCustomAgents) { + const items = await harness.itemProvider.provideCustomAgents(sessionResource, token); + if (items) { + const result: ICustomAgent[] = []; + for (const item of items) { + const promptFile = await this.promptsService.parseNew(item.uri, token); + const extra = { + name: item.name, + description: item.description, + sessionTypes: [sessionType], + hooks: undefined, + source: { storage: PromptsStorage.local } satisfies IAgentSource, + type: PromptsType.agent, + enabled: true, + }; + result.push(CustomAgent.fromParsedPromptFile(promptFile, extra)); + } + return result; + } + } + + const items = await harness.itemProvider.provideChatSessionCustomizations(sessionResource, token); if (!items) { return []; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 7fd97104c616f..e79fc1e4b7919 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -1697,15 +1697,16 @@ export class LanguageModelsService implements ILanguageModelsService { } } - private async promptForEnum(groupName: string, property: string, propertySchema: IJSONSchema, existing: IStringDictionary | undefined): Promise { + private async promptForEnum(groupName: string, property: string, propertySchema: IJSONSchema & { enumItemLabels?: string[] }, existing: IStringDictionary | undefined): Promise { const values = propertySchema.enum; if (!Array.isArray(values) || values.length === 0) { return undefined; } const enumDescriptions = propertySchema.enumDescriptions; + const enumItemLabels = Array.isArray(propertySchema.enumItemLabels) ? propertySchema.enumItemLabels : undefined; const initial = existing?.[property] !== undefined ? String(existing[property]) : (propertySchema.default !== undefined ? String(propertySchema.default) : undefined); const items: IQuickPickItem[] = values.map((value, index) => ({ - label: String(value), + label: enumItemLabels?.[index] ?? String(value), description: enumDescriptions?.[index], id: String(value) })); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index a5c6d9392b860..51a8b0f59ef08 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -66,6 +66,11 @@ export const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; */ export const CLAUDE_CONFIG_FOLDER = '.claude'; +/** + * Copilot configuration folder name. + */ +export const COPILOT_CONFIG_FOLDER = '.copilot'; + /** * Copilot custom instructions file name. */ 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 7416be30bb0a9..05817cb31411b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -28,7 +28,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; -import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, getSkillFolderName, GITHUB_CONFIG_FOLDER, IResolvedPromptSourceFolder, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CONFIG_FOLDER, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, getSkillFolderName, GITHUB_CONFIG_FOLDER, IResolvedPromptSourceFolder, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptFileSource, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IWorkspaceInstructionFile, PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { evaluateApplyToPattern, PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; @@ -699,8 +699,9 @@ export class PromptsService extends Disposable implements IPromptsService { if (!useCopilotInstructionsFiles) { logger?.logInfo('Copilot instructions files are disabled via configuration.'); } else { - const githubConfigFiles = [{ fileName: COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, type: AgentInstructionFileType.copilotInstructionsMd }]; - promises.push(this.fileLocator.findFilesInRoots(rootFolders, GITHUB_CONFIG_FOLDER, githubConfigFiles, token, resolvedAgentFiles)); + const copilotInstructionsFile = { fileName: COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, type: AgentInstructionFileType.copilotInstructionsMd }; + promises.push(this.fileLocator.findFilesInRoots(rootFolders, GITHUB_CONFIG_FOLDER, [copilotInstructionsFile], token, resolvedAgentFiles)); // copilot-instructions.md in .github folder under workspace root + promises.push(this.fileLocator.findFilesInRoots([await this.pathService.userHome()], COPILOT_CONFIG_FOLDER, [copilotInstructionsFile], token, resolvedAgentFiles)); // copilot-instructions.md in ~/.copilot folder } promises.push(this.fileLocator.findFilesInRoots(rootFolders, undefined, rootFiles, token, resolvedAgentFiles)); @@ -1469,4 +1470,3 @@ export namespace CustomAgent { } } - diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index 7cdb56ebe8baf..dad4232226526 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -241,8 +241,7 @@ suite('aiCustomizationListWidget', () => { getItems: () => observableValue('test', [] as readonly never[]), getCount: () => observableValue('test', 0), getPluginCount: () => observableValue('test', 0), - getActiveItemSource: () => ({ onDidChange: Event.None, fetchItems: async () => [], sessionResource: activeSessionResource.get(), dispose() { } }), - getPromptsServiceItemProvider: () => ({ onDidChange: Event.None, provideChatSessionCustomizations: async () => undefined }), + getActiveItemSource: () => ({ onDidAICustomizationItemsChange: Event.None, fetchProviderItems: async () => [], fetchAICustomizationItems: async () => [], sessionResource: activeSessionResource.get(), dispose() { } }), }); }); 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 4bd43e5fc149b..b3fed0d43ee80 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -17,6 +17,8 @@ import { MockContextKeyService } from '../../../../../platform/keybinding/test/c import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js'; import { ChatTipService, CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND, CREATE_AGENT_TRACKING_COMMAND, CREATE_PROMPT_TRACKING_COMMAND, CREATE_SKILL_TRACKING_COMMAND, FORK_CONVERSATION_TRACKING_COMMAND, IChatTip, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; import { AgentInstructionFileType, IPromptPath, IPromptsService, IAgentInstructionFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -127,6 +129,7 @@ suite('ChatTipService', () => { instantiationService.stub(IKeybindingService, { lookupKeybinding: () => undefined, } as Partial as IKeybindingService); + instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); }); test('returns a welcome tip', () => { @@ -144,6 +147,7 @@ suite('ChatTipService', () => { keybindingService: { lookupKeybinding: () => undefined, } as Partial as IKeybindingService, + experimentalTipMessages: new Map(), }).value; const commandLinkRegex = /\[[^\]]+\]\((command:[^)]+)\)/g; diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index ab4189afac2cf..04b5dccedbd6b 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -20,9 +20,10 @@ import { IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatMode, ChatModeService } from '../../common/chatModes.js'; import { ChatModeKind } from '../../common/constants.js'; import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { createVSCodeHarnessDescriptor, CustomizationHarnessServiceBase, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; +import { SessionType } from '../../common/chatSessionsService.js'; class TestChatAgentService implements Partial { _serviceBrand: undefined; @@ -53,6 +54,7 @@ suite('ChatModeService', () => { let storageService: TestStorageService; let configurationService: TestConfigurationService; let chatModeService: ChatModeService; + let customizationHarnessService: CustomizationHarnessServiceBase; setup(async () => { instantiationService = testDisposables.add(new TestInstantiationService()); @@ -60,17 +62,14 @@ suite('ChatModeService', () => { chatAgentService = new TestChatAgentService(); storageService = testDisposables.add(new TestStorageService()); configurationService = new TestConfigurationService(); - + customizationHarnessService = testDisposables.add(new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService)); instantiationService.stub(IPromptsService, promptsService); instantiationService.stub(IChatAgentService, chatAgentService); instantiationService.stub(IStorageService, storageService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IContextKeyService, new MockContextKeyService()); instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(ICustomizationHarnessService, { - onDidChangeCustomAgents: Event.None, - getCustomAgents: async () => [], - }); + instantiationService.stub(ICustomizationHarnessService, customizationHarnessService); chatModeService = testDisposables.add(instantiationService.createInstance(ChatModeService)); // Eagerly create the ChatModes for the local session type and await 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 5690b9694197f..964cc7c15339b 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 @@ -2341,6 +2341,51 @@ suite('ComputeAutomaticInstructions', () => { assert.ok(!paths.includes(`/home/user/.claude/CLAUDE.md`), 'Should not include ~/.claude/CLAUDE.md when disabled'); }); + test('should collect ~/.copilot/copilot-instructions.md when enabled', async () => { + const rootFolderName = 'collect-copilot-home-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `/home/user/.copilot/copilot-instructions.md`, + contents: [ + 'Copilot guidelines from home', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: [ + 'console.log("test");', + ] + }, + ]); + + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + let instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + let paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(paths.includes(`/home/user/.copilot/copilot-instructions.md`), 'Should include ~/.copilot/copilot-instructions.md when enabled'); + + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); + const variables2 = new ChatRequestVariableSet(); + variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer2.collect(variables2, CancellationToken.None); + + instructionFiles = variables2.asArray().filter(v => isPromptFileVariableEntry(v)); + paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(!paths.includes(`/home/user/.copilot/copilot-instructions.md`), 'Should not include ~/.copilot/copilot-instructions.md when disabled'); + }); + test('should collect instructions from multi-root workspace', async () => { const rootFolder1Name = 'multi-root-1'; const rootFolder1 = `/${rootFolder1Name}`; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 7f079dd4caf19..8eafc359675e6 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -674,7 +674,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } private async handleMcpInstallUri(uri: URI): Promise { - let parsed: IMcpServerConfiguration & { name: string; inputs?: IMcpServerVariable[]; gallery?: boolean }; + let parsed: IMcpServerConfiguration & { name: string; inputs?: IMcpServerVariable[] }; try { parsed = JSON.parse(decodeURIComponent(uri.query)); } catch (e) { @@ -682,7 +682,27 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } try { - const { name, inputs, gallery, ...config } = parsed; + const { name, inputs, ...config } = parsed; + + // When a gallery field is present and the gallery service is available, + // verify the server exists in the active gallery by name. If verified, + // route through the gallery-only path (matching handleMcpServerByName). + if (config.gallery && this.mcpGalleryService.isEnabled()) { + try { + // Verify by name against the active gallery (not by URL, which would + // make outbound requests to untrusted URLs from the protocol payload). + const [galleryServer] = await this.mcpGalleryService.getMcpServersFromGallery([{ name }]); + if (galleryServer) { + const local = this.local.find(e => e.name === galleryServer.name) ?? this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), e => this.getRuntimeStatus(e), undefined, galleryServer, undefined); + this.open(local); + return true; + } + this.logService.info(`MCP server '${name}' not found in gallery, installing as local`); + } catch (e) { + this.logService.info(`Gallery verification failed for MCP server '${name}', installing as local`); + } + } + if (config.type === undefined) { (>config).type = (parsed).command ? McpServerType.LOCAL : McpServerType.REMOTE; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts index 7f934c650a7cf..b9cd6c2b6dc54 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; import { dirname, posix, win32 } from '../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../base/common/platform.js'; +import { arch } from '../../../../base/common/process.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; @@ -263,7 +264,10 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService const execPath = await this._getExecPath(os, appRoot, remoteAuthority); const tempDir = await this._getTempDir(remoteAuthority); const srtPath = this._pathJoin(os, appRoot, 'node_modules', '@vscode', 'sandbox-runtime', 'dist', 'cli.js'); - const rgPath = this._pathJoin(os, appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'); + // @vscode/ripgrep-universal ships per-platform-arch binaries under bin/{platform}-{arch}/{rg|rg.exe} + // Windows is handled by the early return above, so os is narrowed to Mac/Linux here. + const rgPlatform = os === OperatingSystem.Macintosh ? 'darwin' : 'linux'; + const rgPath = this._pathJoin(os, appRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', `${rgPlatform}-${arch}`, 'rg'); const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig, launchCwd) : undefined; this._logService.debug(`McpSandboxService: Updated sandbox config path: ${sandboxConfigPath}`); return { execPath, srtPath, rgPath, sandboxConfigPath, tempDir }; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts new file mode 100644 index 0000000000000..b1438fb3f8767 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { IAgentConnection, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostSandboxConfigKey } from '../../../../../platform/agentHost/common/sandboxConfigSchema.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { ROOT_STATE_URI } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { readAgentHostSandboxValues, SANDBOX_SETTING_KEYS } from '../common/sandboxSettingsReader.js'; + +/** + * Forwards the workbench user's sandbox setting values into every connected + * agent host (local + remote) via `RootConfigChanged` actions, so the + * agent-host terminal sandbox engine can mirror the user's preferences. + * + * Each push is schema-guarded against the receiving host's published root + * config schema, so older hosts that don't advertise the sandbox keys + * gracefully ignore them. Per-key value comparison against + * `rootState.config.values` suppresses no-op dispatches. + * + * The forwarder reacts to: + * - workbench `IConfigurationService.onDidChangeConfiguration` for any + * sandbox-related key (modern or deprecated) + * - `IAgentHostService.rootState.onDidChange` / per-remote rootState + * hydration (covers the initial push race where state arrives after + * construction) + * - `IRemoteAgentHostService.onDidChangeConnections` (new remotes get an + * initial push as soon as they connect) + */ +export class AgentHostSandboxForwarder extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.agentHostSandboxForwarder'; + + private readonly _remoteListeners = this._register(new MutableDisposable()); + + constructor( + @IAgentHostService private readonly _localAgentHostService: IAgentHostService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (SANDBOX_SETTING_KEYS.some(key => e.affectsConfiguration(key))) { + this._pushToAllConnections(); + } + })); + + this._register(this._localAgentHostService.rootState.onDidChange(() => { + this._pushToConnection(this._localAgentHostService); + })); + + this._register(this._remoteAgentHostService.onDidChangeConnections(() => { + this._refreshRemoteListeners(); + this._pushToAllConnections(); + })); + this._refreshRemoteListeners(); + + this._pushToAllConnections(); + } + + private _refreshRemoteListeners(): void { + const store = new DisposableStore(); + for (const info of this._remoteAgentHostService.connections) { + const connection = this._remoteAgentHostService.getConnection(info.address); + if (connection) { + store.add(connection.rootState.onDidChange(() => this._pushToConnection(connection))); + } + } + this._remoteListeners.value = store; + } + + private _pushToAllConnections(): void { + this._pushToConnection(this._localAgentHostService); + for (const info of this._remoteAgentHostService.connections) { + const connection = this._remoteAgentHostService.getConnection(info.address); + if (connection) { + this._pushToConnection(connection); + } + } + } + + private _pushToConnection(connection: IAgentConnection): void { + const rootState = connection.rootState.value; + if (!rootState || rootState instanceof Error) { + return; + } + const schemaProperties = rootState.config?.schema.properties; + if (!schemaProperties?.[AgentHostSandboxConfigKey.Sandbox]) { + // Older hosts that don't advertise the `sandbox` config key — + // skip silently. + return; + } + const desired = readAgentHostSandboxValues(this._configurationService, this._logService); + if (typeof desired.enabled === 'object') { + delete desired.enabled; // Work around nested enabled.windows setting. + } + const current = (rootState.config?.values?.[AgentHostSandboxConfigKey.Sandbox] as Record | undefined) ?? {}; + if (equals(current, desired)) { + return; + } + connection.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostSandboxConfigKey.Sandbox]: desired }, + }); + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index 5b73122b41bd4..fbf457915a46a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -23,6 +23,7 @@ import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey import { TerminalChatAgentToolsCommandId } from '../common/terminal.chatAgentTools.js'; import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js'; import { AgentNetworkDomainSettingId } from '../../../../../platform/networkFilter/common/settings.js'; +import { AgentHostSandboxForwarder } from './agentHostSandboxForwarder.js'; import { GetTerminalLastCommandTool, GetTerminalLastCommandToolData } from './tools/getTerminalLastCommandTool.js'; import { KillTerminalTool, KillTerminalToolData } from './tools/killTerminalTool.js'; import { GetTerminalOutputTool, GetTerminalOutputToolData } from './tools/getTerminalOutputTool.js'; @@ -192,6 +193,7 @@ export class ChatAgentToolsContribution extends Disposable implements IWorkbench } } registerWorkbenchContribution2(ChatAgentToolsContribution.ID, ChatAgentToolsContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostSandboxForwarder.ID, AgentHostSandboxForwarder, WorkbenchPhase.AfterRestored); // #endregion Contributions diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts new file mode 100644 index 0000000000000..d0f1ba7dfdec0 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { AgentNetworkDomainSettingId } from '../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; +import { sandboxSettingIdToAgentHostKey } from '../../../../../platform/agentHost/common/sandboxConfigSchema.js'; + +/** Setting IDs that affect the engine's sandbox configuration (modern + deprecated). */ +export const SANDBOX_SETTING_KEYS: readonly string[] = [ + AgentSandboxSettingId.AgentSandboxEnabled, + AgentSandboxSettingId.AgentSandboxWindowsEnabled, + AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, + AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, + AgentSandboxSettingId.AgentSandboxLinuxFileSystem, + AgentSandboxSettingId.AgentSandboxMacFileSystem, + AgentSandboxSettingId.AgentSandboxWindowsFileSystem, + AgentSandboxSettingId.AgentSandboxAdvancedRuntime, + AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, + AgentSandboxSettingId.DeprecatedAgentSandboxLinuxFileSystem, + AgentSandboxSettingId.DeprecatedAgentSandboxMacFileSystem, + AgentNetworkDomainSettingId.AllowedNetworkDomains, + AgentNetworkDomainSettingId.DeniedNetworkDomains, + AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, + AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, + AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains, + AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains, +]; + +/** + * Maps each modern sandbox setting ID to the ordered list of deprecated + * setting IDs the workbench should fall back to when the modern key has not + * been configured by the user. Consumers (engine adapter, agent-host + * forwarder) only ever resolve values by modern key. + */ +const DEPRECATED_SANDBOX_FALLBACKS: Readonly> = { + [AgentSandboxSettingId.AgentSandboxEnabled]: [AgentSandboxSettingId.DeprecatedAgentSandboxEnabled], + [AgentSandboxSettingId.AgentSandboxLinuxFileSystem]: [AgentSandboxSettingId.DeprecatedAgentSandboxLinuxFileSystem], + [AgentSandboxSettingId.AgentSandboxMacFileSystem]: [AgentSandboxSettingId.DeprecatedAgentSandboxMacFileSystem], + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: [AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains], + [AgentNetworkDomainSettingId.DeniedNetworkDomains]: [AgentNetworkDomainSettingId.DeprecatedSandboxDeniedNetworkDomains, AgentNetworkDomainSettingId.DeprecatedOldDeniedNetworkDomains], +}; + +/** + * Reads a single sandbox-related setting from `IConfigurationService`, + * preferring the modern key and falling back to its deprecated peers in + * order. Boolean values for `chat.agent.sandbox.enabled` (legacy) are + * normalized to the modern `'on' | 'off'` enum. Returns `undefined` when + * no user value is configured. + */ +export function readSandboxSetting(configurationService: IConfigurationService, logService: ILogService, settingId: string): T | undefined { + const modern = configurationService.inspect(settingId); + if (modern.userValue !== undefined) { + return normalizeSandboxSettingValue(settingId, modern.value); + } + const deprecatedFallbacks = DEPRECATED_SANDBOX_FALLBACKS[settingId]; + if (deprecatedFallbacks?.length) { + // Some deprecated keys are namespace parents of newer settings (e.g. + // `chat.agent.sandbox` vs `chat.agent.sandbox.fileSystem.linux`). + // `inspect()` may surface a populated namespace object even when the + // exact deprecated key was not explicitly configured by the user, so + // cross-check against the user-configured key list before honouring + // a deprecated value. + const userConfiguredKeys = configurationService.keys().user; + for (const deprecatedId of deprecatedFallbacks) { + const deprecated = configurationService.inspect(deprecatedId); + if (deprecated.userValue !== undefined && userConfiguredKeys.includes(deprecatedId)) { + logService.warn(`SandboxSettingsReader: Using deprecated setting ${deprecatedId} because ${settingId} is not set. Please update your settings to use ${settingId} instead.`); + return normalizeSandboxSettingValue(settingId, deprecated.value); + } + } + } + return normalizeSandboxSettingValue(settingId, modern.value); +} + +/** + * Reads the currently-configured sandbox values for forwarding to an agent + * host. The returned record is keyed by the prefix-free agent-host sandbox + * sub-keys ({@link AgentHostSandboxKey}); keys without a user value are + * omitted entirely. Callers should nest this under the agent host's + * top-level `sandbox` config key when dispatching a `RootConfigChanged`. + */ +export function readAgentHostSandboxValues(configurationService: IConfigurationService, logService: ILogService): Record { + const values: Record = {}; + for (const [settingId, sandboxKey] of Object.entries(sandboxSettingIdToAgentHostKey)) { + const value = readSandboxSetting(configurationService, logService, settingId); + if (value !== undefined) { + values[sandboxKey] = value; + } + } + return values; +} + +/** + * Coerce values into the canonical shape the agent-host schema expects. + * Today the only non-trivial case is `chat.agent.sandbox.enabled`, which + * historically accepted a boolean and now uses the `'on' | 'off' | 'allowNetwork'` + * enum. + */ +function normalizeSandboxSettingValue(settingId: string, value: T | undefined): T | undefined { + if (settingId === AgentSandboxSettingId.AgentSandboxEnabled || settingId === AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) { + if (value === true) { + return AgentSandboxEnabledValue.On as unknown as T; + } + if (value === false) { + return AgentSandboxEnabledValue.Off as unknown as T; + } + } + return value; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 5e5139c7086cd..447ccb01e9299 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -14,7 +14,7 @@ import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; import { arch } from '../../../../../base/common/process.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -24,6 +24,7 @@ import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/r import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannelClient } from '../../../../../platform/sandbox/common/sandboxHelperIpc.js'; import { ISandboxDependencyStatus, ISandboxHelperService, IWindowsMxcFilesystemPolicy } from '../../../../../platform/sandbox/common/sandboxHelperService.js'; import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../../../../platform/sandbox/common/terminalSandboxEngine.js'; +import { readSandboxSetting, SANDBOX_SETTING_KEYS } from './sandboxSettingsReader.js'; import { ITerminalSandboxService, type ISandboxDependencyInstallOptions, type ISandboxDependencyInstallResult, type ITerminalSandboxCommand, type ITerminalSandboxPrerequisiteCheckResult, type ITerminalSandboxResolvedNetworkDomains, type ITerminalSandboxWrapResult } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; @@ -49,6 +50,10 @@ interface ISandboxDependencyInstallTerminalContext { /** Subdirectory under the user home + product data folder where the engine creates its temp dir. */ const SANDBOX_TEMP_DIR_NAME = 'tmp'; +function affectsSandboxSettings(e: IConfigurationChangeEvent): boolean { + return SANDBOX_SETTING_KEYS.some(key => e.affectsConfiguration(key)); +} + export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { readonly _serviceBrand: undefined; @@ -58,10 +63,10 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private readonly _onDidChangeRoots = this._register(new Emitter()); constructor( - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IFileService fileService: IFileService, @IEnvironmentService private readonly _environmentService: IEnvironmentService, - @ILogService logService: ILogService, + @ILogService private readonly _logService: ILogService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IProductService private readonly _productService: IProductService, @@ -73,6 +78,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb super(); this._remoteEnvDetailsPromise = this._remoteAgentService.getEnvironment(); + const onDidChangeSandboxSettings = Event.filter(this._configurationService.onDidChangeConfiguration, affectsSandboxSettings); + const host: ITerminalSandboxEngineHost = { getOS: () => this._resolveOS(), getRuntimeInfo: () => this._resolveRuntimeInfo(), @@ -84,6 +91,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb checkSandboxDependencies: () => this._resolveSandboxDependencyStatus(), getWindowsMxcFilesystemPolicy: () => this._resolveWindowsMxcFilesystemPolicy(), getWindowsMxcEnvironment: () => this._resolveWindowsMxcEnvironment(), + getSandboxSetting: (settingId: string): T | undefined => this._readSandboxSetting(settingId), + onDidChangeSandboxSettings: Event.map(onDidChangeSandboxSettings, () => undefined), }; this._engine = this._register(instantiationService.createInstance(TerminalSandboxEngine, host)); @@ -157,6 +166,10 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return remoteEnv ? remoteEnv.os : OS; } + private _readSandboxSetting(settingId: string): T | undefined { + return readSandboxSetting(this._configurationService, this._logService, settingId); + } + private async _resolveRuntimeInfo(): Promise { const remoteEnv = await this._resolveRemoteEnv(); if (remoteEnv) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts new file mode 100644 index 0000000000000..c09a14c203bb2 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IAgentConnection, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService, IRemoteAgentHostConnectionInfo } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../../../../../platform/agentHost/common/sandboxConfigSchema.js'; +import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import type { ActionEnvelope, IRootConfigChangedAction, INotification, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import type { RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; +import { AgentHostSandboxForwarder } from '../../browser/agentHostSandboxForwarder.js'; + +// ---- Mocks ------------------------------------------------------------------ + +class MockAgentConnection { + declare readonly _serviceBrand: undefined; + + public readonly clientId = 'mock-client'; + public dispatched: (SessionAction | TerminalAction | IRootConfigChangedAction)[] = []; + + private _rootStateValue: RootState | undefined; + private readonly _rootStateOnDidChange = new Emitter(); + + readonly rootState: IAgentSubscription = (() => { + const self = this; + return { + get value() { return self._rootStateValue; }, + get verifiedValue() { return self._rootStateValue; }, + onDidChange: this._rootStateOnDidChange.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + })(); + + readonly onDidAction: Event = Event.None; + readonly onDidNotification: Event = Event.None; + + dispatch(_channel: string, action: SessionAction | TerminalAction | IRootConfigChangedAction): void { + this.dispatched.push(action); + } + + setRootState(state: RootState | undefined): void { + this._rootStateValue = state; + if (state) { + this._rootStateOnDidChange.fire(state); + } + } + + dispose(): void { + this._rootStateOnDidChange.dispose(); + } +} + +class MockAgentHostService extends mock() { + declare readonly _serviceBrand: undefined; + public readonly inner = new MockAgentConnection(); + + override readonly clientId = this.inner.clientId; + override readonly onAgentHostStart = Event.None; + override readonly onAgentHostExit = Event.None; + override readonly onDidAction = this.inner.onDidAction; + override readonly onDidNotification = this.inner.onDidNotification; + override readonly rootState = this.inner.rootState; + + override dispatch(channel: string, action: SessionAction | TerminalAction | IRootConfigChangedAction): void { + this.inner.dispatch(channel, action); + } + + get dispatched(): readonly (SessionAction | TerminalAction | IRootConfigChangedAction)[] { + return this.inner.dispatched; + } + + setRootState(state: RootState | undefined): void { + this.inner.setRootState(state); + } + + dispose(): void { + this.inner.dispose(); + } +} + +class MockRemoteAgentHostService extends mock() { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeConnections = new Emitter(); + override readonly onDidChangeConnections = this._onDidChangeConnections.event; + + private _connections: IRemoteAgentHostConnectionInfo[] = []; + private readonly _byAddress = new Map(); + + override get connections(): readonly IRemoteAgentHostConnectionInfo[] { + return this._connections; + } + + override getConnection(address: string): IAgentConnection | undefined { + return this._byAddress.get(address) as unknown as IAgentConnection | undefined; + } + + addConnection(address: string): MockAgentConnection { + const conn = new MockAgentConnection(); + this._byAddress.set(address, conn); + this._connections = [...this._connections, { address, name: address, clientId: conn.clientId, status: { kind: 'connected' } }]; + this._onDidChangeConnections.fire(); + return conn; + } + + removeConnection(address: string): void { + const conn = this._byAddress.get(address); + conn?.dispose(); + this._byAddress.delete(address); + this._connections = this._connections.filter(c => c.address !== address); + this._onDidChangeConnections.fire(); + } + + dispose(): void { + for (const conn of this._byAddress.values()) { + conn.dispose(); + } + this._byAddress.clear(); + this._onDidChangeConnections.dispose(); + } +} + +// ---- Helpers ---------------------------------------------------------------- + +function rootStateWithSandboxSchema(sandbox: Record = {}): RootState { + return { + agents: [], + config: { + schema: { + type: 'object', + properties: { + [AgentHostSandboxConfigKey.Sandbox]: { type: 'object', title: 'Agent Sandbox' }, + }, + }, + values: { [AgentHostSandboxConfigKey.Sandbox]: sandbox }, + }, + }; +} + +function rootStateWithoutSandboxSchema(): RootState { + return { + agents: [], + config: { + schema: { + type: 'object', + // Older / third-party host that doesn't advertise sandbox keys. + properties: { customizations: { type: 'array', title: 'Customizations' } }, + }, + values: {}, + }, + }; +} + +interface ITestSetup { + forwarder: AgentHostSandboxForwarder; + local: MockAgentHostService; + remote: MockRemoteAgentHostService; + configurationService: TestConfigurationService; +} + +function setup(disposables: DisposableStore, configValues: Record = {}): ITestSetup { + const instantiationService = disposables.add(new TestInstantiationService()); + const local = new MockAgentHostService(); + disposables.add({ dispose: () => local.dispose() }); + const remote = new MockRemoteAgentHostService(); + disposables.add({ dispose: () => remote.dispose() }); + const configurationService = new TestConfigurationService(configValues); + + instantiationService.stub(IAgentHostService, local); + instantiationService.stub(IRemoteAgentHostService, remote); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + + const forwarder = disposables.add(instantiationService.createInstance(AgentHostSandboxForwarder)); + return { forwarder, local, remote, configurationService }; +} + +// ============================================================================= + +suite('AgentHostSandboxForwarder', () => { + const disposables = new DisposableStore(); + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('does not dispatch while rootState is unhydrated', () => { + const { local } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + assert.deepStrictEqual(local.dispatched, []); + }); + + test('dispatches sandbox values to the local host when rootState hydrates', () => { + const { local } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + + local.setRootState(rootStateWithSandboxSchema()); + + assert.deepStrictEqual(local.dispatched, [{ + type: ActionType.RootConfigChanged, + config: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + }]); + }); + + test('schema-guards keys: skips keys the host does not advertise', () => { + const { local } = setup(disposables, { + [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On, + [AgentNetworkDomainSettingId.AllowedNetworkDomains]: ['example.com'], + }); + + local.setRootState(rootStateWithoutSandboxSchema()); + + assert.deepStrictEqual(local.dispatched, []); + }); + + test('skips no-op dispatch when rootState already matches workbench values', () => { + const { local } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + + local.setRootState(rootStateWithSandboxSchema({ [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On })); + + assert.deepStrictEqual(local.dispatched, []); + }); + + test('re-dispatches when the workbench sandbox setting changes', () => { + const { local, configurationService } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + + local.setRootState(rootStateWithSandboxSchema({ [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On })); + // Initial state already matches → no dispatch. + assert.deepStrictEqual(local.dispatched, []); + + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectsConfiguration: (key: string) => key === AgentSandboxSettingId.AgentSandboxEnabled, + affectedKeys: new Set([AgentSandboxSettingId.AgentSandboxEnabled]), + change: { keys: [AgentSandboxSettingId.AgentSandboxEnabled], overrides: [] }, + }); + + assert.deepStrictEqual(local.dispatched, [{ + type: ActionType.RootConfigChanged, + config: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.AllowNetwork } }, + }]); + }); + + test('dispatches to remote connections when they appear', () => { + const { remote } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + + const remoteConn = remote.addConnection('remote.example:9000'); + remoteConn.setRootState(rootStateWithSandboxSchema()); + + assert.deepStrictEqual(remoteConn.dispatched, [{ + type: ActionType.RootConfigChanged, + config: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On } }, + }]); + }); + + test('fans out workbench setting changes to all connected agent hosts', () => { + const { local, remote, configurationService } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + local.setRootState(rootStateWithSandboxSchema()); + const remoteConn = remote.addConnection('remote.example:9000'); + remoteConn.setRootState(rootStateWithSandboxSchema()); + + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectsConfiguration: (key: string) => key === AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, + affectedKeys: new Set([AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands]), + change: { keys: [AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands], overrides: [] }, + }); + + const expectedPatch = { + type: ActionType.RootConfigChanged, + config: { + [AgentHostSandboxConfigKey.Sandbox]: { + [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, + [AgentHostSandboxKey.AllowUnsandboxedCommands]: true, + }, + }, + }; + assert.deepStrictEqual(local.dispatched.at(-1), expectedPatch); + assert.deepStrictEqual(remoteConn.dispatched.at(-1), expectedPatch); + }); + + test('ignores unrelated configuration changes', () => { + const { local, configurationService } = setup(disposables, { [AgentSandboxSettingId.AgentSandboxEnabled]: AgentSandboxEnabledValue.On }); + local.setRootState(rootStateWithSandboxSchema({ [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On })); + assert.deepStrictEqual(local.dispatched, []); + + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectsConfiguration: (key: string) => key === 'editor.fontSize', + affectedKeys: new Set(['editor.fontSize']), + change: { keys: ['editor.fontSize'], overrides: [] }, + }); + + assert.deepStrictEqual(local.dispatched, []); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts new file mode 100644 index 0000000000000..621d719d0d3ea --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; +import { AgentHostSandboxKey } from '../../../../../../platform/agentHost/common/sandboxConfigSchema.js'; +import { readAgentHostSandboxValues, readSandboxSetting } from '../../common/sandboxSettingsReader.js'; + +suite('sandboxSettingsReader', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns user value for modern key', () => { + const cfg = new TestConfigurationService(); + cfg.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + + assert.strictEqual( + readSandboxSetting(cfg, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + AgentSandboxEnabledValue.On, + ); + }); + + test('returns undefined when nothing is configured', () => { + const cfg = new TestConfigurationService(); + assert.strictEqual( + readSandboxSetting(cfg, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + undefined, + ); + }); + + test('falls back to deprecated key when modern key is not user-set', () => { + // Build a config where the deprecated parent key is explicitly user-set. + // `chat.agent.sandbox` is the deprecated namespace parent of + // `chat.agent.sandbox.enabled`, but `TestConfigurationService.inspect` + // only reflects the exact user keys, so this exercises the fallback + // path cleanly. + const cfg = new TestConfigurationService(); + cfg.setUserConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); + + assert.strictEqual( + readSandboxSetting(cfg, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + AgentSandboxEnabledValue.AllowNetwork, + ); + }); + + test('normalizes legacy boolean form of chat.agent.sandbox.enabled', () => { + const cfgOn = new TestConfigurationService(); + cfgOn.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, true); + assert.strictEqual( + readSandboxSetting(cfgOn, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + AgentSandboxEnabledValue.On, + ); + + const cfgOff = new TestConfigurationService(); + cfgOff.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, false); + assert.strictEqual( + readSandboxSetting(cfgOff, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + AgentSandboxEnabledValue.Off, + ); + }); + + test('normalizes legacy boolean form when arriving via the deprecated key', () => { + const cfg = new TestConfigurationService(); + cfg.setUserConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, true); + + assert.strictEqual( + readSandboxSetting(cfg, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + AgentSandboxEnabledValue.On, + ); + }); + + test('modern user value wins over deprecated user value', () => { + const cfg = new TestConfigurationService(); + cfg.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + cfg.setUserConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, AgentSandboxEnabledValue.AllowNetwork); + + assert.strictEqual( + readSandboxSetting(cfg, new NullLogService(), AgentSandboxSettingId.AgentSandboxEnabled), + AgentSandboxEnabledValue.On, + ); + }); + + test('readAgentHostSandboxValues builds a bag keyed by prefix-free agent-host sandbox sub-keys', () => { + const cfg = new TestConfigurationService(); + cfg.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + cfg.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); + cfg.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); + + const bag = readAgentHostSandboxValues(cfg, new NullLogService()); + + assert.deepStrictEqual(bag, { + [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, + [AgentHostSandboxKey.AllowUnsandboxedCommands]: true, + [AgentHostSandboxKey.AllowedNetworkDomains]: ['example.com'], + }); + }); + + test('readAgentHostSandboxValues omits keys that are not user-configured', () => { + const cfg = new TestConfigurationService(); + const bag = readAgentHostSandboxValues(cfg, new NullLogService()); + assert.deepStrictEqual(bag, {}); + }); +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 1aeeaf78cb872..05471b2fc2517 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -12,7 +12,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IReference } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; -import { constObservable, derived, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; @@ -46,7 +46,8 @@ import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginS import { MarketplaceReferenceKind } from '../../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../../contrib/chat/common/plugins/pluginInstallService.js'; import { AICustomizationManagementEditor } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; -import { AICustomizationItemsModel, IAICustomizationItemsModel } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; +import { IAICustomizationItemSource, IAICustomizationListItem } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; +import { AICustomizationItemsModel, IAICustomizationItemsModel, ItemsModelSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { EmbeddedMcpServerDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../../../../contrib/chat/browser/agentPluginEditor/agentPluginItems.js'; @@ -101,6 +102,23 @@ function createMockEditorGroup(): IEditorGroup { }(); } +function createMockAICustomizationItemsModel(): IAICustomizationItemsModel { + const itemSource = new class extends mock() { + override readonly sessionResource = LocalChatSessionUri.getNewSessionUri(); + override readonly onDidAICustomizationItemsChange = Event.None; + override async fetchProviderItems() { return []; } + override async fetchAICustomizationItems(_promptType: PromptsType) { return []; } + }(); + + return new class extends mock() { + override getItems(_section: ItemsModelSection): IObservable { return constObservable([]); } + override getActiveItemSource() { return itemSource; } + override getCount(_section: ItemsModelSection): IObservable { return constObservable(0); } + override getPluginCount(): IObservable { return constObservable(0); } + override async whenSectionLoaded(_section: ItemsModelSection): Promise { } + }(); +} + function toExtensionInfo(file: IFixtureFile): { identifier: ExtensionIdentifier; displayName?: string } | undefined { if (!file.extensionId) { return undefined; @@ -962,6 +980,7 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise() { }()); + reg.defineInstance(IAICustomizationItemsModel, createMockAICustomizationItemsModel()); }, });