diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index a5ac97ef8bd633..805aff6035c54b 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -140,7 +140,7 @@ } }, "type": "boolean", - "default": false, + "default": true, "included": true }, { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 529db684406e12..ef1c3d8c91d017 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4144,6 +4144,22 @@ "onExp" ] }, + "github.copilot.chat.claudeOpusDefaultReasoningEffort": { + "type": "string", + "default": "", + "enum": [ + "", + "low", + "medium", + "high", + "max" + ], + "markdownDescription": "%github.copilot.config.claudeOpusDefaultReasoningEffort%", + "tags": [ + "experimental", + "onExp" + ] + }, "github.copilot.chat.gpt55ReadFileTool.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index fa3928b65a197a..2c49c81c487814 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -362,6 +362,7 @@ "github.copilot.config.gpt56Verbosity.enabled": "Sets the response verbosity to low for gpt-5.6 models.", "github.copilot.config.gemini3GetChangedFilesTool.enabled": "Enables the Get Changed Files tool for gemini-3 models.", "github.copilot.config.gemini3LowReasoningEffort.enabled": "Sets the reasoning effort to low for gemini-3 models.", + "github.copilot.config.claudeOpusDefaultReasoningEffort": "Overrides the default thinking effort shown in the model picker for Claude Opus models. Leave empty to use the built-in default. Ignored if the model does not support the chosen level.", "github.copilot.config.gpt55ReadFileTool.enabled": "Enables the Read File tool for gpt-5.5 models.", "github.copilot.config.anthropic.tools.websearch.enabled": "Enable Anthropic's native web search tool for BYOK Claude models. When enabled, allows Claude to search the web for current information. \n\n**Note**: This is an experimental feature only available for BYOK Anthropic Claude models.", "github.copilot.config.anthropic.tools.websearch.maxUses": "Maximum number of web searches allowed per request. Valid range is 1 to 20. Prevents excessive API calls within a single interaction. If Claude exceeds this limit, the response returns an error.", diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index 1c55c18653e4cd..973d2e331d410a 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -70,16 +70,17 @@ export function getReasoningEffortLabel(level: string): string { * Builds the `reasoningEffort` property descriptor for a model's * {@link LanguageModelConfigurationSchema}. Centralises the default-selection * and localized descriptions so the picker stays consistent across the - * Copilot and BYOK code paths. + * Copilot and BYOK code paths. `defaultOverride` wins over the family default + * when it is one of the advertised levels. */ -export function buildReasoningEffortSchemaProperty(effortLevels: readonly string[], family: string): NonNullable[string] { +export function buildReasoningEffortSchemaProperty(effortLevels: readonly string[], family: string, defaultOverride?: string): NonNullable[string] { return { type: 'string', title: l10n.t('Thinking Effort'), enum: effortLevels, enumItemLabels: effortLevels.map(getReasoningEffortLabel), enumDescriptions: effortLevels.map(getReasoningEffortDescription), - default: pickDefaultReasoningEffort(effortLevels, family), + default: defaultOverride && effortLevels.includes(defaultOverride) ? defaultOverride : pickDefaultReasoningEffort(effortLevels, family), group: 'navigation', }; } diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 1e1f6017310226..3427a4644e37ac 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -10,6 +10,7 @@ import { IAuthenticationService } from '../../../platform/authentication/common/ import { CopilotToken } from '../../../platform/authentication/common/copilotToken'; import { IBlockedExtensionService } from '../../../platform/chat/common/blockedExtensionService'; import { ChatFetchResponseType, ChatLocation, getErrorDetailsFromChatFetchError } from '../../../platform/chat/common/commonTypes'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { getTextPart } from '../../../platform/chat/common/globalStringUtils'; import { EmbeddingType, getWellKnownEmbeddingTypeInfo, IEmbeddingsComputer } from '../../../platform/embeddings/common/embeddingsComputer'; import { AUTO_MODE_TIER_PROPERTY, defaultAutoModeTier, selectableAutoModeTiers } from '../../../platform/endpoint/common/autoModeTiers'; @@ -119,7 +120,7 @@ function buildAutoRoutingContext( // Auto model delegates to different backends, so the only picker it exposes is // the routing tier; per-model options belong to the model it routes to. -function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { +function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boolean, opusDefaultEffort: string | undefined): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { if (endpoint instanceof AutoChatEndpoint) { return autoTiersEnabled ? { configurationSchema: { properties: { [AUTO_MODE_TIER_PROPERTY]: buildAutoModeTierSchemaProperty(selectableAutoModeTiers, defaultAutoModeTier) } } } @@ -131,7 +132,9 @@ function buildConfigurationSchema(endpoint: IChatEndpoint, autoTiersEnabled: boo // Reasoning effort config const effortLevels = endpoint.supportsReasoningEffort; if (effortLevels && effortLevels.length > 1) { - properties.reasoningEffort = buildReasoningEffortSchemaProperty(effortLevels, endpoint.family.toLowerCase()); + const family = endpoint.family.toLowerCase(); + const defaultOverride = family.includes('opus') ? opusDefaultEffort : undefined; + properties.reasoningEffort = buildReasoningEffortSchemaProperty(effortLevels, family, defaultOverride); } // Context size config @@ -246,6 +249,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib @IVSCodeExtensionContext private readonly _vsCodeExtensionContext: IVSCodeExtensionContext, @IAutomodeService private readonly _automodeService: IAutomodeService, @IExperimentationService private readonly _expService: IExperimentationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -299,6 +303,11 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib // honored while routing goes through `POST /auto`. this._onDidChange.fire(); })); + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.ClaudeOpusDefaultReasoningEffort.fullyQualifiedId)) { + this._onDidChange.fire(); + } + })); void this._refreshUtilityOverrides().catch(err => { this._logService.warn(`[LanguageModelAccess] Failed to pre-resolve internal model aliases: ${err}`); }); @@ -332,6 +341,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib const seenFamilies = new Set(); const autoTiersEnabled = this._automodeService.areAutoModeTiersSupported(); + const opusDefaultEffort = this._configurationService.getExperimentBasedConfig(ConfigKey.ClaudeOpusDefaultReasoningEffort, this._expService) || undefined; for (const endpoint of chatEndpoints) { if (seenFamilies.has(endpoint.family) && !endpoint.showInModelPicker) { @@ -410,7 +420,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision, toolCalling: endpoint.supportsToolCalls, }, - ...buildConfigurationSchema(endpoint, autoTiersEnabled), + ...buildConfigurationSchema(endpoint, autoTiersEnabled, opusDefaultEffort), }; models.push(model); diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index ab949fd2deb7bf..6f2acea1176405 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -691,6 +691,12 @@ suite('reasoning effort schema', () => { assert.deepStrictEqual(prop.enum, ['low', 'high']); assert.strictEqual(prop.group, 'navigation'); }); + + test('buildReasoningEffortSchemaProperty honors a default override only when advertised', () => { + assert.strictEqual(buildReasoningEffortSchemaProperty(['low', 'medium', 'high'], 'claude-opus-4.5', 'medium').default, 'medium'); + assert.strictEqual(buildReasoningEffortSchemaProperty(['low', 'high'], 'claude-opus-4.5', 'medium').default, 'high'); + assert.strictEqual(buildReasoningEffortSchemaProperty(['low', 'medium', 'high'], 'claude-opus-4.5', undefined).default, 'high'); + }); }); suite('auto mode tier schema', () => { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 5176b2311e03c3..4b578b629ab3da 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1123,6 +1123,8 @@ export namespace ConfigKey { export const EnableGemini3GetChangedFilesTool = defineSetting('chat.gemini3GetChangedFilesTool.enabled', ConfigType.ExperimentBased, false); /** When enabled, sends `reasoning_effort: 'low'` to Gemini 3 models. */ export const EnableGemini3LowReasoningEffort = defineSetting('chat.gemini3LowReasoningEffort.enabled', ConfigType.ExperimentBased, false); + /** Default thinking effort for Claude Opus models in the model picker. Empty keeps the built-in default ('high'). */ + export const ClaudeOpusDefaultReasoningEffort = defineSetting('chat.claudeOpusDefaultReasoningEffort', ConfigType.ExperimentBased, ''); /** Enable read_file tool for GPT-5.5 models */ export const EnableGpt55ReadFileTool = defineSetting('chat.gpt55ReadFileTool.enabled', ConfigType.ExperimentBased, true); export const EnableChatImageUpload = defineSetting('chat.imageUpload.enabled', ConfigType.Simple, true); diff --git a/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts b/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts index e5823d0ebfbd8b..22f3dde0c7c703 100644 --- a/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts +++ b/extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts @@ -196,6 +196,12 @@ export class BaseTelemetryService implements ITelemetryService { ...Object.fromEntries(props), ...this._sharedProperties }; + // Mark the queried-feature name trusted so the telemetry cleaner does not redact the + // `/vscode/`-scoped key as a `user-file-path`. + const queriedFeature = properties['ABExp.queriedFeature']; + if (typeof queriedFeature === 'string') { + properties['ABExp.queriedFeature'] = new TelemetryTrustedValue(queriedFeature); + } this._microsoftTelemetrySender.sendInternalTelemetryEvent(eventName, properties); this._microsoftTelemetrySender.sendTelemetryEvent(eventName, properties); } diff --git a/package.json b/package.json index a55c68603dc801..8e44d8818f12ff 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "tsec-compile-check": "node --max-old-space-size=8192 node_modules/tsec/bin/tsec -p src/tsconfig.tsec.json", "vscode-dts-compile-check": "tsc --project src/tsconfig.vscode-dts.json && tsc --project src/tsconfig.vscode-proposed-dts.json", "valid-layers-check": "node build/checker/layersChecker.ts && node build/checker/layersTypeCheck.ts", - "define-class-fields-check": "node build/lib/propertyInitOrderChecker.ts && tsc --project src/tsconfig.defineClassFields.json", + "define-class-fields-check": "node --max-old-space-size=8192 build/lib/propertyInitOrderChecker.ts && tsc --project src/tsconfig.defineClassFields.json", "update-distro": "node build/npm/update-distro.ts", "export-policy-data": "node build/lib/policies/exportPolicyData.ts", "web": "echo 'npm run web' is replaced by './scripts/code-server' or './scripts/code-web'", diff --git a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts index 23a6aa0342b05d..7f52f4a3c9e5d0 100644 --- a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts @@ -11,7 +11,7 @@ import { ChatAIDisabledSettingId } from '../../chat/common/chatSettings.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { bindContextKey, observableConfigValue } from '../../observable/common/platformObservableUtils.js'; -import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService } from '../../policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService } from '../../policy/common/copilotManagedSettings.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY, IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; export class AgentHostEnablementService extends Disposable implements IAgentHostEnablementService { @@ -20,6 +20,7 @@ export class AgentHostEnablementService extends Disposable implements IAgentHost readonly enabled: IObservable; readonly managedSandboxEnforced: IObservable; + readonly managedSandboxAllowsBypass: IObservable; constructor( private readonly _isAgentHostRuntimeAvailable: boolean, @@ -35,6 +36,9 @@ export class AgentHostEnablementService extends Disposable implements IAgentHost this.managedSandboxEnforced = observableFromEvent(this, managedSettingsService.onDidChangeManagedSettings, () => managedSettingsService.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY) === true); + this.managedSandboxAllowsBypass = observableFromEvent(this, + managedSettingsService.onDidChangeManagedSettings, + () => managedSettingsService.getManagedSettingValue(COPILOT_SANDBOX_ALLOW_BYPASS_KEY) === true); } } diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 70634e21c2b489..35f1b47a29df17 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -13,12 +13,13 @@ import { Schemas } from '../../../base/common/network.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -52,6 +53,8 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import { ResourceSet } from '../../../base/common/map.js'; import { computeReconnectDelay, DEFAULT_RECONNECT_POLICY, hasExhaustedReconnectAttempts, type IRemoteAgentHostReconnectPolicy } from '../common/reconnectPolicy.js'; import type { IRemoteAgentHostProtocolClient } from '../common/remoteAgentHostService.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../workspace/common/workspaceTrust.js'; +import { isWorktreeUnderRepository } from '../common/worktreePaths.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; // AHP 0.9 changed the automation catalog wire shape, so VS Code cannot safely negotiate 0.8. @@ -400,6 +403,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect @IAgentHostResourceService private readonly _resourceService: IAgentHostResourceService, @IConfigurationService private readonly _configurationService: IConfigurationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); this._resourceIdentity = identity; @@ -1951,6 +1956,44 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect void (async () => { try { switch (method) { + case RequestAgentHostWorkspaceTrustExtensionMethod: { + if (typeof p.workspace !== 'string') { + throw new Error('Missing workspace'); + } + const hostWorkspace = URI.parse(p.workspace, true); + if (hostWorkspace.scheme !== Schemas.file || !hostWorkspace.path.startsWith('/')) { + throw new Error('Workspace must be an absolute file URI'); + } + const workspace = this.resourceUris.fromAgentHost(hostWorkspace); + if (p.trustedParent !== undefined) { + if (typeof p.trustedParent !== 'string') { + throw new Error('Invalid trustedParent'); + } + const hostParent = URI.parse(p.trustedParent, true); + if (hostParent.scheme !== Schemas.file || !hostParent.path.startsWith('/')) { + throw new Error('Trusted parent must be an absolute file URI'); + } + if (!isWorktreeUnderRepository(hostWorkspace, hostParent)) { + throw new Error('Workspace is not a managed worktree under the trusted parent'); + } + const parent = this.resourceUris.fromAgentHost(hostParent); + const parentTrust = await this._workspaceTrustManagementService.getUriTrustInfo(parent); + if (parentTrust.trusted) { + const workspaceTrust = await this._workspaceTrustManagementService.getUriTrustInfo(workspace); + if (!workspaceTrust.trusted) { + await this._workspaceTrustManagementService.setUrisTrust([workspace], true); + } + sendResult({ trusted: true } satisfies IAgentHostExtensionServerCommandMap[typeof RequestAgentHostWorkspaceTrustExtensionMethod]['result']); + return; + } + } + const trusted = await this._workspaceTrustRequestService.requestResourcesTrust({ + uri: workspace, + message: localize('agentHost.trustWorkspaceMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + }); + sendResult({ trusted: trusted === true } satisfies IAgentHostExtensionServerCommandMap[typeof RequestAgentHostWorkspaceTrustExtensionMethod]['result']); + return; + } case 'resourceList': { if (!p.uri) { throw new Error('Missing uri'); } const result = await this._resourceService.list(identity, URI.parse(p.uri as string)); diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index f4b28c2034c805..e5214007bf01bb 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -27,6 +27,16 @@ export class AgentHostStartError extends Error { } } +/** Reports a provider CWD error after the new directory became irreversible and authoritative. */ +export class AgentWorkingDirectoryChangedError extends Error { + constructor( + readonly workingDirectory: URI, + message: string, + ) { + super(message); + } +} + export function isInvalidUtilityProcessConfigurationMessage(message: string): boolean { return /^Invalid value for (?:args|env|execArgv)$/.test(message); } @@ -249,6 +259,11 @@ export const CODEX_AGENT_PROVIDER_ID = 'codex' as const; */ export type IAgentCapabilities = AgentCapabilities; +/** Agent Host-only capabilities that are not serialized to protocol clients. */ +export interface IAgentHostCapabilities { + readonly workspaceConversion: boolean; +} + /** Metadata describing an agent backend, discovered over IPC. */ export interface IAgentDescriptor { readonly provider: AgentProvider; @@ -1108,6 +1123,9 @@ export interface IAgent { /** Unique provider identifier. */ readonly id: AgentProvider; + /** Capabilities consumed only inside the Agent Host process. */ + readonly agentHostCapabilities: IAgentHostCapabilities; + /** Provider descriptor and capabilities. */ getDescriptor(): IAgentDescriptor; @@ -1143,6 +1161,14 @@ export interface IAgent { /** Optional history mutation for providers with a native truncation operation. */ truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise; + /** + * Changes the working directory of an exact chat's existing provider-native + * backing. Callers MUST gate this operation on + * {@link IAgentHostCapabilities.workspaceConversion}; implementations that do + * not advertise the capability MUST reject the call. + */ + setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise; + /** Return bounded diagnostics for an in-flight turn when supported. */ getTurnDiagnosticSnapshot?(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot | undefined; diff --git a/src/vs/platform/agentHost/common/agentHostEnablementService.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts index 96f8f8c8cb09ab..e1b4951139fb84 100644 --- a/src/vs/platform/agentHost/common/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts @@ -33,6 +33,7 @@ export interface IAgentHostEnablementService { * affected, and virtual workspaces are exempt. */ readonly managedSandboxEnforced: IObservable; + readonly managedSandboxAllowsBypass: IObservable; } const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index 5279b0c1ac8591..4eb14fb4d706a6 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -15,6 +15,7 @@ export const DeleteAgentHostDetachedWorktreeExtensionMethod = 'vscode/deleteAgen export const ReconcileAgentHostDetachedWorktreesExtensionMethod = 'vscode/reconcileAgentHostDetachedWorktrees'; export const ReadAgentHostDebugLogsChunkExtensionMethod = 'vscode/readAgentHostDebugLogsChunk'; export const SetAgentHostDetachedWorktreeArchivedExtensionMethod = 'vscode/setAgentHostDetachedWorktreeArchived'; +export const RequestAgentHostWorkspaceTrustExtensionMethod = 'vscode/requestWorkspaceTrust'; const AgentHostChatStateFileCapabilityMetaKey = 'vscode.getAgentHostSessionStateFile.chat'; const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; @@ -92,3 +93,15 @@ export interface IAgentHostExtensionCommandMap { result: { data: string; eof: boolean }; }; } + +export interface IAgentHostWorkspaceTrustRequest { + readonly workspace: string; + readonly trustedParent?: string; +} + +export interface IAgentHostExtensionServerCommandMap { + [RequestAgentHostWorkspaceTrustExtensionMethod]: { + params: IAgentHostWorkspaceTrustRequest; + result: { trusted: boolean }; + }; +} diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index 9d0113ae1b15f1..6669a7203be142 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -19,6 +19,7 @@ export const enum SessionServerToolName { ListSessions = 'list_sessions', GetCurrentSession = 'get_current_session', + SetWorkspace = 'set_workspace', CreateSession = 'create_session', CreateChat = 'create_chat', RenameChat = 'rename_chat', diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index a2737973c1bcda..42bca40ae0e64a 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -302,6 +302,11 @@ export interface ISessionDatabase extends IDisposable { */ setMetadataValues(values: Readonly>): Promise; + /** + * Atomically delete metadata keys. + */ + deleteMetadata(keys: readonly string[]): Promise; + /** * Atomically stores metadata values only when `key` is absent. Values named * by `copies` are read from their source keys and copied when present. diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index 563712337ba61d..96297212274d89 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -88,7 +88,6 @@ export { AuthRequiredReason, type SessionAddedParams, type SessionRemovedParams, - type SessionSummaryChangedParams, type ProgressParams, type AuthRequiredParams, } from './protocol/notifications.js'; @@ -145,9 +144,19 @@ import { type RootConfigChangedAction, } from './protocol/actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; +import type { SessionSummary } from './protocol/state.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams as ProtocolSessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_, AutomationAction as IAutomationAction_, ClientAutomationAction as IClientAutomationAction_, AutomationRunAction as IAutomationRunAction_, ClientAutomationRunAction as IClientAutomationRunAction_ } from './protocol/action-origin.generated.js'; +export type SessionSummaryChanges = Omit, 'activity'> & { + /** `null` explicitly clears activity; omission leaves it unchanged. */ + activity?: string | null; +}; + +export type SessionSummaryChangedParams = Omit & { + readonly changes: SessionSummaryChanges; +}; + /** * Discriminated union of all server→client protocol notifications other than * the action envelope. Each variant carries its protocol `method` so callers diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index ea20300c048aca..50a0a133127263 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -282,15 +282,18 @@ const MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.hiddenFromTranscrip const MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX = '\n'; const MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY = 'vscode.chat.requestHiddenFromTranscript'; const MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX = '\n'; +const MESSAGE_SYSTEM_INITIATED_LABEL_META_KEY = 'vscode.chat.systemInitiatedLabel'; -function readMessageMeta(message: Message): { readonly hiddenFromTranscript: boolean; readonly requestHiddenFromTranscript: boolean } { +function readMessageMeta(message: Message): { readonly hiddenFromTranscript: boolean; readonly requestHiddenFromTranscript: boolean; readonly systemInitiatedLabel: string | undefined } { const meta = message._meta; + const systemInitiatedLabel = meta?.[MESSAGE_SYSTEM_INITIATED_LABEL_META_KEY]; const hiddenFromTranscript = meta?.[MESSAGE_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true || message.text.startsWith(MESSAGE_HIDDEN_FROM_TRANSCRIPT_PREFIX); return { hiddenFromTranscript, requestHiddenFromTranscript: meta?.[MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_META_KEY] === true || message.text.startsWith(MESSAGE_REQUEST_HIDDEN_FROM_TRANSCRIPT_PREFIX), + systemInitiatedLabel: typeof systemInitiatedLabel === 'string' ? systemInitiatedLabel : undefined, }; } @@ -303,6 +306,10 @@ export function isMessageRequestHiddenFromTranscript(message: Message): boolean return readMessageMeta(message).requestHiddenFromTranscript; } +export function readMessageSystemInitiatedLabel(message: Message): string | undefined { + return readMessageMeta(message).systemInitiatedLabel; +} + export function withMessageHiddenFromTranscript(message: Message, hidden: boolean | undefined): Message { if (!hidden) { return message; @@ -332,6 +339,16 @@ export function withMessageRequestHiddenFromTranscript(message: Message, hidden: }; } +export function withMessageSystemInitiatedLabel(message: Message, label: string): Message { + return { + ...message, + _meta: { + ...message._meta, + [MESSAGE_SYSTEM_INITIATED_LABEL_META_KEY]: label, + }, + }; +} + /** * Whether `turn` is a hidden system notification the host appended purely to * carry a message (e.g. an Agent Merge status change). It never reaches the @@ -1995,6 +2012,9 @@ export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless'; */ export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless'; +/** Blocks turns for a session whose provider could not be detached from an untrusted working directory. */ +export const AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY = 'agentHost.workspaceConversionQuarantined'; + /** * Session-database metadata key recording whether a session is archived. Written by * the AH orchestrator (`AgentSideEffects` on `SessionIsArchivedChanged`) and read by diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index c6ff31388aba0a..ef5d005eeb698b 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -186,6 +186,7 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt agentServiceOptions, accessor, instantiationService!, + services, logService, sessionDataService, foundation, diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts index 973cfed7b3990c..0022261152bc7a 100644 --- a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts +++ b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts @@ -5,6 +5,7 @@ import { Disposable, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; export const AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION = 30_000 * 10; @@ -18,6 +19,7 @@ export interface IAgentHostClientConnectionSource { hasSeenClient(clientId: string): boolean; isClientConnected(clientId: string): boolean; getConnectedClientTransportCounts(): ReadonlyMap; + requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; } export const IAgentHostClientConnectionService = createDecorator('agentHostClientConnectionService'); @@ -28,6 +30,7 @@ export interface IAgentHostClientConnectionService { hasSeenClient(clientId: string): boolean; isClientConnected(clientId: string): boolean; getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts; + requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; } export class AgentHostClientConnectionService extends Disposable implements IAgentHostClientConnectionService { @@ -84,4 +87,13 @@ export class AgentHostClientConnectionService extends Disposable implements IAge clientTransportCount, }; } + + requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise { + for (const source of this._sources) { + if (source.isClientConnected(clientId)) { + return source.requestWorkspaceTrust(clientId, request); + } + } + return Promise.reject(new Error(`Cannot request workspace trust because client ${clientId} is not connected.`)); + } } diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index 6cee16d28a5312..da336c112e05ad 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -64,6 +64,7 @@ import { EditArcReporterService, IEditArcReporterService } from './shared/editAr import { EditSurvivalReporterFactory, IEditSurvivalReporterFactory } from './shared/editSurvivalReporter.js'; import { IAgentHostWorktreeIsolation, WorktreeIsolation } from './shared/worktreeIsolation.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from './shared/agentBranchNameGenerator.js'; +import { AgentHostTurnService, IAgentHostTurnService } from './agentHostTurnService.js'; export interface IAgentHostCoreServiceInputs { readonly storageResource: URI | undefined; @@ -99,6 +100,7 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input services.set(IAgentHostCompletions, new SyncDescriptor(AgentHostCompletions)); services.set(IAgentHostTerminalManager, new SyncDescriptor(AgentHostTerminalManager)); services.set(IAgentHostChatContributions, new SyncDescriptor(AgentHostChatContributions)); + services.set(IAgentHostTurnService, new SyncDescriptor(AgentHostTurnService)); services.set(IAgentHostTelemetryReporter, new SyncDescriptor(AgentHostTelemetryReporter)); services.set(IAgentHostTurnTracker, new SyncDescriptor(AgentHostTurnTracker)); services.set(IAgentHostToolCallTracker, new SyncDescriptor(AgentHostToolCallTracker)); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index a000fabbf8c5ad..df619f6e88ac21 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -10,7 +10,7 @@ import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; @@ -64,6 +64,8 @@ interface ISessionEntry { readonly createdAt: string; /** Last modification timestamp (ISO 8601). Catalog-only; derived from chat aggregation. */ modifiedAt: string; + /** Host-resolved project metadata that is not part of synchronized session state. */ + project?: SessionSummary['project']; /** Aggregate file-change counts for the session-wide changeset. Catalog-only. */ changes?: ChangesSummary; /** Whether this session is still an unused draft. Latches to `Used`. */ @@ -118,7 +120,7 @@ class SessionSummaryNotifier extends Disposable { constructor( private readonly _getSummary: (session: string) => SessionSummary | undefined, - private readonly _emit: (session: string, changes: Partial) => void, + private readonly _emit: (session: string, changes: SessionSummaryChanges) => void, ) { super(); } @@ -197,10 +199,10 @@ class SessionSummaryNotifier extends Disposable { return; } - const changes: Partial = {}; + const changes: SessionSummaryChanges = {}; if (current.title !== lastNotified.title) { changes.title = current.title; } if (current.status !== lastNotified.status) { changes.status = current.status; } - if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } + if (current.activity !== lastNotified.activity) { changes.activity = current.activity ?? null; } if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } @@ -471,7 +473,7 @@ export class AgentHostStateManager extends Disposable { modifiedAt: entry.modifiedAt, }; if (state.activity !== undefined) { summary.activity = state.activity; } - if (state.project !== undefined) { summary.project = state.project; } + if (entry.project !== undefined) { summary.project = entry.project; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } if (state.annotations !== undefined) { summary.annotations = state.annotations; } if (entry.changes !== undefined) { summary.changes = entry.changes; } @@ -830,7 +832,7 @@ export class AgentHostStateManager extends Disposable { /** Builds the authoritative {@link ISessionEntry} for a freshly seeded state. */ private _newEntry(state: SessionState, summary: SessionSummary, use: SessionUse): ISessionEntry { - return { state, createdAt: summary.createdAt, modifiedAt: summary.modifiedAt, changes: summary.changes, use }; + return { state, createdAt: summary.createdAt, modifiedAt: summary.modifiedAt, project: summary.project, changes: summary.changes, use }; } /** @@ -841,8 +843,9 @@ export class AgentHostStateManager extends Disposable { * `workingDirectory`, `modifiedAt`, `changes`) from the supplied summary * onto the session entry so subscribers see them. The reducer-owned metadata * (`title`, `status`, `activity`) is intentionally NOT copied back — the live - * state is authoritative for those. No-ops for sessions that were already - * announced (idempotent). + * state is authoritative for those. Project remains catalog-only while the + * resolved working directories are synchronized session state. No-ops for + * sessions that were already announced (idempotent). */ markSessionPersisted(session: URI, summary: SessionSummary, force = false): void { const key = session.toString(); @@ -855,11 +858,12 @@ export class AgentHostStateManager extends Disposable { return; } // Propagate the materialization-resolved fields so subscribers calling - // `getSessionState` / `getSessionSummary` see the resolved working - // directory / project. We don't need to schedule a + // `getSessionSummary` sees the resolved project and both state and + // summary see the resolved working directory. We don't need to schedule a // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. - entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories }; + entry.state = { ...entry.state, workingDirectories: summary.workingDirectories }; + entry.project = summary.project; entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry); @@ -1386,6 +1390,20 @@ export class AgentHostStateManager extends Disposable { this.dispatchServerAction(session, { type: ActionType.SessionMetaChanged, _meta: meta }); } + /** Updates catalog-only host-resolved project metadata. */ + setSessionProject(session: URI, project: SessionSummary['project']): void { + const entry = this._sessionStates.get(session); + if (!entry) { + this._logService.warn(`[AgentHostStateManager] setSessionProject: unknown session ${session}`); + return; + } + if (equals(entry.project, project)) { + return; + } + entry.project = project; + this._summaryNotifier.markDirty(session); + } + /** * Seeds or replaces a session's resolved {@link SessionConfigState} on the * live session state. Unlike mid-session {@link ActionType.SessionConfigChanged} diff --git a/src/vs/platform/agentHost/node/agentHostTurnService.ts b/src/vs/platform/agentHost/node/agentHostTurnService.ts new file mode 100644 index 00000000000000..6042be45b381ae --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostTurnService.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { StopWatch } from '../../../base/common/stopwatch.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { AgentHostClientType } from '../common/agentHostClientInfo.js'; +import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import type { ChatTurnStartedAction } from '../common/state/protocol/actions.js'; +import { createErrorResponsePart, parseRequiredSessionUriFromChatUri, type ErrorInfo, type Message, type URI as ProtocolURI } from '../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { startTurn } from './agentHostTurnStarter.js'; + +export const IAgentHostTurnService = createDecorator('agentHostTurnService'); + +/** An active host-authored turn whose provider execution has not started yet. */ +export interface IDeferredAgentHostTurn { + readonly turnId: string; +} + +/** Starts host-authored turns through the standard admission and provider-send path. */ +export interface IAgentHostTurnService { + readonly _serviceBrand: undefined; + startTurnMessage(chat: URI, message: Message): void; + beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn; + continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean; + failDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean; + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): void; +} + +/** Standard turn admission and provider routing shared by client- and host-authored turns. */ +export class AgentHostTurnService implements IAgentHostTurnService { + + declare readonly _serviceBrand: undefined; + + private readonly _deferredTurns = new Map(); + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostChatContributions private readonly _chatContributions: IAgentHostChatContributions, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + startTurnMessage(chat: URI, message: Message): void { + const channel = chat.toString(); + const action = this._dispatchTurnStarted(channel, message); + this.handleTurnStarted(channel, action); + } + + beginDeferredTurnMessage(chat: URI, message: Message): IDeferredAgentHostTurn { + const channel = chat.toString(); + if (this._deferredTurns.has(channel) || this._stateManager.getActiveTurnId(channel) !== undefined) { + throw new Error(`Cannot defer another turn while a turn is active: ${channel}`); + } + const action = this._dispatchTurnStarted(channel, message); + this._deferredTurns.set(channel, action); + return { turnId: action.turnId }; + } + + continueDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean { + const channel = chat.toString(); + const action = this._getDeferredTurn(channel, turn); + if (!action) { + return false; + } + if (this._stateManager.getActiveTurnId(channel) !== action.turnId) { + this._deferredTurns.delete(channel); + return false; + } + this.handleTurnStarted(channel, { ...action, message }); + this._deferredTurns.delete(channel); + return true; + } + + failDeferredTurnMessage(chat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean { + const channel = chat.toString(); + const action = this._getDeferredTurn(channel, turn); + if (!action) { + return false; + } + if (this._stateManager.getActiveTurnId(channel) !== action.turnId) { + this._deferredTurns.delete(channel); + return false; + } + this._stateManager.dispatchServerAction(channel, { + type: ActionType.ChatError, + turnId: action.turnId, + duration: Math.max(0, Date.now() - Date.parse(action.startedAt)), + part: createErrorResponsePart(error), + }); + this._chatContributions.turnEnd({ + session: parseRequiredSessionUriFromChatUri(channel), + channel, + turnId: action.turnId, + reason: { kind: 'error', error, resumable: false }, + }); + this._deferredTurns.delete(channel); + return true; + } + + handleTurnStarted(channel: ProtocolURI, action: ChatTurnStartedAction, clientId?: string, clientContextOrType?: IAgentHostClientTelemetryContext | AgentHostClientType): void { + const host = this._chatContributions.getHost(); + if (!host) { + throw new Error('Agent Host turn routing is unavailable.'); + } + const sessionChannel = parseRequiredSessionUriFromChatUri(channel); + const turnStopWatch = StopWatch.create(false); + const clientContext = clientContextOrType === undefined + ? { + ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), + hostLaunchKind: host.hostLaunchKind, + } + : typeof clientContextOrType === 'string' + ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) + : clientContextOrType; + const started = this._instantiationService.invokeFunction(startTurn, { + session: sessionChannel, + chat: channel, + turnChannel: channel, + turnId: action.turnId, + message: action.message, + source: 'direct', + clientId, + clientContext, + turnStopWatch, + }); + if (!started) { + return; + } + host.sendTurnMessage({ + agent: started.agent, + sessionChannel, + turnChannel: channel, + chat: channel, + message: action.message, + turnId: action.turnId, + senderClientId: clientId, + clientContext, + turnStopWatch, + }); + } + + private _dispatchTurnStarted(channel: ProtocolURI, message: Message): ChatTurnStartedAction { + const action: ChatTurnStartedAction = { + type: ActionType.ChatTurnStarted, + turnId: generateUuid(), + startedAt: new Date().toISOString(), + message, + }; + this._stateManager.dispatchServerAction(channel, action); + return action; + } + + private _getDeferredTurn(channel: ProtocolURI, turn: IDeferredAgentHostTurn): ChatTurnStartedAction | undefined { + const action = this._deferredTurns.get(channel); + if (action?.turnId !== turn.turnId) { + return undefined; + } + return action; + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9f571b26446724..c337fa86c62038 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -64,7 +64,7 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentSessionResidency } from './agentSessionResidency.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; -import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; +import { type IAgentServiceSessionServerToolAccessor, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; @@ -96,6 +96,7 @@ import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SU import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; +import { IAgentHostTurnService } from './agentHostTurnService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; /** @@ -400,7 +401,7 @@ export interface IAgentServiceCallbacks { readonly resolveChatAttachmentTurns: NonNullable; readonly getSessionMetadata: (session: URI) => Promise; readonly restoreSession: (session: URI) => Promise; - readonly sessionServerToolAccessor: ISessionServerToolAccessor; + readonly sessionServerToolAccessor: IAgentServiceSessionServerToolAccessor; readonly artifactServerToolAccessor: IArtifactServerToolAccessor; } @@ -621,6 +622,7 @@ export class AgentService extends Disposable implements IAgentService { @IInstantiationService instantiationService: IInstantiationService, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, ) { super(); @@ -1169,9 +1171,11 @@ export class AgentService extends Disposable implements IAgentService { * Builds the dependency surface the session server-tool group needs, bound * to this service so the group stays decoupled from the concrete host. */ - private _createSessionServerToolAccessor(): ISessionServerToolAccessor { + private _createSessionServerToolAccessor(): IAgentServiceSessionServerToolAccessor { return { isActiveAgentTitleGenerationEnabled: () => this._isActiveAgentTitleGenerationEnabled(), + canConvertWorkspace: session => this._providerService.getProviderForSession(session)?.agentHostCapabilities.workspaceConversion === true + && readSessionWorkspaceless(this._stateManager.getSessionState(session.toString())?._meta), listSessions: () => this.listSessions(), getSession: session => this._getSessionMetadata(session), createSession: config => this.createSession(config), @@ -1276,9 +1280,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _startSessionMessage(chat: URI, message: Message): Promise { - const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const; - this._stateManager.dispatchServerAction(chat.toString(), action); - this._sideEffects.handleAction(chat.toString(), action); + this._turnService.startTurnMessage(chat, message); } private async _cancelAutomationSession(session: URI): Promise { @@ -5183,6 +5185,9 @@ export class AgentService extends Disposable implements IAgentService { if (await this._sessionRegistry.isTombstoned(session)) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } + if (await this._isWorkspaceConversionQuarantined(session)) { + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Session is unavailable because its provider could not be detached from an untrusted working directory: ${sessionStr}`); + } let registeredSession = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (registeredSession) { this._providerService.associateSession(session, registeredSession.provider); @@ -6068,6 +6073,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _isWorkspaceConversionQuarantined(session: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase?.(session); + if (!ref) { + return false; + } + try { + return await ref.object.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY) === 'true'; + } finally { + ref.dispose(); + } + } + /** * Reads the orchestrator's persisted peer-chat catalog for a session. * Returns `undefined` when the session has no catalog yet (a legacy session diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 61317402fe8fe2..2d152857ced596 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -8,6 +8,7 @@ import { DisposableStore, type IDisposable, MutableDisposable } from '../../../b import type { IObservable } from '../../../base/common/observable.js'; import { dirname, joinPath } from '../../../base/common/resources.js'; import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; @@ -33,10 +34,13 @@ import { AgentMergeTools } from './agentMergeTools.js'; import { AgentService, type IAgentServiceCollaborators, type IAgentServiceCore, type IAgentServiceOptions } from './agentService.js'; import { AgentSessionRegistry } from './agentSessionRegistry.js'; import { AgentSideEffects } from './agentSideEffects.js'; -import { AgentServerToolHost } from './shared/agentServerToolHost.js'; +import { AgentServerToolHost, IAgentHostServerToolService } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; +import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; +import { ISessionWorkspaceConversionService, SessionWorkspaceConversionService } from './chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostTurnTracker } from './agentHostTurnTracker.js'; export interface IAgentServiceComposition { readonly agentService: AgentService; @@ -66,6 +70,7 @@ export function createAgentServiceComposition( options: IAgentServiceOptions, accessor: ServicesAccessor, instantiationService: IInstantiationService, + services: ServiceCollection, logService: ILogService, sessionDataService: ISessionDataService, foundation: IAgentServiceFoundation, @@ -138,10 +143,28 @@ export function createAgentServiceComposition( () => agentMergeController.isEnabled(), session => agentMergeController.getTurnContext(session), ); + const turnTracker = accessor.get(IAgentHostTurnTracker); + const workspaceConversionService: { value: ISessionWorkspaceConversionService | undefined } = { value: undefined }; + const sessionServerToolAccessor: ISessionServerToolAccessor = { + ...callbackAdapter.sessionServerToolAccessor, + requestSessionWorkspaceUpdate: (chat, turnId, workspaceFolder, isolation) => { + const initiatingClientId = turnTracker.getInitiatorClientId(chat.toString(), turnId); + if (!initiatingClientId) { + throw new Error('Session workspace conversion requires a turn initiated by a connected VS Code client.'); + } + if (!workspaceConversionService.value) { + throw new Error('Session workspace conversion is unavailable.'); + } + workspaceConversionService.value.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation, initiatingClientId); + }, + }; const serverToolHost = new AgentServerToolHost( stateManager, - buildServerToolGroups(callbackAdapter.sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), + buildServerToolGroups(sessionServerToolAccessor, agentMergeTools, callbackAdapter.artifactServerToolAccessor), ); + services.set(IAgentHostServerToolService, serverToolHost); + workspaceConversionService.value = owned.add(instantiationService.createInstance(SessionWorkspaceConversionService)); + services.set(ISessionWorkspaceConversionService, workspaceConversionService.value); const automationService = owned.add(instantiationService.createInstance(AgentHostAutomationService, callbackAdapter.automationExecution)); const collaborators: IAgentServiceCollaborators = { diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index 2a4f02b6989b60..8506160d570ad6 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -21,7 +21,7 @@ import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProx import { AgentHostRequestService } from './agentHostRequestService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import type { IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; -import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js'; +import type { IAgentServiceSessionServerToolAccessor } from './shared/sessionServerTools.js'; import { hostBuildInfoFromProduct } from '../common/state/sessionState.js'; export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder { @@ -34,8 +34,9 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder cancelSession: session => this.value.automationExecution.cancelSession(session), }; - readonly sessionServerToolAccessor: ISessionServerToolAccessor = { + readonly sessionServerToolAccessor: IAgentServiceSessionServerToolAccessor = { isActiveAgentTitleGenerationEnabled: () => this.value.sessionServerToolAccessor.isActiveAgentTitleGenerationEnabled(), + canConvertWorkspace: session => this.value.sessionServerToolAccessor.canConvertWorkspace(session), listSessions: () => this.value.sessionServerToolAccessor.listSessions(), getSession: session => this.value.sessionServerToolAccessor.getSession(session), createSession: config => this.value.sessionServerToolAccessor.createSession(config), diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 1882f0b4077456..4766171dbf7302 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -77,8 +77,8 @@ import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHost import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js'; +import { IAgentHostTurnService } from './agentHostTurnService.js'; import type { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; -import { startTurn } from './agentHostTurnStarter.js'; import './localCommands/localChatCommands.contribution.js'; import { SessionPermissionManager } from './sessionPermissions.js'; import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/proxyChatError.js'; @@ -238,6 +238,7 @@ export class AgentSideEffects extends Disposable { @IAgentHostTurnTracker private readonly _turnTracker: AgentHostTurnTracker, @IAgentHostToolCallTracker private readonly _toolCallTracker: AgentHostToolCallTracker, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, + @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, ) { super(); this.onDidStartTurn = this._turnTracker.onDidStartTurn; @@ -1350,32 +1351,7 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`); } - const turnStopWatch = StopWatch.create(false); - const started = this._instantiationService.invokeFunction(startTurn, { - session: sessionChannel, - chat: channel, - turnChannel: channel, - turnId: action.turnId, - message: action.message, - source: 'direct', - clientId, - clientContext, - turnStopWatch, - }); - if (!started) { - break; - } - void this._sendTurnMessage({ - agent: started.agent, - sessionChannel, - turnChannel: channel, - chat: channel, - message: action.message, - turnId: action.turnId, - senderClientId: clientId, - clientContext, - turnStopWatch, - }); + this._turnService.handleTurnStarted(channel, action, clientId, clientContext); break; } case ActionType.ChatTurnResume: { diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index 61cd96cc1b8342..928bd79d829cd2 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -14,6 +14,7 @@ import { LocalCommandContribution } from './localCommand/localCommandContributio import { MarkdownPlanRichLinksContribution } from './markdownPlanRichLinks/markdownPlanRichLinksContribution.js'; import { MarkUnreadContribution } from './markUnread/markUnreadContribution.js'; import { PersistedTurnUsageContribution } from './persistedTurnUsage/persistedTurnUsageContribution.js'; +import { SessionWorkspaceConversionContribution } from './sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { QueueDrainContribution } from './queueDrain/queueDrainContribution.js'; import { SessionFlagsContribution } from './sessionFlags/sessionFlagsContribution.js'; import { SessionInputNeededContribution } from './sessionInputNeeded/sessionInputNeededContribution.js'; @@ -34,6 +35,7 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(PersistedTurnUsageContribution)); registrations.add(contributions.registerContribution(WorktreeAnnouncementContribution)); registrations.add(contributions.registerContribution(CheckpointAndChangesetContribution)); + registrations.add(contributions.registerContribution(SessionWorkspaceConversionContribution)); registrations.add(contributions.registerContribution(QueueDrainContribution)); registrations.add(contributions.registerContribution(SessionInputNeededContribution)); registrations.add(contributions.registerContribution(GitHubReferencesContribution)); diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index 0761febd2102a8..769cc4bec90ee1 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -17,6 +17,7 @@ import { getErrorResponsePart, isAhpChatChannel, parseRequiredSessionUriFromChat import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostProviderService } from '../../agentHostProviderService.js'; import { startTurn } from '../../agentHostTurnStarter.js'; +import { ISessionWorkspaceConversionService } from '../sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; const QueuedSender = createChatMementoKey('queueDrain.sender', () => undefined); @@ -33,6 +34,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, ) { super(); } @@ -89,6 +91,9 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat } private _tryConsumeNextQueuedMessage(channel: ProtocolURI): void { + if (this._conversionService.isPending(channel)) { + return; + } if (this._stateManager.getActiveTurnId(channel)) { return; } diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts new file mode 100644 index 00000000000000..4e23de05aa6007 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { ISessionWorkspaceConversionService } from './sessionWorkspaceConversionService.js'; + +/** Finalizes requested workspace conversions after a turn and blocks new turns while conversion is pending. */ +export class SessionWorkspaceConversionContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'sessionWorkspaceConversion'; + readonly order = 150; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, + ) { + super(); + } + + onTurnEnd(turn: ITurnEnd): void { + if (turn.reason.kind === 'success') { + void this._conversionService.updateSessionWorkspace(turn.channel, turn.turnId); + } else { + this._conversionService.cancel(turn.channel, turn.turnId); + } + } + + onIncomingRequest(request: IIncomingRequest): IncomingRequestDisposition | undefined { + if (!this._conversionService.isPending(request.chat)) { + return undefined; + } + return { + kind: 'reject', + error: { + errorType: 'workspaceConversionPending', + message: localize('agentHost.workspaceConversionPending', "Wait for workspace setup to finish before sending another message."), + }, + stage: 'validation', + }; + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts new file mode 100644 index 00000000000000..604699116fd22f --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -0,0 +1,531 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { createDecorator } from '../../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { AgentSession, AgentWorkingDirectoryChangedError, type IAgent, type IAgentSessionProjectInfo } from '../../../common/agent.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; +import { ActionType } from '../../../common/state/sessionActions.js'; +import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withMessageSystemInitiatedLabel, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; +import { IAgentHostClientConnectionService } from '../../agentHostClientConnectionService.js'; +import { IAgentHostProviderService } from '../../agentHostProviderService.js'; +import { IAgentHostTurnService, type IDeferredAgentHostTurn } from '../../agentHostTurnService.js'; +import { IAgentHostServerToolService } from '../../shared/agentServerToolHost.js'; +import { IAgentHostWorktreeIsolation, type IIsolationConfigContribution } from '../../shared/worktreeIsolation.js'; + +interface IPendingSessionWorkspaceConversion { + readonly chat: URI; + readonly turnId: string; + readonly workspaceFolder: URI; + readonly isolation: boolean; + readonly initiatingClientId: string; + readonly prompt: string | undefined; + phase: 'requested' | 'converting'; + resolvedWorkingDirectory?: URI; +} + +interface IResolvedWorkspace { + readonly workingDirectory: URI; + readonly configValues: Record; + readonly isolationConfig: IIsolationConfigContribution | undefined; + readonly isolated: boolean; + readonly project: IAgentSessionProjectInfo | undefined; +} + +class UnsafeProviderWorkingDirectoryError extends Error { +} + +export const ISessionWorkspaceConversionService = createDecorator('sessionWorkspaceConversionService'); + +/** Coordinates requested workspace changes after the requesting turn has finished. */ +export interface ISessionWorkspaceConversionService { + readonly _serviceBrand: undefined; + requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): void; + isPending(chat: ProtocolURI): boolean; + cancel(chat: ProtocolURI, turnId: string | undefined): void; + updateSessionWorkspace(chat: ProtocolURI, turnId: string | undefined): Promise; +} + +/** Converts workspace-less sessions in place while preserving their session and chat identities. */ +export class SessionWorkspaceConversionService extends Disposable implements ISessionWorkspaceConversionService { + + declare readonly _serviceBrand: undefined; + + private readonly _pending = new Map(); + private readonly _quarantined = new Set(); + + constructor( + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentHostWorktreeIsolation private readonly _worktreeIsolation: IAgentHostWorktreeIsolation, + @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, + @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, + @IAgentHostServerToolService private readonly _serverToolHost: IAgentHostServerToolService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._stateManager.onDidRemoveSession(session => { + const chat = buildDefaultChatUri(session); + this._pending.delete(chat); + this._quarantined.delete(chat); + })); + } + + requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): void { + if (!initiatingClientId) { + throw new Error('Session workspace conversion requires an initiating client.'); + } + this._validateConversion(chat, workspaceFolder); + const activeTurnId = this._stateManager.getActiveTurnId(chat.toString()); + if (activeTurnId !== turnId) { + throw new Error('Session workspace conversion must be requested from the active turn.'); + } + const prompt = this._stateManager.getChatState(chat.toString())?.activeTurn?.message.text; + const key = chat.toString(); + if (this.isPending(key)) { + throw new Error('A workspace conversion is already pending for this session.'); + } + this._pending.set(key, { chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, phase: 'requested' }); + } + + isPending(chat: ProtocolURI): boolean { + return this._pending.has(chat) || this._quarantined.has(chat); + } + + cancel(chat: ProtocolURI, turnId: string | undefined): void { + const pending = this._pending.get(chat); + if (pending && pending.turnId === turnId && pending.phase === 'requested') { + this._pending.delete(chat); + } + } + + async updateSessionWorkspace(chat: ProtocolURI, turnId: string | undefined): Promise { + const pending = this._pending.get(chat); + if (!pending || pending.turnId !== turnId || pending.phase !== 'requested') { + return; + } + + pending.phase = 'converting'; + let continuation: IDeferredAgentHostTurn | undefined; + try { + continuation = this._beginContinuation(pending); + pending.resolvedWorkingDirectory = await this._convert(pending.chat, pending.workspaceFolder, pending.isolation, pending.initiatingClientId, pending.prompt); + this._pending.delete(chat); + this._continueConversion(continuation, pending, true); + } catch (error) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(error)}`); + if (error instanceof UnsafeProviderWorkingDirectoryError) { + this._pending.delete(chat); + this._quarantined.add(chat); + this._failConversion(continuation, pending, error); + } else { + this._pending.delete(chat); + this._continueConversion(continuation, pending, false, error); + } + } + } + + private async _convert(chat: URI, workspaceFolder: URI, isolation: boolean, initiatingClientId: string, prompt?: string): Promise { + const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); + const provider = this._providerService.getProviderForSession(session); + if (!provider?.agentHostCapabilities.workspaceConversion) { + throw new Error(`Provider does not support changing the working directory: ${AgentSession.provider(session) ?? '(unknown)'}`); + } + await this._requireWorkspaceTrust(initiatingClientId, workspaceFolder); + const resolvedWorkspace = await this._resolveWorkspace(session, chat, workspaceFolder, isolation, initiatingClientId, prompt, state.config?.values); + let authoritativeWorkingDirectory = resolvedWorkspace.workingDirectory; + let providerAlignmentError: AgentWorkingDirectoryChangedError | undefined; + try { + await provider.setWorkingDirectory(chat, session, resolvedWorkspace.workingDirectory); + } catch (error) { + if (!(error instanceof AgentWorkingDirectoryChangedError)) { + const cleanupError = resolvedWorkspace.isolated ? await this._removeWorktree(session) : undefined; + if (cleanupError) { + throw new Error(`${toErrorMessage(error)}; failed to clean up the isolated worktree: ${toErrorMessage(cleanupError)}`); + } + throw error; + } + authoritativeWorkingDirectory = error.workingDirectory; + providerAlignmentError = error; + } + if (!isEqual(authoritativeWorkingDirectory, resolvedWorkspace.workingDirectory)) { + try { + await this._requireWorkspaceTrust(initiatingClientId, authoritativeWorkingDirectory); + } catch (error) { + const finalizationErrors = [error]; + const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); + finalizationErrors.push(...disposal.errors); + if (resolvedWorkspace.isolated) { + const cleanupError = await this._removeWorktree(session); + if (cleanupError) { + finalizationErrors.push(cleanupError); + } + } + throw new UnsafeProviderWorkingDirectoryError(`The provider changed to an untrusted working directory and was disposed: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}`); + } + } + + const convertedState = this._getUnchangedConversionState(session, chat, previousWorkingDirectory); + if (!convertedState) { + const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); + const finalizationErrors = [...disposal.errors]; + if (resolvedWorkspace.isolated) { + const cleanupError = await this._removeWorktree(session); + if (cleanupError) { + finalizationErrors.push(cleanupError); + } + } + throw new UnsafeProviderWorkingDirectoryError(`The workspace-less session state changed after the provider working directory changed, so the provider was disposed${finalizationErrors.length > 0 ? `: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}` : ''}`); + } + const worktreeApplied = resolvedWorkspace.isolated && isEqual(authoritativeWorkingDirectory, resolvedWorkspace.workingDirectory); + const worktreeCleanupError = resolvedWorkspace.isolated && !worktreeApplied ? await this._removeWorktree(session) : undefined; + const configPatch: Record = worktreeApplied + ? { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: resolvedWorkspace.configValues[SessionConfigKey.Branch], + } + : { [SessionConfigKey.Isolation]: 'folder' }; + const configValues = convertedState.config || worktreeApplied + ? { ...convertedState.config?.values, ...configPatch } + : undefined; + let persistenceError: unknown; + const database = this._sessionDataService.openDatabase(session); + try { + const metadata = { [AH_META_WORKSPACELESS_DB_KEY]: 'false' }; + if (configValues) { + Object.assign(metadata, { configValues: JSON.stringify(configValues) }); + } + await database.object.setMetadataValues(metadata); + } catch (error) { + persistenceError = error; + } finally { + database.dispose(); + } + if (persistenceError) { + const finalizationErrors = [persistenceError]; + const quarantineError = await this._persistQuarantine(session); + if (quarantineError) { + finalizationErrors.push(quarantineError); + } + throw new UnsafeProviderWorkingDirectoryError(`The provider working directory changed, but the converted session metadata could not be committed atomically: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}`); + } + + const finalState = this._getUnchangedConversionState(session, chat, previousWorkingDirectory, convertedState); + if (!finalState) { + const disposal = await this._disposeUnsafeProviderChat(provider, chat, session); + const finalizationErrors = [...disposal.errors]; + if (resolvedWorkspace.isolated) { + const cleanupError = await this._removeWorktree(session); + if (cleanupError) { + finalizationErrors.push(cleanupError); + } + } + throw new UnsafeProviderWorkingDirectoryError(`The workspace-less session state changed while converted metadata was being persisted, so the provider was disposed and the session was quarantined${finalizationErrors.length > 0 ? `: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}` : ''}`); + } + + if (worktreeApplied && resolvedWorkspace.project) { + this._stateManager.setSessionProject(session.toString(), { + uri: resolvedWorkspace.project.uri.toString(), + displayName: resolvedWorkspace.project.displayName, + }); + } + this._stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(finalState._meta, false)); + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: previousWorkingDirectory, + replacement: authoritativeWorkingDirectory.toString(), + }); + this._updateIsolationConfig(session, finalState.config, configPatch, resolvedWorkspace.isolationConfig, worktreeApplied); + this._serverToolHost.advertise(session.toString()); + try { + const customizations = await provider.getChatCustomizations(chat, session); + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionCustomizationsChanged, + customizations: [...customizations], + }); + } catch (error) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to refresh customizations for ${session.toString()}: ${toErrorMessage(error)}`); + } + const finalizationErrors: unknown[] = []; + if (providerAlignmentError) { + finalizationErrors.push(providerAlignmentError); + } + if (worktreeCleanupError) { + finalizationErrors.push(worktreeCleanupError); + } + if (finalizationErrors.length > 0) { + throw new Error(`The workspace changed to '${authoritativeWorkingDirectory.fsPath}', but conversion did not complete cleanly: ${finalizationErrors.map(error => toErrorMessage(error)).join('; ')}`); + } + return authoritativeWorkingDirectory; + } + + private async _requireWorkspaceTrust(clientId: string, workspace: URI, trustedParent?: URI): Promise { + const trusted = await this._clientConnections.requestWorkspaceTrust(clientId, { + workspace: workspace.toString(), + ...(trustedParent ? { trustedParent: trustedParent.toString() } : {}), + }); + if (!trusted) { + throw new Error(`Workspace trust was not granted for '${workspace.fsPath}'`); + } + } + + private async _persistQuarantine(session: URI): Promise { + const database = this._sessionDataService.openDatabase(session); + try { + await database.object.setMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, 'true'); + return undefined; + } catch (error) { + return error; + } finally { + database.dispose(); + } + } + + private async _disposeUnsafeProviderChat(provider: IAgent, chat: URI, session: URI): Promise<{ readonly errors: readonly unknown[] }> { + const errors: unknown[] = []; + const quarantineError = await this._persistQuarantine(session); + if (quarantineError) { + errors.push(quarantineError); + } + try { + await provider.chats.releaseChat(chat, session); + } catch (error) { + errors.push(error); + } + try { + await provider.chats.disposeChat(chat, session); + } catch (error) { + errors.push(error); + } + return { errors }; + } + + private async _resolveWorkspace( + session: URI, + chat: URI, + workspaceFolder: URI, + isolation: boolean, + initiatingClientId: string, + prompt: string | undefined, + currentConfig: Record | undefined, + ): Promise { + if (!isolation) { + return { + workingDirectory: workspaceFolder, + configValues: { ...currentConfig, [SessionConfigKey.Isolation]: 'folder' }, + isolationConfig: undefined, + isolated: false, + project: undefined, + }; + } + if (!this._worktreeIsolation.supported) { + throw new Error('Isolated worktrees are not supported by this Agent Host.'); + } + + const requestedConfig: Record = { ...currentConfig, [SessionConfigKey.Isolation]: 'worktree' }; + delete requestedConfig[SessionConfigKey.Branch]; + const isolationConfig = await this._worktreeIsolation.resolveIsolationConfig({ + workingDirectory: workspaceFolder, + config: requestedConfig, + }); + if (!isolationConfig || isolationConfig.isolationValue !== 'worktree' || !isolationConfig.branchValue) { + throw new Error('An isolated worktree requires a local Git repository with at least one commit.'); + } + const configValues = { + ...requestedConfig, + [SessionConfigKey.Branch]: isolationConfig.branchValue, + }; + const workingDirectory = await this._worktreeIsolation.resolveOnFirstSend({ + sessionUri: session, + sessionId: AgentSession.id(session), + workingDirectory: workspaceFolder, + config: configValues, + prompt, + onWillCreate: async metadata => { + if (!isEqual(metadata.repositoryRoot, workspaceFolder)) { + await this._requireWorkspaceTrust(initiatingClientId, metadata.repositoryRoot); + } + await this._requireWorkspaceTrust(initiatingClientId, metadata.worktreePath, metadata.repositoryRoot); + }, + }); + if (!workingDirectory || isEqual(workingDirectory, workspaceFolder)) { + throw new Error('The isolated worktree could not be created.'); + } + this._worktreeIsolation.takePendingAnnouncement(AgentSession.id(session)); + const project = this._worktreeIsolation.sessionWorktreeProject(AgentSession.id(session)); + if (!project) { + const cleanupError = await this._removeWorktree(session); + throw new Error(cleanupError + ? `The isolated worktree project could not be resolved, and cleanup failed: ${toErrorMessage(cleanupError)}` + : 'The isolated worktree project could not be resolved.'); + } + return { workingDirectory, configValues, isolationConfig, isolated: true, project }; + } + + private async _removeWorktree(session: URI): Promise { + const sessionId = AgentSession.id(session); + try { + const worktree = await this._worktreeIsolation.prepareSessionDeletion(session, sessionId); + await this._worktreeIsolation.discardSessionWorktree(session, sessionId, worktree); + return undefined; + } catch (error) { + return error; + } + } + + private _updateIsolationConfig( + session: URI, + currentConfig: SessionConfigState | undefined, + configPatch: Record, + isolationConfig: IIsolationConfigContribution | undefined, + worktreeApplied: boolean, + ): void { + if (worktreeApplied && isolationConfig) { + const properties = { + ...currentConfig?.schema.properties, + [SessionConfigKey.Isolation]: isolationConfig.isolationProperty.protocol, + ...(isolationConfig.branchProperty ? { [SessionConfigKey.Branch]: isolationConfig.branchProperty.protocol } : {}), + ...(isolationConfig.worktreeBranchPrefixProperty ? { [SessionConfigKey.WorktreeBranchPrefix]: isolationConfig.worktreeBranchPrefixProperty.protocol } : {}), + ...(isolationConfig.worktreeBranchTrackProperty ? { [SessionConfigKey.WorktreeBranchTrack]: isolationConfig.worktreeBranchTrackProperty.protocol } : {}), + ...(isolationConfig.worktreeCreateNewBranchProperty ? { [SessionConfigKey.WorktreeCreateNewBranch]: isolationConfig.worktreeCreateNewBranchProperty.protocol } : {}), + ...(isolationConfig.worktreeIncludeFilesProperty ? { [SessionConfigKey.WorktreeIncludeFiles]: isolationConfig.worktreeIncludeFilesProperty.protocol } : {}), + }; + this._stateManager.setSessionConfig(session.toString(), { + schema: { type: 'object', properties }, + values: { ...currentConfig?.values }, + }); + } + if (this._stateManager.getSessionState(session.toString())?.config) { + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionConfigChanged, + config: configPatch, + }); + } + } + + private _getUnchangedConversionState(session: URI, chat: URI, previousWorkingDirectory: ProtocolURI, expectedState?: ISessionWithDefaultChat): ISessionWithDefaultChat | undefined { + const state = this._stateManager.getSessionState(session.toString()); + if (!state + || this._pending.get(chat.toString())?.phase !== 'converting' + || !readSessionWorkspaceless(state._meta) + || (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived + || state.defaultChat !== chat.toString() + || state.workingDirectories?.length !== 1 + || state.workingDirectories[0] !== previousWorkingDirectory + || (expectedState && (!equals(state._meta, expectedState._meta) || !equals(state.config, expectedState.config) || !equals(state.project, expectedState.project))) + ) { + return undefined; + } + return state; + } + + private _validateConversion(chat: URI, workspaceFolder: URI) { + const parsedChat = parseChatUri(chat); + if (!parsedChat) { + throw new Error(`Cannot change the working directory for invalid chat resource: ${chat.toString()}`); + } + if (workspaceFolder.scheme !== Schemas.file || !workspaceFolder.path.startsWith('/')) { + throw new Error('The workspace folder must be an absolute local path or file URI.'); + } + const session = URI.parse(parsedChat.session, true); + const state = this._stateManager.getSessionState(session.toString()); + if (!state) { + throw new Error(`Cannot change the working directory for unknown session: ${session.toString()}`); + } + if (!readSessionWorkspaceless(state._meta)) { + throw new Error('Only a workspace-less session can be converted to a workspace session.'); + } + if ((state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived) { + throw new Error('An archived session cannot be converted to a workspace session.'); + } + if (!isDefaultChatUri(chat) || state.defaultChat !== chat.toString()) { + throw new Error('Only the owning default chat can convert the session to a workspace session.'); + } + if (state.workingDirectories?.length !== 1) { + throw new Error('A workspace-less session must have exactly one working directory before conversion.'); + } + return { session, state, previousWorkingDirectory: state.workingDirectories[0] }; + } + + private _beginContinuation(pending: IPendingSessionWorkspaceConversion): IDeferredAgentHostTurn { + const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageSystemInitiatedLabel({ + text: localize('agentHost.continueInWorkspaceMessage', "Continue in the requested workspace."), + origin: { kind: MessageKind.SystemNotification }, + }, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace"))); + return continuation; + } + + private _continueConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, converted: boolean, error?: unknown): void { + if (!continuation) { + this._logService.error(`[SessionWorkspaceConversionService] Cannot continue workspace conversion for ${pending.chat.toString()} because its deferred turn did not start.`); + return; + } + const errorMessage = error === undefined ? undefined : toErrorMessage(error).replace(/\.+$/, ''); + const text = converted + ? `The current session is now attached to ${(pending.resolvedWorkingDirectory ?? pending.workspaceFolder).fsPath}${pending.isolation ? ' in an isolated worktree' : ''}. Continue the user's original task in this workspace. Do not request another session or workspace conversion.` + : `The requested workspace setup did not complete successfully: ${errorMessage}. Do not run the user's task. Tell the user that workspace setup failed and include this error.`; + const label = converted + ? localize('agentHost.workspaceSetLabel', "Workspace Set") + : localize('agentHost.workspaceSetupFailedLabel', "Workspace Setup Failed"); + this._publishConversionOutcome(pending.chat, continuation, label); + try { + if (!this._turnService.continueDeferredTurnMessage(pending.chat, continuation, withMessageSystemInitiatedLabel({ + text, + origin: { kind: MessageKind.SystemNotification }, + }, label))) { + this._logService.info(`[SessionWorkspaceConversionService] The deferred workspace conversion turn for ${pending.chat.toString()} ended before it could continue.`); + } + } catch (continuationError) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to start the conversion continuation for ${pending.chat.toString()}: ${toErrorMessage(continuationError)}`); + this._failConversion(continuation, pending, continuationError instanceof Error ? continuationError : new Error(toErrorMessage(continuationError)), false); + } + } + + private _failConversion(continuation: IDeferredAgentHostTurn | undefined, pending: IPendingSessionWorkspaceConversion, error: Error, publishOutcome = true): void { + if (!continuation) { + this._logService.error(`[SessionWorkspaceConversionService] Cannot report workspace conversion failure for ${pending.chat.toString()} because its deferred turn did not start.`); + return; + } + if (publishOutcome) { + this._publishConversionOutcome(pending.chat, continuation, localize('agentHost.workspaceSetupFailedLabel', "Workspace Setup Failed")); + } + if (!this._turnService.failDeferredTurnMessage(pending.chat, continuation, { + errorType: 'workspaceConversionFailed', + message: toErrorMessage(error), + })) { + this._logService.info(`[SessionWorkspaceConversionService] The deferred workspace conversion turn for ${pending.chat.toString()} ended before its failure could be reported.`); + } + } + + private _publishConversionOutcome(chat: URI, continuation: IDeferredAgentHostTurn, label: string): void { + if (this._stateManager.getActiveTurnId(chat.toString()) !== continuation.turnId) { + return; + } + this._stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatResponsePart, + turnId: continuation.turnId, + part: { + kind: ResponsePartKind.SystemNotification, + content: label, + }, + }); + } + + override dispose(): void { + this._pending.clear(); + this._quarantined.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index dbb1d79a5b592f..a8baa4bcf574a1 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -351,6 +351,7 @@ class ClaudeActiveClientHandle implements IActiveClient { */ export class ClaudeAgent extends Disposable implements IAgent { readonly id: AgentProvider = CLAUDE_AGENT_PROVIDER_ID; + readonly agentHostCapabilities = { workspaceConversion: false } as const; private readonly _onDidChatProgress = this._register(new Emitter()); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -715,6 +716,10 @@ export class ClaudeAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(platformRootSchema, AgentHostClaudeMultiRootEnabledConfigKey) === true; } + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error('Claude does not support changing the working directory of an existing session.'); + } + getProtectedResources(): ProtectedResourceMetadata[] { // Always listed, always optional. Listing it is what lets the host forward a // token to an already-signed-in user (matching ignores `required`); the diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index 12c33cbf54623a..09e0d710bc654e 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -10,6 +10,7 @@ import { ClaudePermissionMode, ClaudeSessionConfigKey } from '../../common/claud import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { ChatInputResponseKind, ToolCallPendingConfirmationState, ToolCallStatus } from '../../common/state/protocol/state.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { getServerToolDisplay } from '../shared/serverToolGroups.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { extractServerToolName } from './claudeServerToolMcpServer.js'; import { buildAskUserSessionInputQuestions, buildExitPlanModeConfirmationState, flattenAskUserAnswers, parseAskUserQuestionInput } from './claudeInteractiveTools.js'; @@ -149,16 +150,17 @@ async function dispatchCanUseTool( const permissionKind = getClaudePermissionKind(toolName); const displayName = getClaudeToolDisplayName(toolName); const permissionPath = options.blockedPath ?? getClaudeToolPath(toolName, input); - const toolInputString = getClaudeToolInputString(toolName, input); + const serverDisplay = serverToolName ? getServerToolDisplay(serverToolName, input) : undefined; + const toolInputString = serverDisplay?.hideConfirmationInput ? undefined : getClaudeToolInputString(toolName, input); const meta = buildClaudeToolMeta(toolName); const state: ToolCallPendingConfirmationState = { status: ToolCallStatus.PendingConfirmation, toolCallId: options.toolUseID, toolName, displayName, - invocationMessage: getClaudeInvocationMessage(toolName, displayName, input), + invocationMessage: serverDisplay?.confirmationMessage ?? getClaudeInvocationMessage(toolName, displayName, input), toolInput: toolInputString, - confirmationTitle: getClaudeConfirmationTitle(toolName), + confirmationTitle: serverDisplay?.confirmationTitle ?? getClaudeConfirmationTitle(toolName), ...(meta ? { _meta: meta } : {}), }; diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 2143c0f4240a38..aa72e8a1cea3b7 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -1004,6 +1004,7 @@ function narrowFileChangeDecision(decision: CommandExecutionApprovalDecision): F export class CodexAgent extends Disposable implements IAgent { readonly id: AgentProvider = CODEX_AGENT_PROVIDER_ID; + readonly agentHostCapabilities = { workspaceConversion: false } as const; private readonly _onDidChatProgress = this._register(new Emitter()); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -2710,14 +2711,15 @@ export class CodexAgent extends Disposable implements IAgent { if (!entry) { return { result: this._toolFailure(`No pending server tool call for ${params.tool} (callId ${params.callId})`) }; } - const invocationMessage = getServerToolDisplay(params.tool, params.arguments)?.invocationMessage ?? `Calling ${params.tool}`; + const display = getServerToolDisplay(params.tool, params.arguments); + const invocationMessage = display?.confirmationMessage ?? display?.invocationMessage ?? `Calling ${params.tool}`; const decision = await session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => { this._fire(session.sessionUri, { type: ActionType.ChatToolCallReady, turnId: entry.turnId, toolCallId: entry.toolCallId, invocationMessage, - confirmationTitle: localize('codex.serverToolConfirmation.title', "Allow tool call?"), + confirmationTitle: display?.confirmationTitle ?? localize('codex.serverToolConfirmation.title', "Allow tool call?"), }); }); if (decision !== 'accept' && decision !== 'acceptForSession') { @@ -3983,6 +3985,10 @@ export class CodexAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(platformRootSchema, AgentHostCodexMultiRootEnabledConfigKey) === true; } + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error('Codex does not support changing the working directory of an existing session.'); + } + /** * Hides the multi-root Folder picker unless several working directories carry * a Codex `.codex/hooks.json` hook manifest (see diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts index cad3dd863a2aa3..898c7553ebcdb2 100644 --- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +++ b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; import { dirname } from '../../../../base/common/path.js'; import { OS, OperatingSystem } from '../../../../base/common/platform.js'; @@ -24,25 +25,32 @@ 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}. + * {@link AgentHostSandboxEngine}. */ -class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { - readonly onDidChangeRoots = Event.None; +class AgentHostTerminalSandboxHost extends Disposable implements ITerminalSandboxEngineHost { + private readonly _onDidChangeRoots = this._register(new Emitter()); + readonly onDidChangeRoots = this._onDidChangeRoots.event; readonly onDidChangeSandboxSettings: Event; private readonly _sandboxHelper: ISandboxHelperService; constructor( private readonly _sessionId: string, - private readonly _workingDirectory: URI | undefined, + private _workingDirectory: URI | undefined, private readonly _environmentService: INativeEnvironmentService, private readonly _productService: IProductService, private readonly _agentConfigurationService: IAgentConfigurationService, sandboxHelper: ISandboxHelperService, ) { + super(); this._sandboxHelper = sandboxHelper; this.onDidChangeSandboxSettings = this._agentConfigurationService.onDidRootConfigChange; } + setWorkingDirectory(workingDirectory: URI): void { + this._workingDirectory = workingDirectory; + this._onDidChangeRoots.fire(); + } + async getOS(): Promise { return OS; } @@ -119,21 +127,28 @@ class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { } } -/** - * 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); +/** Owns the terminal sandbox engine and its mutable Agent Host adapter. */ +export class AgentHostSandboxEngine extends Disposable { + readonly engine: TerminalSandboxEngine; + private readonly _host: AgentHostTerminalSandboxHost; + + constructor( + sessionId: string, + workingDirectory: URI | undefined, + @IInstantiationService instantiationService: IInstantiationService, + @IEnvironmentService environmentService: IEnvironmentService, + @IProductService productService: IProductService, + @IAgentConfigurationService agentConfigurationService: IAgentConfigurationService, + @ISandboxHelperService sandboxHelper: ISandboxHelperService, + ) { + super(); + this._host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, sandboxHelper); + this.engine = instantiationService.createInstance(TerminalSandboxEngine, this._host); + this._register(this.engine); + this._register(this._host); + } + + setWorkingDirectory(workingDirectory: URI): void { + this._host.setWorkingDirectory(workingDirectory); + } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index b155ce51c9d0c6..0f8144b6b3bf52 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -18,7 +18,7 @@ import { FileAccess, Schemas } from '../../../../base/common/network.js'; import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; import { autorun, observableValue, observableValueOpts, type IObservable, type ISettableObservable } from '../../../../base/common/observable.js'; -import { delimiter, dirname, join } from '../../../../base/common/path.js'; +import { delimiter, dirname, isAbsolute, join } from '../../../../base/common/path.js'; import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -77,7 +77,7 @@ import { IAgentHostSessionTitleSignal } from '../agentHostSessionTitleSignal.js' import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreeResumeService, SessionWorkingDirectoryMissingError } from '../shared/worktreeIsolation.js'; import { buildSessionEventLogFromTurns } from './buildSessionEvents.js'; -import { CopilotAgentSession } from './copilotAgentSession.js'; +import { CopilotAgentSession, type ICopilotWorkingDirectoryChangeTransaction } from './copilotAgentSession.js'; import { createCopilotCliEnvironment } from './copilotCliEnvironment.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; @@ -343,6 +343,22 @@ interface ICopilotAgentSessionIdentity { readonly resource: URI; } +interface IWorkingDirectoryMetadataSnapshot { + readonly workingDirectory: string | undefined; + readonly workingDirectories: string | undefined; + readonly customizationDirectory: string | undefined; +} + +interface IWorkingDirectoryChangeTransactionOptions { + readonly resource: URI; + readonly activeClient: ActiveClient; + readonly workingDirectory: URI; + readonly previousWorkingDirectory: URI; + readonly previousCustomizationDirectory: URI; + readonly previousCustomizationAdditionalDirectories: readonly URI[]; + readonly previousMetadata: IWorkingDirectoryMetadataSnapshot; +} + /** Stable empty host-customization snapshot used before the host publishes one. */ const NO_HOST_CUSTOMIZATIONS: readonly Customization[] = Object.freeze([]); const CHAT_QUEUE_STALL_WARNING_MS = 60_000; @@ -713,6 +729,7 @@ const NANO_AIU_PER_CREDIT = 1_000_000_000; */ export class CopilotAgent extends Disposable implements IAgent { readonly id = 'copilotcli' as const; + readonly agentHostCapabilities = { workspaceConversion: true } as const; protected readonly _now = Date.now; private readonly _onDidChatProgress = this._register(new Emitter()); @@ -843,6 +860,7 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _chatEntriesBySdkId = this._register(new DisposableMap()); /** Exact host chat URI -> persisted provider backing; live SDK sessions are tracked separately. */ private readonly _chatBackings = new Map(); + private readonly _workingDirectoryMutations = new ResourceMap(); /** Exact chat -> recorded configuration scope, used for fork/restore paths that only know the chat URI. */ private readonly _chatScopes = new Map(); @@ -1443,6 +1461,156 @@ export class CopilotAgent extends Disposable implements IAgent { return applyMcpServerEnablement(customizations, this._retainedHostCustomizations(session)); } + async setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise { + const initial = this._resolveLiveWorkingDirectoryContext(chat, context); + if (!isDefaultChatUri(chat)) { + throw new Error(`Cannot change the working directory for peer chat '${chat.toString()}': live working-directory changes are only supported for the owning default chat`); + } + const existingMutation = this._workingDirectoryMutations.get(initial.configurationResource); + if (existingMutation) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another working-directory change is active for its configuration`); + } + this._throwIfRecordedChatSharesConfiguration(chat, initial.configurationResource); + this._workingDirectoryMutations.set(initial.configurationResource, initial.entry); + try { + await this._queueChat(initial.configurationId, initial.sdkSessionId, 'setWorkingDirectory', async () => { + const current = this._resolveLiveWorkingDirectoryContext(chat, context); + if (current.entry !== initial.entry || current.sdkSessionId !== initial.sdkSessionId) { + throw new Error(`Cannot change the working directory: chat '${chat.toString()}' is no longer backed by the same live session`); + } + this._throwIfRecordedChatSharesConfiguration(chat, current.configurationResource); + for (const candidate of this._chatEntriesBySdkId.values()) { + const sibling = candidate.chatSession; + if (sibling !== current.entry && isEqual(sibling.ownerSessionUri ?? sibling.sessionUri, current.configurationResource)) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`); + } + } + if (workingDirectory.scheme !== Schemas.file || !isAbsolute(workingDirectory.fsPath)) { + throw new Error(`Cannot change the working directory to non-local or relative resource '${workingDirectory.toString()}'`); + } + if (!await this._isExistingDirectory(workingDirectory.fsPath)) { + throw new Error(`Cannot change the working directory because '${workingDirectory.fsPath}' is not an existing directory`); + } + + const { entry, configurationResource, resource } = current; + const activeClient = this._activeClients.get(configurationResource); + if (!activeClient) { + throw new Error(`Cannot change the working directory: chat '${chat.toString()}' has no active client`); + } + + const storedMetadata = await this._readWorkingDirectoryMetadata(resource); + if (entry.appliedAdditionalDirectories.length > 0 || activeClient.pluginController.additionalDirectories.length > 0 || (storedMetadata.workingDirectories?.length ?? 0) > 1) { + throw new Error(`Cannot change the working directory for multi-root chat '${chat.toString()}'`); + } + + const previousWorkingDirectory = entry.workingDirectory; + if (!previousWorkingDirectory) { + throw new Error(`Cannot change the working directory: live chat '${chat.toString()}' has no working directory`); + } + if (isEqual(previousWorkingDirectory, workingDirectory)) { + return; + } + + const previousCustomizationDirectory = activeClient.pluginController.directory ?? previousWorkingDirectory; + const previousCustomizationAdditionalDirectories = [...activeClient.pluginController.additionalDirectories]; + const transaction = this._createWorkingDirectoryChangeTransaction({ + resource, + activeClient, + workingDirectory, + previousWorkingDirectory, + previousCustomizationDirectory, + previousCustomizationAdditionalDirectories, + previousMetadata: storedMetadata.snapshot, + }); + await entry.setWorkingDirectory(workingDirectory, transaction); + }); + } finally { + if (this._workingDirectoryMutations.get(initial.configurationResource) === initial.entry) { + this._workingDirectoryMutations.delete(initial.configurationResource); + } + } + } + + private _createWorkingDirectoryChangeTransaction(options: IWorkingDirectoryChangeTransactionOptions): ICopilotWorkingDirectoryChangeTransaction { + const { + resource, + activeClient, + workingDirectory, + previousWorkingDirectory, + previousCustomizationDirectory, + previousCustomizationAdditionalDirectories, + previousMetadata, + } = options; + const metadataFor = (directory: URI): IWorkingDirectoryMetadataSnapshot => ({ + workingDirectory: directory.toString(), + workingDirectories: JSON.stringify([directory.toString()]), + customizationDirectory: directory.toString(), + }); + const applyProviderState = async (directory: URI, additionalDirectories: readonly URI[], metadata: IWorkingDirectoryMetadataSnapshot): Promise => { + const errors: string[] = []; + try { + activeClient.pluginController.reanchor(directory); + } catch (error) { + errors.push(`customization anchor: ${getErrorMessage(error)}`); + } + try { + activeClient.pluginController.setAdditionalDirectories(additionalDirectories); + } catch (error) { + errors.push(`customization additional roots: ${getErrorMessage(error)}`); + } + try { + await this._storeWorkingDirectoryMetadataSnapshot(resource, metadata); + } catch (error) { + errors.push(`provider metadata: ${getErrorMessage(error)}`); + } + if (errors.length > 0) { + throw new Error(errors.join('; ')); + } + }; + + return { + prepare: () => applyProviderState(workingDirectory, [], metadataFor(workingDirectory)), + rollback: () => applyProviderState(previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata), + reconcile: authoritativeWorkingDirectory => isEqual(authoritativeWorkingDirectory, previousWorkingDirectory) + ? applyProviderState(previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata) + : applyProviderState(authoritativeWorkingDirectory, [], metadataFor(authoritativeWorkingDirectory)), + }; + } + + private _resolveLiveWorkingDirectoryContext(chat: URI, context: URI | IAgentChatContext): { + readonly configurationResource: URI; + readonly configurationId: string; + readonly resource: URI; + readonly sdkSessionId: string; + readonly entry: CopilotAgentSession; + } { + const resolved = URI.isUri(context) + ? { configurationResource: context, resource: context } + : resolveAgentChatContext(context, chat); + const backing = this._chatBackings.get(chat.toString()); + const entry = backing ? this._findSessionBySdkId(backing.sdkSessionId) : undefined; + const configurationResource = entry?.ownerSessionUri ?? entry?.sessionUri; + if ( + !backing + || !entry + || backing.sdkSessionId !== entry.sessionId + || !isEqual(chat, entry.chatChannelUri) + || !configurationResource + || !isEqual(resolved.configurationResource, configurationResource) + || !isEqual(resolved.resource, entry.resourceUri) + || (isDefaultChatUri(chat) && (!isEqual(entry.resourceUri, configurationResource) || !isEqual(chat, URI.parse(buildDefaultChatUri(configurationResource))))) + ) { + throw new Error(`Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`); + } + return { + configurationResource, + configurationId: AgentSession.id(configurationResource), + resource: entry.resourceUri, + sdkSessionId: entry.sessionId, + entry, + }; + } + /** * Copilot applies hooks from the primary working directory only (see * `_hookWorkingDirectories` in sessionCustomizationDiscovery), so in a @@ -3100,6 +3268,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _createChat(chat: URI, context: IAgentChatContext, options: IAgentCreateChatOptions = {}): Promise { const scope = context.configurationResource; const chatKey = chat.toString(); + this._throwIfWorkingDirectoryMutationBlocksChat(scope, chat); // A duplicate/reconnect create call for a chat the agent already binds — // live (a real running session), provisional/reserved, or restored via // `materializeChat` — must never roll back that preexisting binding just @@ -3290,7 +3459,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const activeClient = this._activeClients.get(current.configurationResource); const currentSnapshot = activeClient ? await activeClient.snapshot(current.chatKey) : undefined; - if (activeClient && currentSnapshot && await activeClient.requiresRestart(entry.appliedSnapshot, current.chatKey, currentSnapshot)) { + if (entry.requiresRestartAfterWorkingDirectoryChange || (activeClient && currentSnapshot && await activeClient.requiresRestart(entry.appliedSnapshot, current.chatKey, currentSnapshot))) { await this._destroyLiveSession(entry, true); entry = entry.sessionId === current.configurationId ? await this._resumeSession(current.configurationId, current.chat) @@ -4008,7 +4177,7 @@ export class CopilotAgent extends Disposable implements IAgent { [...new Set(entry.appliedDisabledRootMcpServers)].sort(), [...new Set(currentDisabledRootMcpServers)].sort(), ); - if (entry && (rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh || entry.requiresControlPlaneResync)) { + if (entry && (entry.requiresRestartAfterWorkingDirectoryChange || rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh || entry.requiresControlPlaneResync)) { this._logService.info(`[Copilot:${current.configurationId}] Session configuration changed, refreshing session. clients=[${activeClient ? [...activeClient.toolSet.clientIds()].join(', ') || '(none)' : '(none)'}]`); // Finish disconnecting before resuming the SAME SDK session id with // the updated config. Routing is preserved so the session identity @@ -4400,6 +4569,7 @@ export class CopilotAgent extends Disposable implements IAgent { /** Resolves the live session for an addressed chat from exact recorded backings. */ private async _ensureResolvedChatSession(context: IResolvedCopilotChatContext, workingDirectories?: readonly URI[]): Promise { + this._throwIfWorkingDirectoryMutationBlocksChat(context.configurationResource, context.chat); const provisional = this._provisionalSessions.get(context.configurationId); if (provisional && provisional.sdkSessionId === context.sdkSessionId) { return this._materializeProvisional(context.configurationId, workingDirectories); @@ -4705,6 +4875,7 @@ export class CopilotAgent extends Disposable implements IAgent { /** Returns the live session for an exact chat, resuming it if necessary. */ private async _resolveOrResumeChatSession(context: IResolvedCopilotChatContext, workingDirectories?: readonly URI[]): Promise { const { configurationResource, configurationId, chat, chatKey } = context; + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); const existing = this._findChatByUri(chat); if (existing) { return existing; @@ -4720,6 +4891,7 @@ export class CopilotAgent extends Disposable implements IAgent { } let agentSession: CopilotAgentSession | undefined; try { + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); const again = this._findChatByUri(chat); if (again) { return again; @@ -4728,19 +4900,22 @@ export class CopilotAgent extends Disposable implements IAgent { if (!info) { return undefined; } + const storedMetadata = await this._readSessionMetadata(configurationResource); + const resumeWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories); const parentEntry = this._findSessionBySdkId(configurationId); - const persistedWorkingDirectory = workingDirectories?.[0] ?? parentEntry?.workingDirectory + const persistedWorkingDirectory = resumeWorkingDirectories?.[0] ?? parentEntry?.workingDirectory ?? this._provisionalSessions.get(configurationId)?.workingDirectory - ?? (await this._readSessionMetadata(configurationResource)).workingDirectory; + ?? storedMetadata.workingDirectory; if (!persistedWorkingDirectory) { this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); return undefined; } const workingDirectory = await this._worktree.resolveWorkingDirectoryForResume(configurationResource, AgentSession.id(configurationResource), persistedWorkingDirectory); - const launchWorkingDirectories = workingDirectories - ? [workingDirectory, ...workingDirectories.slice(1)] + const launchWorkingDirectories = resumeWorkingDirectories + ? [workingDirectory, ...resumeWorkingDirectories.slice(1)] : undefined; const client = await this._ensureClient(); + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); const activeClient = this._getOrCreateActiveClient(configurationResource, workingDirectory); activeClient.pluginController.reanchor(workingDirectory); const snapshot = await activeClient.snapshot(chatKey); @@ -4762,6 +4937,7 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource }); await agentSession.initializeSession(); this._throwIfClientReplaced(client, agentSession); + this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); this._registerLiveChat(chat, agentSession, activeClient); if (launchWorkingDirectories) { await this._storeSessionMetadata(context.resource, info.model, workingDirectory, launchWorkingDirectories, undefined, undefined); @@ -4778,6 +4954,21 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + private _throwIfWorkingDirectoryMutationBlocksChat(configurationResource: URI, chat: URI): void { + const mutationOwner = this._workingDirectoryMutations.get(configurationResource); + if (mutationOwner && this._findChatByUri(chat) !== mutationOwner) { + throw new Error(`Cannot create or resume chat '${chat.toString()}' while its configuration working directory is changing`); + } + } + + private _throwIfRecordedChatSharesConfiguration(chat: URI, configurationResource: URI): void { + for (const [recordedChat, recordedConfigurationResource] of this._chatScopes) { + if (recordedChat !== chat.toString() && isEqual(recordedConfigurationResource, configurationResource)) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another recorded chat shares its configuration`); + } + } + } + async truncateChat(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise { const resolved = this._resolveTruncateChatContext(chat, context); const sessionId = resolved.configurationId; @@ -5284,7 +5475,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Multi-root: re-attach the non-primary roots so discovery spans every // root on resume. Empty when single-root / gated off. A send-time // snapshot supersedes the persisted restoration seed. - const launchWorkingDirectories = workingDirectories ?? storedMetadata.workingDirectories; + const launchWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories) ?? storedMetadata.workingDirectories; activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(launchWorkingDirectories)); // Prefer chat-scoped membership when this SDK session is already bound to a chat. const snapshot = await activeClient.snapshot(this._findBoundSessionChatUri(sessionId)?.toString()); @@ -5328,6 +5519,22 @@ export class CopilotAgent extends Disposable implements IAgent { return agentSession; } + private _workingDirectoriesForResume( + storedMetadata: { readonly workingDirectory?: URI; readonly workingDirectories?: readonly URI[]; readonly workspaceless?: boolean }, + workingDirectories: readonly URI[] | undefined, + ): readonly URI[] | undefined { + // A live Quick Chat CWD change updates provider metadata before the AHP + // session model is converted. Preserve that provider-owned directory + // across refreshes instead of accepting the model's stale scratch root. + if (storedMetadata.workspaceless && storedMetadata.workingDirectory) { + return [ + storedMetadata.workingDirectory, + ...(storedMetadata.workingDirectories?.slice(1) ?? []), + ]; + } + return workingDirectories; + } + // ---- session metadata persistence -------------------------------------- private static readonly _META_MODEL = 'copilot.model'; @@ -5445,6 +5652,58 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private async _readWorkingDirectoryMetadata(session: URI): Promise<{ + workingDirectory?: URI; + workingDirectories?: readonly URI[]; + customizationDirectory?: URI; + snapshot: IWorkingDirectoryMetadataSnapshot; + }> { + const dbRef = await this._sessionDataService.tryOpenDatabase(session); + if (!dbRef) { + return { + snapshot: { + workingDirectory: undefined, + workingDirectories: undefined, + customizationDirectory: undefined, + }, + }; + } + try { + const metadata = await dbRef.object.getMetadataObject({ + [CopilotAgent._META_CWD]: true, + [CopilotAgent._META_CWDS]: true, + [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: true, + }); + const workingDirectory = metadata[CopilotAgent._META_CWD]; + const customizationDirectory = metadata[CopilotAgent._META_CUSTOMIZATION_DIRECTORY]; + return { + workingDirectory: workingDirectory ? URI.parse(workingDirectory) : undefined, + workingDirectories: this._parseWorkingDirectories(metadata[CopilotAgent._META_CWDS], undefined), + customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, + snapshot: { + workingDirectory, + workingDirectories: metadata[CopilotAgent._META_CWDS], + customizationDirectory, + }, + }; + } finally { + dbRef.dispose(); + } + } + + private async _storeWorkingDirectoryMetadataSnapshot(session: URI, metadata: IWorkingDirectoryMetadataSnapshot): Promise { + const dbRef = this._sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadataValues({ + [CopilotAgent._META_CWD]: metadata.workingDirectory ?? '', + [CopilotAgent._META_CWDS]: metadata.workingDirectories ?? '', + [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: metadata.customizationDirectory ?? '', + }); + } finally { + dbRef.dispose(); + } + } + /** * Parses the persisted ordered working-directory set. Prefers the JSON * `_META_CWDS` array when present and valid, otherwise falls back to the diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 50f83a3d115142..f0ee8e65822f62 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -40,7 +40,7 @@ import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; -import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; +import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot } from '../../common/agent.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta, type IToolSearchCandidate } from '../../common/meta/agentToolCallMeta.js'; @@ -492,6 +492,13 @@ export interface ICopilotAgentSessionOptions { readonly controlPlaneRpcTimeoutMs?: number; } +/** Keeps provider-owned state consistent with a live SDK working-directory mutation. */ +export interface ICopilotWorkingDirectoryChangeTransaction { + prepare(): Promise; + rollback(): Promise; + reconcile(authoritativeWorkingDirectory: URI): Promise; +} + /** * Lifecycle state of a {@link CopilotTurn}. * @@ -874,6 +881,7 @@ export class CopilotAgentSession extends Disposable { */ private readonly _currentTurn = this._register(new MutableDisposable()); private _resumingTurnAwaitingProviderStart: CopilotTurn | undefined; + private _abortingTurn: CopilotTurn | undefined; private _developmentRecoverableError: { readonly turnId: string; remainingFailures: number; readonly totalFailures: number } | undefined; private readonly _developmentErrorInjectionEnabled: boolean; private _dropLateRootTurnEvents = false; @@ -981,6 +989,8 @@ export class CopilotAgentSession extends Disposable { private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; + private _workingDirectoryMutationInProgress = false; + private _requiresRestartAfterWorkingDirectoryChange = false; private readonly _slashCommandProvider: CopilotSlashCommandProvider; /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ private _lastAppliedMode: CopilotSdkMode | undefined; @@ -1058,8 +1068,8 @@ export class CopilotAgentSession extends Disposable { private readonly _shellManager: ShellManager | undefined; /** Streams runtime-executed shell output into output-only (non-pty) terminal channels. */ private readonly _nonPtyShellTerminals: NonPtyShellTerminalStreams; - private readonly _workingDirectory: URI | undefined; - private readonly _customizationDirectory: URI | undefined; + private _workingDirectory: URI | undefined; + private _customizationDirectory: URI | undefined; private readonly _serverToolHost: IAgentServerToolHost | undefined; /** Bridges SDK-reported MCP server state into AHP customization actions. */ private readonly _mcpCustomizations: McpCustomizationController; @@ -1860,6 +1870,10 @@ export class CopilotAgentSession extends Disposable { return this._appliedSnapshot; } + get requiresRestartAfterWorkingDirectoryChange(): boolean { + return this._requiresRestartAfterWorkingDirectoryChange; + } + get requiresMcpLaunchConfigurationRefresh(): boolean { this._markMcpLaunchConfigurationDirty(); return this._mcpLaunchConfigurationDirty; @@ -2417,7 +2431,115 @@ export class CopilotAgentSession extends Disposable { // ---- session operations ------------------------------------------------- + async setWorkingDirectory(workingDirectory: URI, transaction: ICopilotWorkingDirectoryChangeTransaction): Promise { + if (!this._wrapper) { + throw new Error('Cannot change the working directory before the session is initialized'); + } + if (this.hasActiveTurn) { + throw new Error('Cannot change the working directory while a turn is active'); + } + if (this._workingDirectoryMutationInProgress) { + throw new Error('Cannot change the working directory while another working directory change is in progress'); + } + const previousWorkingDirectory = this._workingDirectory; + if (!previousWorkingDirectory) { + throw new Error('Cannot change the working directory for a session without an existing working directory'); + } + this._shellManager?.assertCanSetWorkingDirectory(); + + this._workingDirectoryMutationInProgress = true; + try { + try { + await transaction.prepare(); + } catch (prepareError) { + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new Error(`Working directory preparation failed: ${getErrorMessage(prepareError)}; failed to roll back the prepared working directory: ${getErrorMessage(rollbackError)}`); + } + throw prepareError; + } + + if (this.hasActiveTurn) { + const activeTurnError = new Error('Cannot change the working directory while a turn is active'); + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new Error(`${activeTurnError.message}; failed to roll back the prepared working directory: ${getErrorMessage(rollbackError)}`); + } + throw activeTurnError; + } + + let result: Awaited>; + try { + result = await this._wrapper.session.rpc.metadata.setWorkingDirectory({ workingDirectory: workingDirectory.fsPath }); + } catch (sdkError) { + try { + await transaction.rollback(); + } catch (rollbackError) { + throw new Error(`Failed to change the SDK working directory: ${getErrorMessage(sdkError)}; failed to roll back the prepared working directory: ${getErrorMessage(rollbackError)}`); + } + throw sdkError; + } + + const requestedUri = normalizePath(URI.file(workingDirectory.fsPath)); + const actualUri = normalizePath(URI.file(result.workingDirectory)); + let runtimeAlignmentError: string | undefined; + try { + const updateResult = await this._wrapper.session.rpc.options.update({ workingDirectory: actualUri.fsPath }); + if (!updateResult.success) { + runtimeAlignmentError = 'the SDK rejected the runtime working directory update'; + } + } catch (error) { + runtimeAlignmentError = `failed to update the SDK runtime working directory: ${getErrorMessage(error)}`; + } + if (extUriBiasedIgnorePathCase.isEqual(actualUri, requestedUri)) { + let shellAlignmentError: string | undefined; + try { + this._shellManager?.setWorkingDirectory(workingDirectory); + } catch (error) { + shellAlignmentError = `failed to align the local shell working directory: ${getErrorMessage(error)}`; + } finally { + this._workingDirectory = workingDirectory; + this._customizationDirectory = workingDirectory; + this._requiresRestartAfterWorkingDirectoryChange = true; + } + const alignmentErrors = [runtimeAlignmentError, shellAlignmentError].filter(isDefined); + if (alignmentErrors.length > 0) { + throw new AgentWorkingDirectoryChangedError(workingDirectory, `The SDK working directory changed to '${workingDirectory.fsPath}', but runtime alignment failed: ${alignmentErrors.join('; ')}`); + } + return; + } + + const mismatchError = new Error(`The SDK returned working directory '${result.workingDirectory}' instead of '${workingDirectory.fsPath}'`); + const alignmentErrors = runtimeAlignmentError ? [runtimeAlignmentError] : []; + try { + this._shellManager?.setWorkingDirectory(actualUri); + } catch (shellError) { + alignmentErrors.push(`failed to align the local shell working directory: ${getErrorMessage(shellError)}`); + } finally { + this._workingDirectory = actualUri; + this._customizationDirectory = actualUri; + this._requiresRestartAfterWorkingDirectoryChange = true; + } + try { + await transaction.reconcile(actualUri); + } catch (reconcileError) { + alignmentErrors.push(`failed to reconcile the authoritative working directory: ${getErrorMessage(reconcileError)}`); + } + if (alignmentErrors.length > 0) { + throw new AgentWorkingDirectoryChangedError(actualUri, `${mismatchError.message}; ${alignmentErrors.join('; ')}`); + } + throw new AgentWorkingDirectoryChangedError(actualUri, mismatchError.message); + } finally { + this._workingDirectoryMutationInProgress = false; + } + } + async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, hostInstructions?: readonly string[], clientContext = createUnknownAgentHostClientTelemetryContext(clientType), agentMergeTurn = false): Promise { + if (this._workingDirectoryMutationInProgress) { + throw new Error('Cannot start a turn while the working directory is changing'); + } this._resetAbortToken(); this._agentMergeTurn = agentMergeTurn; if (turnId && this._currentTurn.value?.id !== turnId) { @@ -3122,6 +3244,8 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); const abortingTurn = this._currentTurn.value; const resumingTurn = this._resumingTurnAwaitingProviderStart; + const abortTarget = abortingTurn ?? resumingTurn; + this._abortingTurn = abortTarget; if (abortingTurn) { this._dropLateRootTurnEvents = true; } @@ -3130,6 +3254,9 @@ export class CopilotAgentSession extends Disposable { try { await this._wrapper.session.abort(); } catch (error) { + if (this._abortingTurn === abortTarget) { + this._abortingTurn = undefined; + } this._resetAbortToken(); throw error; } @@ -5189,6 +5316,8 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onIdle(e => { this._logService.info(`[Copilot:${sessionId}] Session idle`); + const abortingTurn = this._abortingTurn; + this._abortingTurn = undefined; if (e.data.aborted) { this._resetAbortToken(); } @@ -5212,7 +5341,7 @@ export class CopilotAgentSession extends Disposable { // drop it before the provider starts. // - any other pending turn is a queued message started after the // abort; leave it open for its own non-abort idle. - if (e.data.aborted) { + if (e.data.aborted && (!abortingTurn || turn === abortingTurn)) { this._cancelActiveRepoInfoTelemetry(); if (turn.isRunning || turn === this._resumingTurnAwaitingProviderStart) { this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down cancelled turn ${turn.id}`); @@ -5227,6 +5356,13 @@ export class CopilotAgentSession extends Disposable { } return; } + if (e.data.aborted && !turn.isRunning) { + this._logService.trace(`[Copilot:${sessionId}] Idle from abort; leaving ${turn.state} replacement turn ${turn.id} open`); + return; + } + if (e.data.aborted) { + this._logService.trace(`[Copilot:${sessionId}] Idle from abort reached running replacement turn ${turn.id}; completing replacement`); + } if (turn === this._resumingTurnAwaitingProviderStart && !turn.providerTurnStarted) { this._logService.trace(`[Copilot:${sessionId}] Ignoring idle from the failed execution while resumed turn ${turn.id} awaits provider start`); return; diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 9c7e3e75b5f7f2..34868c18850066 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -8,19 +8,15 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.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, TerminalLifecycleStatus, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; import { parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { isZsh } from '../agentHostShellUtils.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; -import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { AgentHostSandboxEngine } from './agentHostSandboxEngine.js'; import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js'; // Re-exported for consumers (and tests) that historically imported these @@ -61,7 +57,9 @@ export class ShellManager extends Disposable { private readonly _shells = new Map(); private readonly _toolCallShells = new Map(); private _resolvedExecutable: Promise | undefined; - private _sandboxEngine: TerminalSandboxEngine | undefined; + private _sandboxEngine: AgentHostSandboxEngine | undefined; + private _workingDirectory: URI | undefined; + private _pendingShellCreations = 0; /** 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. */ @@ -72,16 +70,13 @@ export class ShellManager extends Disposable { constructor( private readonly _sessionUri: URI, - public readonly workingDirectory: URI | undefined, + 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(); + this._workingDirectory = workingDirectory; this._register(toDisposable(() => { for (const store of this._heldShellReleaseListeners.values()) { @@ -99,6 +94,32 @@ export class ShellManager extends Disposable { })); } + get workingDirectory(): URI | undefined { + return this._workingDirectory; + } + + /** Throws if shell activity prevents changing the working directory safely. */ + assertCanSetWorkingDirectory(): void { + if (this._busyShellIds.size > 0 || this._heldShellReleaseListeners.size > 0 || this._pendingShellCreations > 0) { + throw new Error('Cannot change the working directory while a shell is busy'); + } + } + + /** Re-anchors future shells and sandbox roots after safely discarding idle shell state. */ + setWorkingDirectory(workingDirectory: URI): void { + this.assertCanSetWorkingDirectory(); + + for (const shell of this._shells.values()) { + if (this._terminalManager.hasTerminal(shell.terminalUri)) { + this._terminalManager.disposeTerminal(shell.terminalUri); + } + } + this._shells.clear(); + this._toolCallShells.clear(); + this._workingDirectory = workingDirectory; + this._sandboxEngine?.setWorkingDirectory(workingDirectory); + } + /** * Resolves the session's shell executable via {@link IAgentHostTerminalManager.getDefaultShell} * and caches it so every tool call in the session uses the same binary @@ -119,22 +140,18 @@ export class ShellManager extends Disposable { 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, + const sandboxEngine = this._instantiationService.createInstance( + AgentHostSandboxEngine, sessionId, - this.workingDirectory, + this._workingDirectory, ); - this._register(engine); + this._register(sandboxEngine); this._register(toDisposable(() => { - void engine.cleanupTempDir().catch(err => this._logService.warn('[ShellManager] Sandbox temp dir cleanup failed', err)); + void sandboxEngine.engine.cleanupTempDir().catch(err => this._logService.warn('[ShellManager] Sandbox temp dir cleanup failed', err)); })); - this._sandboxEngine = engine; + this._sandboxEngine = sandboxEngine; } - return this._sandboxEngine; + return this._sandboxEngine.engine; } /** @@ -184,22 +201,27 @@ export class ShellManager extends Disposable { }; const shellDisplayName = shellType === 'bash' ? 'Bash' : 'PowerShell'; - const executable = await this.getResolvedExecutable(); - - await this._terminalManager.createTerminal({ - channel: terminalUri, - claim, - name: shellDisplayName, - cwd: cwd ?? this.workingDirectory?.fsPath, - }, { shell: executable, preventShellHistory: true, nonInteractive: true }); - - const shell: IManagedShell = { id, terminalUri, shellType, executable }; - this._shells.set(id, shell); - this._busyShellIds.add(id); - this._trackToolCall(toolCallId, id); - - this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri}, executable=${executable})`); - return this._makeReference(shell); + this._pendingShellCreations++; + try { + const executable = await this.getResolvedExecutable(); + + await this._terminalManager.createTerminal({ + channel: terminalUri, + claim, + name: shellDisplayName, + cwd: cwd ?? this.workingDirectory?.fsPath, + }, { shell: executable, preventShellHistory: true, nonInteractive: true }); + + const shell: IManagedShell = { id, terminalUri, shellType, executable }; + this._shells.set(id, shell); + this._busyShellIds.add(id); + this._trackToolCall(toolCallId, id); + + this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri}, executable=${executable})`); + return this._makeReference(shell); + } finally { + this._pendingShellCreations--; + } } private _makeReference(shell: IManagedShell): IReference { diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index c08a3cb4475b37..c2d6762d73b931 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -1196,10 +1196,11 @@ export function getPermissionDisplay(request: PermissionRequest, workingDirector permissionPath: path, }; } + const serverDisplay = sdkToolName ? getServerToolDisplay(sdkToolName, args) : undefined; return { - confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), - invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), - toolInput: args ? tryStringify(args) : tryStringify(request), + confirmationTitle: serverDisplay?.confirmationTitle ?? localize('copilot.permission.default.title', "Allow tool call?"), + invocationMessage: serverDisplay?.confirmationMessage ?? md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), + toolInput: serverDisplay?.hideConfirmationInput ? undefined : args ? tryStringify(args) : tryStringify(request), permissionKind: request.kind, permissionPath: path, }; diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index 2f153cecf73b81..e8957e3ccb4788 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -47,8 +47,8 @@ export const COPILOT_AGENT_HOST_SYSTEM_MESSAGE = { * Scratch/repoless guidance appended to a workspace-less chat's system message. * A workspace-less chat's working directory is a throwaway SCRATCH dir, not a * code repository — so this tells the agent not to treat it like a project, to - * stay read-only on real repos, and to delegate code changes to a dedicated - * session. Modeled on the GitHub app's `build_general_chat_system_message`. + * stay read-only on real repos, and to attach a workspace before project work. + * Modeled on the GitHub app's `build_general_chat_system_message`. */ export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ '', @@ -56,7 +56,8 @@ export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ '', '- Your working directory is a SCRATCH directory for running commands and saving throwaway artifacts — it is NOT a code repository. Do not treat it as a project to build, test, or commit.', '- If the user points you at a real repository, prefer read-only operations: read files, search code, and inspect git metadata (branch, log, diff, status) to answer questions. Avoid modifying files or running builds, tests, linters, or installs in their working copies.', - '- When the user wants code changes, test runs, or any work that modifies or executes against a real project, delegate it to a dedicated session rather than doing it here.', + '- When the task should continue in a real workspace and `set_workspace` is available, prefer attaching that workspace and continuing this same conversation. Do not create another session solely to move the work. Use `list_sessions` to discover a known workspace when needed, and never guess a path.', + '- Immediately before every `set_workspace` call, always use `ask_user` to confirm both the workspace and whether the work should be isolated, even if the user previously mentioned or requested those choices. Tool approval is separate and does not replace this confirmation.', '', ].join('\n'); diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts index 0af78e827b4946..f29a2d345729a4 100644 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts @@ -186,8 +186,8 @@ export function buildSandboxConfigForSdk( addCurrentWorkingDirectory: true, allowDevToolAccess: true, auth: { - git: false, - gh: false, + git: true, + gh: true, }, userPolicy: { filesystem: { @@ -198,7 +198,7 @@ export function buildSandboxConfigForSdk( }, network: { allowOutbound: typeof allowNetwork === 'boolean' ? allowNetwork : false, - allowLocalNetwork: true, + allowLocalNetwork: false, }, }, }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index dd8d5e53736ec5..a047e83ad2163a 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -20,7 +20,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { type IAgentService } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult } from '../common/agentHostExtensionProtocol.js'; +import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; import { isAgentDevContainerWorktreeHandle } from '../common/meta/agentDevContainerWorktreeMeta.js'; import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; @@ -1332,6 +1332,15 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return result; } + async requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise { + const result = await this._sendReverseRequest( + clientId, + RequestAgentHostWorkspaceTrustExtensionMethod, + request, + ); + return result.trusted === true; + } + /** Number of clients that currently have a live connection. */ private get _connectedClientCount(): number { let count = 0; diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 9d038c7aad43ce..a7e62bddae8c24 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -736,6 +736,17 @@ export class SessionDatabase implements ISessionDatabase { })); } + deleteMetadata(keys: readonly string[]): Promise { + return this._track(() => this._metadataSequencer.queue(async () => { + if (keys.length === 0) { + return; + } + const db = await this._ensureDb(); + const placeholders = keys.map(() => '?').join(','); + await dbRun(db, `DELETE FROM session_metadata WHERE key IN (${placeholders})`, [...keys]); + })); + } + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { return this._track(() => this._metadataSequencer.queue(async () => { const db = await this._ensureDb(); diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 37c591e843f1ab..885bd4dfaa716d 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -651,6 +651,9 @@ export const feedbackServerToolGroup: IServerToolGroup = { isEnabled(): boolean { return true; }, + isEnabledForSession(): boolean { + return true; + }, canRequireConfirmation(toolName): boolean { return feedbackToolRequiresConfirmation(toolName); }, diff --git a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts index 8c47629fbb8604..9150093cafa8c1 100644 --- a/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts @@ -62,6 +62,7 @@ export function createAgentMergeServerToolGroup(accessor?: IAgentMergeToolAccess return { definitions, isEnabled: toolName => accessor?.isEnabled() === true && definitions.some(definition => definition.name === toolName), + isEnabledForSession: () => true, execute: (_stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown) => { if (!accessor) { throw new Error('Agent Merge tools are not available without an Agent Merge controller.'); diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 4eda016fdecf91..b264a47d9deb4f 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -6,8 +6,16 @@ import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { parseRequiredSessionUriFromChatUri, type StringOrMarkdown, type ToolDefinition, type URI } from '../../common/state/sessionState.js'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; +export const IAgentHostServerToolService = createDecorator('agentHostServerToolService'); + +/** Injectable Agent Host server-tool registry and advertiser. */ +export interface IAgentHostServerToolService extends IAgentServerToolHost { + readonly _serviceBrand: undefined; +} + /** * Result of a server tool, passed to {@link IServerToolGroup.getDisplay} so the * owning group can tailor its past-tense message to what the tool returned. @@ -34,6 +42,12 @@ export interface IServerToolDisplay { readonly invocationMessage?: StringOrMarkdown; /** Past-tense message shown once the tool completes. When omitted, the provider reuses `invocationMessage`. */ readonly pastTenseMessage?: StringOrMarkdown; + /** Short title shown when the tool requires confirmation. */ + readonly confirmationTitle?: string; + /** Plain-language description of the decision shown when the tool requires confirmation. */ + readonly confirmationMessage?: StringOrMarkdown; + /** Whether the generic raw-input preview should be omitted from the confirmation. */ + readonly hideConfirmationInput?: boolean; } export interface IServerToolExecutionContext { @@ -72,6 +86,8 @@ export interface IServerToolGroup { readonly materializeDefinitions?: boolean; /** Whether a contributed tool is currently enabled for advertisement and execution. */ isEnabled(toolName: string): boolean; + /** Whether a contributed tool is supported by a specific session. */ + isEnabledForSession(toolName: string, sessionUri: URI): boolean; /** * Whether {@link toolName} (one of this group's {@link definitions}) can * ever prompt for confirmation. Providers exclude such tools from their @@ -126,7 +142,9 @@ export interface IServerToolGroup { * tool on a session's {@link SessionState.serverTools} so clients see them as * server-provided. */ -export class AgentServerToolHost implements IAgentServerToolHost { +export class AgentServerToolHost implements IAgentHostServerToolService { + + declare readonly _serviceBrand: undefined; /** Every name the host answers to — current and legacy — and its owning group. */ private readonly _groupByToolName = new Map(); @@ -181,9 +199,10 @@ export class AgentServerToolHost implements IAgentServerToolHost { const currentByName = new Map(group.definitions.map(definition => [definition.name, definition])); return materializedDefinitions .filter(definition => this._groupByToolName.get(definition.name) === group) + .filter(definition => !currentByName.has(definition.name) || group.isEnabledForSession(definition.name, sessionUri)) .map(definition => currentByName.get(definition.name) ?? definition); } - const definitions = group.definitions.filter(definition => group.isEnabled(definition.name)); + const definitions = group.definitions.filter(definition => group.isEnabled(definition.name) && group.isEnabledForSession(definition.name, sessionUri)); return isEphemeral ? definitions.filter(definition => definition.enabledForEphemeralSessions) : definitions; }); } @@ -250,6 +269,10 @@ export class AgentServerToolHost implements IAgentServerToolHost { } private _isEnabledForSession(group: IServerToolGroup, chatUri: URI, toolName: string, requestedToolName = toolName): boolean { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + if (!group.isEnabledForSession(toolName, sessionUri)) { + return false; + } const advertisedTools = this._stateManager.getSessionState(chatUri)?.serverTools; return advertisedTools ? advertisedTools.some(tool => tool.name === toolName) || group.legacyToolNames?.has(requestedToolName) === true diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts index aee5845e1b1687..0402f70c0dd8f1 100644 --- a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -125,6 +125,9 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce isEnabled(): boolean { return accessor?.isEnabled() === true; }, + isEnabledForSession(): boolean { + return true; + }, getDisplay(toolName, args, result): IServerToolDisplay | undefined { switch (toolName) { case ArtifactServerToolName.AddArtifactOrReference: { diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 2cddea6e377c72..dad63d53cdf6d7 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -5,13 +5,15 @@ import type { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import type { IAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { toAgentMessageDelegationMeta, type IAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { localize } from '../../../../nls.js'; import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; +import { ActionType } from '../../common/state/sessionActions.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; -import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, MessageKind, parseChatUri, PendingMessageKind, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -35,7 +37,7 @@ const maxCreatedChats = 25; /** Process-wide backstop against runaway `send_message` fan-out. */ const maxSentMessages = 50; -const sessionConfirmationToolNames: ReadonlySet = new Set([SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.SendMessage, SessionServerToolName.DeleteSession]); +const sessionConfirmationToolNames: ReadonlySet = new Set([SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.SendMessage, SessionServerToolName.DeleteSession]); const createSessionRelationshipValues = ['currentSession', 'independent'] as const; export type CreateSessionRelationship = typeof createSessionRelationshipValues[number]; @@ -86,6 +88,21 @@ const getCurrentSessionInputSchema: ToolDefinition['inputSchema'] = { properties: {}, }; +const setWorkspaceInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + workspaceFolder: { + type: 'string', + description: 'Absolute local folder path or file URI to set as the current session\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', + }, + isolation: { + type: 'boolean', + description: 'Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool.', + }, + }, + required: ['workspaceFolder', 'isolation'], +}; + const renameChatInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: { @@ -146,6 +163,13 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ inputSchema: getCurrentSessionInputSchema, annotations: { readOnlyHint: true }, }, + { + name: SessionServerToolName.SetWorkspace, + title: 'Set Workspace', + description: 'Set the current session\'s workspace when the task should continue in a workspace not yet attached to this session. This preserves the session, chat, and conversation history. Immediately before every call to this tool, always use the available user-input tool to ask the user to confirm both the workspace and whether the work should be isolated, even if the user previously mentioned or requested those choices. Tool approval is separate and does not replace this confirmation. Set `isolation` to true to create a managed Git worktree, or false to work directly in the folder. The workspace change is deferred until the current turn ends, then the host automatically continues the original task in the selected workspace. Make this the final tool call of the turn.', + inputSchema: setWorkspaceInputSchema, + annotations: { readOnlyHint: false }, + }, { name: SessionServerToolName.CreateSession, title: 'Create Session', @@ -163,7 +187,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.SendMessage, title: 'Send Message', - description: 'Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.', + description: 'Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.', inputSchema: sendMessageInputSchema, annotations: { readOnlyHint: false }, }, @@ -210,9 +234,10 @@ export type IResolvedCreateSessionArgs = { readonly model?: IAgentModelInfo; }; -/** Minimal dependency surface needed by the session server-tool group. */ -export interface ISessionServerToolAccessor { +/** AgentService-owned operations used by the session server-tool group. */ +export interface IAgentServiceSessionServerToolAccessor { readonly isActiveAgentTitleGenerationEnabled: () => boolean; + readonly canConvertWorkspace: (session: URI) => boolean; readonly listSessions: () => Promise; readonly getSession: (session: URI) => Promise; readonly createSession: (config: IAgentCreateSessionConfig) => Promise; @@ -231,6 +256,11 @@ export interface ISessionServerToolAccessor { readonly setSessionSpawnDepth: (session: URI, depth: number) => void; } +/** Complete dependency surface needed by the session server-tool group. */ +export interface ISessionServerToolAccessor extends IAgentServiceSessionServerToolAccessor { + readonly requestSessionWorkspaceUpdate: (chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean) => void; +} + export interface IRenameTitleResult { readonly title: string; } @@ -308,6 +338,13 @@ function getRequiredString(value: unknown, field: string, toolName: string): str return value; } +function getRequiredBoolean(value: unknown, field: string, toolName: string): boolean { + if (typeof value !== 'boolean') { + throw new Error(`Invalid ${toolName} input: ${field} must be a boolean.`); + } + return value; +} + function getOptionalString(value: unknown, field: string, toolName: string): string | undefined { if (value === undefined) { return undefined; @@ -378,6 +415,20 @@ function parseWorkspaceUri(workspace: string): URI | undefined { } } +/** Validates and resolves the workspace requested by `set_workspace`. */ +export function getSetWorkspaceArgs(rawArgs: unknown): { readonly workspaceFolder: URI; readonly isolation: boolean } { + const args = (rawArgs ?? {}) as { readonly workspaceFolder?: unknown; readonly isolation?: unknown }; + const input = getRequiredString(args.workspaceFolder, 'workspaceFolder', SessionServerToolName.SetWorkspace); + const workspaceFolder = parseWorkspaceUri(input); + if (!workspaceFolder || workspaceFolder.scheme !== Schemas.file || !workspaceFolder.path.startsWith('/') || workspaceFolder.query || workspaceFolder.fragment) { + throw new Error(`Invalid ${SessionServerToolName.SetWorkspace} input: workspaceFolder must be an absolute local path or file URI.`); + } + return { + workspaceFolder, + isolation: getRequiredBoolean(args.isolation, 'isolation', SessionServerToolName.SetWorkspace), + }; +} + function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMetadata[]): URI { const parsed = parseWorkspaceUri(workspace); for (const session of sessions) { @@ -1065,11 +1116,12 @@ export function getSendMessageArgs(rawArgs: unknown, sessions: readonly IAgentSe } /** - * Sends a message to an existing session/chat, starting a new turn there. + * Sends a message to an existing session/chat, starting a new turn there or + * queuing it behind the target chat's active or pending messages. * Refuses to target {@link currentChannel} (the chat channel the tool runs on) * to avoid a session trivially messaging itself in a loop. */ -export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI, sourceTurnId?: string): Promise { +export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI, sourceTurnId?: string, stateManager?: AgentHostStateManager): Promise { const sessions = await accessor.listSessions(); const { session, chat, chatId, message } = getSendMessageArgs(rawArgs, sessions); if (currentChannel && chat.toString() === URI.parse(currentChannel).toString()) { @@ -1077,17 +1129,33 @@ export async function applySendMessageTool(accessor: ISessionServerToolAccessor, } const sourceChat = currentChannel ? URI.parse(currentChannel) : undefined; const sourceSession = sourceChat ? currentSessionUri(sourceChat.toString()) : undefined; - await accessor.startPrompt(session, chat, message, sourceSession ? { + const delegation: IAgentMessageDelegationMeta | undefined = sourceSession ? { sourceSession: sourceSession.toString(), sourceChat: sourceChat?.toString(), ...(sourceTurnId !== undefined ? { sourceTurnId } : {}), - } : undefined); - return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId)); + } : undefined; + const targetState = stateManager?.getChatState(chat.toString()); + if (stateManager && (targetState?.activeTurn || targetState?.steeringMessage || targetState?.queuedMessages?.length)) { + const queuedMessage: Message = { + text: message, + origin: { kind: MessageKind.Agent }, + ...(delegation ? { _meta: toAgentMessageDelegationMeta(delegation) } : {}), + }; + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Queued, + id: generateUuid(), + message: queuedMessage, + }); + return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId), true); + } + await accessor.startPrompt(session, chat, message, delegation); + return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId), false); } /** Builds the model-facing `send_message` result. */ -export function formatSendMessageResult(openLink: string): string { - return `Message sent (${openLink}).`; +export function formatSendMessageResult(openLink: string, queued: boolean): string { + return `Message ${queued ? 'queued' : 'sent'} (${openLink}).`; } // --- get_session_context ----------------------------------------------------- @@ -1311,6 +1379,18 @@ export async function applyDeleteSessionTool(accessor: ISessionServerToolAccesso return `Deleted session ${session.toString()}. Reply with one short sentence confirming the session was deleted.`; } +/** Requests setting the workspace in place after the tool's active turn completes. */ +export function applySetWorkspaceTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, chat: URI, turnId: string | undefined): string { + if (!turnId) { + throw new Error(`${SessionServerToolName.SetWorkspace} must run from an active chat turn.`); + } + const { workspaceFolder, isolation } = getSetWorkspaceArgs(rawArgs); + accessor.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation); + return isolation + ? `An isolated worktree will be created from ${workspaceFolder.toString()} and set as the workspace after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the isolated workspace.` + : `Workspace will be set to ${workspaceFolder.toString()} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace.`; +} + function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServerToolDisplayResult): IServerToolDisplay | undefined { switch (toolName) { case SessionServerToolName.ListSessions: @@ -1364,6 +1444,29 @@ function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServe displayName: localize('toolName.getCurrentSession', "Get Current Session"), invocationMessage: localize('toolInvoke.getCurrentSession', "Get current session"), }; + case SessionServerToolName.SetWorkspace: + { + const input = args as { readonly workspaceFolder?: unknown; readonly isolation?: unknown } | undefined; + const workspaceFolder = typeof input?.workspaceFolder === 'string' + ? input.workspaceFolder + : localize('toolConfirm.setWorkspace.selectedWorkspace', "the selected workspace"); + const workspaceName = typeof input?.workspaceFolder === 'string' + ? basename(parseWorkspaceUri(input.workspaceFolder) ?? URI.file(input.workspaceFolder)) || workspaceFolder + : workspaceFolder; + const confirmationMessage = input?.isolation === true + ? localize('toolConfirm.setWorkspace.isolated', "Continue this session in {0} with changes isolated from the existing folder?", workspaceFolder) + : input?.isolation === false + ? localize('toolConfirm.setWorkspace.direct', "Continue this session in {0} and make changes directly in that folder?", workspaceFolder) + : localize('toolConfirm.setWorkspace.generic', "Continue this session in {0}?", workspaceFolder); + return { + displayName: localize('toolName.setWorkspace', "Set Workspace"), + invocationMessage: localize('toolInvoke.setWorkspace', "Setting workspace"), + pastTenseMessage: localize('toolComplete.setWorkspace', "Scheduled workspace change"), + confirmationTitle: localize('toolConfirm.setWorkspace.title', "Continue in {0}?", workspaceName), + confirmationMessage, + hideConfirmationInput: true, + }; + } case SessionServerToolName.DeleteSession: return { displayName: localize('toolName.deleteSession', "Delete Session"), @@ -1396,13 +1499,16 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess isEnabled(toolName: string): boolean { return toolName !== SessionServerToolName.RenameChat || accessor?.isActiveAgentTitleGenerationEnabled() !== false; }, + isEnabledForSession(toolName: string, sessionUri: ProtocolURI): boolean { + return toolName !== SessionServerToolName.SetWorkspace || accessor?.canConvertWorkspace(URI.parse(sessionUri)) === true; + }, canRequireConfirmation(toolName: string): boolean { return sessionToolRequiresConfirmation(toolName); }, getDisplay(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined { return getSessionToolDisplay(toolName, args, result); }, - async execute(_stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown): Promise { + async execute(stateManager: AgentHostStateManager, context, toolName: string, rawArgs: unknown): Promise { if (!accessor) { throw new Error(`Session server tool "${toolName}" cannot run: the group was built without a session accessor.`); } @@ -1418,6 +1524,9 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess const metadata = await accessor.getSession(currentSession); return serializeCurrentSession(currentSession, metadata ? [metadata] : []); } + case SessionServerToolName.SetWorkspace: { + return applySetWorkspaceTool(accessor, rawArgs, URI.parse(currentChannel), context.turnId); + } case SessionServerToolName.CreateSession: { const relationship = getCreateSessionRelationship(rawArgs); if (relationship === 'currentSession' && createdChatCount >= maxCreatedChats) { @@ -1448,7 +1557,7 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess if (sentMessageCount >= maxSentMessages) { throw new Error(`Refusing to send more than ${maxSentMessages} messages from server tools in this process.`); } - const result = await applySendMessageTool(accessor, rawArgs, currentChannel, context.turnId); + const result = await applySendMessageTool(accessor, rawArgs, currentChannel, context.turnId, stateManager); sentMessageCount++; return result; } diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 06dc2063b263c3..3f4b2d02ae59f7 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -63,6 +63,7 @@ export interface IAgentHostWorktreeIsolation extends IAgentHostWorktreePendingSt applyRestoreAnnouncement(sessionUri: URI, turns: readonly Turn[]): Promise; prepareSessionDeletion(sessionUri: URI, sessionId: string): Promise; removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise; + discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise; cleanupWorktreeOnArchive(sessionUri: URI, sessionId: string): Promise; recreateWorktreeOnUnarchive(sessionUri: URI, sessionId: string): Promise; readWorktreeMetadata(sessionUri: URI): Promise; @@ -905,9 +906,9 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI // first percentage arrives. onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut)); - await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); const worktreePath = URI.joinPath(worktreesRoot, getWorktreeName(newBranchName ?? selectedBranch, worktreeBranchPrefix)); await request.onWillCreate?.({ repositoryRoot, worktreePath, baseBranch, branchName: newBranchName ?? selectedBranch }); + await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => this._gitService.addWorktree(repositoryRoot, { @@ -1072,6 +1073,23 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return this._sequencer.queue(sessionId, () => this._removeSessionWorktree(sessionId, worktree)); } + async discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise { + await this.removeSessionWorktree(sessionId, worktree); + const dbRef = this._sessionDataService.openDatabase(sessionUri); + try { + await dbRef.object.deleteMetadata([ + WORKTREE_META_BRANCH, + WORKTREE_META_PATH, + WORKTREE_META_REPOSITORY_ROOT, + WORKTREE_META_CREATION_FAILURE, + LEGACY_WORKTREE_META_WORKING_DIRECTORY, + META_DIFF_BASE_BRANCH, + ]); + } finally { + dbRef.dispose(); + } + } + private async _removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise { this.clearPending(sessionId); if (!worktree) { @@ -1468,6 +1486,7 @@ export class NullAgentHostWorktreeIsolation implements IAgentHostWorktreeIsolati async applyRestoreAnnouncement(_sessionUri: URI, turns: readonly Turn[]): Promise { return turns; } async prepareSessionDeletion(_sessionUri: URI, _sessionId: string): Promise { return undefined; } async removeSessionWorktree(_sessionId: string, _worktree: ISessionWorktree | undefined): Promise { } + async discardSessionWorktree(_sessionUri: URI, _sessionId: string, _worktree: ISessionWorktree | undefined): Promise { } async cleanupWorktreeOnArchive(_sessionUri: URI, _sessionId: string): Promise { } async recreateWorktreeOnUnarchive(_sessionUri: URI, _sessionId: string): Promise { } async readWorktreeMetadata(_sessionUri: URI): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts index 2ae3b0ef95c0f9..f4ad17bdd489dd 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostEnablementService.test.ts @@ -12,7 +12,7 @@ import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../common/agentHostEnablement import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationOverrides } from '../../../configuration/common/configuration.js'; import { ChatAIDisabledSettingId } from '../../../chat/common/chatSettings.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; -import { COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../policy/common/copilotManagedSettings.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY, IManagedSettingsService, NullManagedSettingsService } from '../../../policy/common/copilotManagedSettings.js'; import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; class AgentHostTestConfigurationService extends TestConfigurationService { @@ -112,6 +112,31 @@ suite('AgentHostEnablementService', () => { }); }); + test('tracks bypass-only policy changes without changing the managed sandbox floor', () => { + let allowBypass: boolean | undefined; + const managedSettingsEmitter = disposables.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsEmitter.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ENABLED_KEY ? true : key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY ? allowBypass : undefined, + }; + const { service } = createService(false, true, managedSettingsService); + const enforcedChanges: boolean[] = []; + const bypassChanges: boolean[] = []; + disposables.add(autorun(reader => enforcedChanges.push(service.managedSandboxEnforced.read(reader)))); + disposables.add(autorun(reader => bypassChanges.push(service.managedSandboxAllowsBypass.read(reader)))); + + for (const value of [false, true, true, false, undefined]) { + allowBypass = value; + managedSettingsEmitter.fire(); + } + + assert.deepStrictEqual({ enforcedChanges, bypassChanges }, { + enforcedChanges: [true], + bypassChanges: [false, true, false], + }); + }); + test('tracks the effective managed sandbox floor', () => { let sandboxEnabled = false; const managedSettingsEmitter = disposables.add(new Emitter()); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index e6c96253bfb5ee..8d75939af585e7 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -94,6 +94,12 @@ export class TestSessionDatabase implements ISessionDatabase { } } + async deleteMetadata(keys: readonly string[]): Promise { + for (const key of keys) { + this._metadata.delete(key); + } + } + async setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { if (this._metadata.has(key)) { return false; diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 3906d696de5f08..19fa407d818004 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -14,10 +14,11 @@ import { observableValue } from '../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, AgentHostProtocolClient } from '../../browser/agentHostProtocolClient.js'; -import { getAgentHostExtensionInitializeResultMeta } from '../../common/agentHostExtensionProtocol.js'; +import { getAgentHostExtensionInitializeResultMeta, RequestAgentHostWorkspaceTrustExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js'; import { AgentHostPermissionMode, AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; @@ -84,6 +85,7 @@ import type { Implementation } from '../../common/state/protocol/common/commands import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; import type { IRemoteAgentHostReconnectPolicy } from '../../common/reconnectPolicy.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, type ResourceTrustRequestOptions } from '../../../workspace/common/workspaceTrust.js'; type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; type RootConfigValue = boolean | string | AgentHostTerminalAutoApproveRules | undefined; @@ -189,6 +191,11 @@ class TestProtocolTransport extends Disposable implements IProtocolTransport { this._onMessage.fire(message); } + fireExtensionRequest(id: number, method: string, params: Record): void { + // VS Code-private reverse requests intentionally are not part of the public AHP ProtocolMessage union. + this._onMessage.fire({ jsonrpc: '2.0', id, method, params } as unknown as ProtocolMessage); + } + fireClose(): void { this._onClose.fire(); } @@ -334,11 +341,40 @@ suite('AgentHostProtocolClient', () => { }; } - function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { + function createWorkspaceTrustServices(config?: { readonly trusted?: readonly URI[]; readonly requestResult?: boolean }) { + const trusted = new Set((config?.trusted ?? []).map(uri => uri.toString())); + const requests: URI[] = []; + const grants: URI[] = []; + const management = new class extends mock() { + override async getUriTrustInfo(uri: URI) { + return { uri, trusted: trusted.has(uri.toString()) }; + } + + override async setUrisTrust(uris: URI[], isTrusted: boolean): Promise { + for (const uri of uris) { + if (isTrusted) { + trusted.add(uri.toString()); + grants.push(uri); + } else { + trusted.delete(uri.toString()); + } + } + } + }(); + const request = new class extends mock() { + override async requestResourcesTrust(options: ResourceTrustRequestOptions): Promise { + requests.push(options.uri); + return config?.requestResult ?? true; + } + }(); + return { management, request, requests, grants }; + } + + function createClientForIdentity(identity: AgentHostResourceIdentity, transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService(), configurationService = new TestConfigurationService(), clientId?: string, clientInfo?: Implementation, telemetryService: ITelemetryService = NullTelemetryService, reconnectPolicy?: IRemoteAgentHostReconnectPolicy, workspaceTrust = createWorkspaceTrustServices()): { client: AgentHostProtocolClient; transport: TestProtocolTransport; configurationService: TestConfigurationService } { const options = loadEstimator !== undefined || clientId !== undefined || clientInfo !== undefined || reconnectPolicy !== undefined ? { loadEstimator, clientId, clientInfo, reconnectPolicy } : undefined; - const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService)); + const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService, workspaceTrust.management, workspaceTrust.request)); return { client, transport, configurationService }; } @@ -1183,6 +1219,7 @@ suite('AgentHostProtocolClient', () => { test('forwards the actual telemetry service restriction during initialization and config sync', async () => { const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.RemoteExtensionHost)); const configurationService = new TestConfigurationService(); + const workspaceTrust = createWorkspaceTrustServices(); const client = disposables.add(new AgentHostProtocolClient( 'test.example:1234', transport, @@ -1191,6 +1228,8 @@ suite('AgentHostProtocolClient', () => { createPermissionService(), configurationService, NullTelemetryService, + workspaceTrust.management, + workspaceTrust.request, )); const connectPromise = client.connect(); @@ -1728,6 +1767,133 @@ suite('AgentHostProtocolClient', () => { await rejected; }); + suite('reverse workspace trust', () => { + + test('uses the standard workspace trust request', async () => { + const workspaceTrust = createWorkspaceTrustServices({ requestResult: false }); + const { transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + workspaceTrust, + ); + const workspace = URI.file('/workspace/project'); + + transport.fireExtensionRequest(51, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: workspace.toString(), + }); + await timeout(0); + + assert.deepStrictEqual({ + requests: workspaceTrust.requests.map(uri => uri.toString()), + response: transport.sentMessages.pop(), + }, { + requests: [workspace.toString()], + response: { + jsonrpc: '2.0', + id: 51, + result: { trusted: false }, + }, + }); + }); + + test('inherits trust for a validated managed worktree', async () => { + const parent = URI.file('/workspace/project'); + const worktree = URI.file('/workspace/project.worktrees/feature'); + const workspaceTrust = createWorkspaceTrustServices({ trusted: [parent] }); + const { transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + workspaceTrust, + ); + + transport.fireExtensionRequest(52, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: worktree.toString(), + trustedParent: parent.toString(), + }); + await timeout(0); + + assert.deepStrictEqual({ + requests: workspaceTrust.requests, + grants: workspaceTrust.grants.map(uri => uri.toString()), + response: transport.sentMessages.pop(), + }, { + requests: [], + grants: [worktree.toString()], + response: { + jsonrpc: '2.0', + id: 52, + result: { trusted: true }, + }, + }); + }); + + test('rejects invalid workspace trust resources', async () => { + const workspaceTrust = createWorkspaceTrustServices({ trusted: [URI.file('/workspace/project')] }); + const { transport } = createClientForIdentity( + LOCAL_AGENT_HOST_RESOURCE_IDENTITY, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + workspaceTrust, + ); + + transport.fireExtensionRequest(53, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: 'https://example.com/project', + }); + transport.fireExtensionRequest(54, RequestAgentHostWorkspaceTrustExtensionMethod, { + workspace: URI.file('/workspace/unrelated').toString(), + trustedParent: URI.file('/workspace/project').toString(), + }); + await timeout(0); + + assert.deepStrictEqual({ + requests: workspaceTrust.requests, + grants: workspaceTrust.grants, + responses: transport.sentMessages.splice(-2), + }, { + requests: [], + grants: [], + responses: [{ + jsonrpc: '2.0', + id: 53, + error: { + code: -32000, + message: 'Workspace must be an absolute file URI', + }, + }, { + jsonrpc: '2.0', + id: 54, + error: { + code: -32000, + message: 'Workspace is not a managed worktree under the trusted parent', + }, + }], + }); + }); + }); + suite('reverse permission gating', () => { test('remote local address does not receive trusted local access', async () => { @@ -2317,8 +2483,9 @@ suite('AgentHostProtocolClient', () => { transports.push(t); return t; }; + const workspaceTrust = createWorkspaceTrustServices(); const client = disposables.add(new AgentHostProtocolClient( - 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, + 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, workspaceTrust.management, workspaceTrust.request, )); return { client, transports }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index 3af4932ff97e86..3b297e5914c412 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -213,6 +213,18 @@ suite('AgentHostPromptRegistry', () => { }); suite('workspace-less scratch/repoless wiring', () => { + test('prefers attaching a workspace over creating a replacement session', () => { + assert.deepStrictEqual({ + usesSetWorkspace: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS.includes('`set_workspace` is available, prefer attaching that workspace and continuing this same conversation'), + avoidsReplacementSession: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS.includes('Do not create another session solely to move the work'), + requiresConfirmation: COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS.includes('Immediately before every `set_workspace` call, always use `ask_user` to confirm both the workspace and whether the work should be isolated'), + }, { + usesSetWorkspace: true, + avoidsReplacementSession: true, + requiresConfirmation: true, + }); + }); + test('appends the scratch instructions to the default config for a workspace-less chat', () => { const registry = new AgentHostPromptRegistry(); assert.deepStrictEqual( diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index fc376767808d61..53fe7a1251c14d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -357,7 +357,7 @@ suite('AgentHostStateManager', () => { addedWorkingDirectories: added?.type === NotificationType.SessionAdded ? added.summary.workingDirectories : undefined, }, { status: SessionStatus.InProgress, - project: persisted.project, + project: provisional.project, workingDirectories: persisted.workingDirectories, addedStatus: SessionStatus.InProgress, addedProject: persisted.project, @@ -1988,6 +1988,34 @@ suite('AgentHostStateManager', () => { ); }); }); + + test('SessionSummaryNotifier serializes activity clearing as null', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + manager.createSession(makeSessionSummary()); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(notification => notifications.push(notification))); + + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActivityChanged, + activity: 'Setting up workspace', + }); + await new Promise(resolve => setTimeout(resolve, 150)); + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionActivityChanged, + activity: undefined, + }); + await new Promise(resolve => setTimeout(resolve, 150)); + + const summaryChanges = notifications + .filter(notification => notification.type === NotificationType.SessionSummaryChanged) + .map(notification => JSON.parse(JSON.stringify(notification)) as SessionSummaryChangedParams); + assert.deepStrictEqual(summaryChanges.map(notification => notification.changes.activity), [ + 'Setting up workspace', + null, + ]); + }); + }); }); // Exercises the opaque, agent-owned `providerData` blob supplied to restored diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index b6117fcdd934cd..52b9cb5b3d92c8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -30,6 +30,7 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -37,6 +38,7 @@ import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../n import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; @@ -256,10 +258,18 @@ suite('AgentSideEffects — tool call telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], + [ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 6d6ae3f25cf3ac..9ff069c8a828c9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -30,6 +30,7 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -42,6 +43,7 @@ import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker, TURN_ACTIVITY_NONE, TURN_HANG_THRESHOLD_MS } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; import { IAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; import { createNoopGitStateService, createNullSessionDataService } from '../common/sessionTestHelpers.js'; @@ -210,6 +212,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { hasSeenClient: clientId => clientId === 'test', isClientConnected: clientId => clientId === 'test', getConnectedClientTransportCounts: () => new Map([['test', 1]]), + requestWorkspaceTrust: async () => true, })); const sharedLocalTurns = new AgentHostLocalTurns(sessionDataService, logService); const services = new ServiceCollection( @@ -225,10 +228,18 @@ suite('AgentSideEffects — turn hang telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, clientConnections], + [ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); @@ -506,6 +517,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { hasSeenClient: clientId => clientId === 'connected-client', isClientConnected: clientId => clientId === 'connected-client', getConnectedClientTransportCounts: () => new Map([['connected-client', 1]]), + requestWorkspaceTrust: async () => true, })); const diagnosticAgent = disposables.add(new MockAgent('copilotcli')); diagnosticAgent.getTurnDiagnosticSnapshot = () => ({ diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts new file mode 100644 index 00000000000000..ce02eb9346ac84 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostTurnService.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IAgentHostChatContributions, ITurnEnd } from '../../common/agentHostChatContributionsService.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { buildDefaultChatUri, MessageKind, SessionStatus, TurnState } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostTurnService } from '../../node/agentHostTurnService.js'; + +suite('AgentHostTurnService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness() { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const turnEnds: ITurnEnd[] = []; + const contributions = new class extends mock() { + override turnEnd(turn: ITurnEnd): void { + turnEnds.push(turn); + } + }(); + const instantiationService = new class extends mock() { }(); + const service = new AgentHostTurnService(stateManager, contributions, instantiationService); + const session = URI.parse('copilot:/deferred-turn'); + const chat = URI.parse(buildDefaultChatUri(session)); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Deferred turn', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + return { service, stateManager, session, chat, turnEnds }; + } + + test('keeps a deferred turn active until it is failed', () => { + const harness = createHarness(); + const message = { text: 'Setting up workspace', origin: { kind: MessageKind.SystemNotification } } as const; + + const deferred = harness.service.beginDeferredTurnMessage(harness.chat, message); + const active = harness.stateManager.getChatState(harness.chat.toString()); + const failed = harness.service.failDeferredTurnMessage(harness.chat, deferred, { + errorType: 'workspaceConversionFailed', + message: 'Workspace setup failed', + }); + const ended = harness.stateManager.getChatState(harness.chat.toString()); + const endedTurn = ended?.turns.at(-1); + + assert.deepStrictEqual({ + activeTurnId: active?.activeTurn?.id, + activeMessage: active?.activeTurn?.message, + activeStatus: active?.status, + failed, + endedTurn: endedTurn && { + id: endedTurn.id, + message: endedTurn.message, + responseParts: endedTurn.responseParts, + state: endedTurn.state, + }, + turnEnds: harness.turnEnds, + }, { + activeTurnId: deferred.turnId, + activeMessage: message, + activeStatus: SessionStatus.InProgress, + failed: true, + endedTurn: { + id: deferred.turnId, + message, + responseParts: [{ + kind: 'error', + error: { + errorType: 'workspaceConversionFailed', + message: 'Workspace setup failed', + }, + }], + state: TurnState.Error, + }, + turnEnds: [{ + session: harness.session.toString(), + channel: harness.chat.toString(), + turnId: deferred.turnId, + reason: { + kind: 'error', + error: { + errorType: 'workspaceConversionFailed', + message: 'Workspace setup failed', + }, + resumable: false, + }, + }], + }); + }); + + test('does not continue a deferred turn after cancellation', () => { + const harness = createHarness(); + const deferred = harness.service.beginDeferredTurnMessage(harness.chat, { + text: 'Setting up workspace', + origin: { kind: MessageKind.SystemNotification }, + }); + harness.stateManager.dispatchServerAction(harness.chat.toString(), { + type: ActionType.ChatTurnCancelled, + turnId: deferred.turnId, + duration: 1, + }); + + const continued = harness.service.continueDeferredTurnMessage(harness.chat, deferred, { + text: 'Continue the task', + origin: { kind: MessageKind.SystemNotification }, + }); + const replacement = harness.service.beginDeferredTurnMessage(harness.chat, { + text: 'Replacement turn', + origin: { kind: MessageKind.SystemNotification }, + }); + + assert.deepStrictEqual({ + continued, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + }, { + continued: false, + activeTurnId: replacement.turnId, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 82a141b84ff7a9..7869a5b101f12a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -32,6 +32,7 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { AgentHostChatContributions } from '../../node/agentHostChatContributionsService.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -39,6 +40,7 @@ import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter } from '../../n import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostClientConnectionService, IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; @@ -236,10 +238,18 @@ suite('AgentSideEffects — turn tracker telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], + [ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); services.set(IAgentHostSessionTitleController, disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService }, logService))); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => agent)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f439c8c5ad007e..32a44e14179dce 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -2803,6 +2803,7 @@ suite('AgentService (node dispatcher)', () => { unsupported: undefined, }); }); + }); suite('createSession', () => { @@ -7608,6 +7609,22 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(await db.getChatDraft(chat), expected); } + test('refuses to restore a workspace-conversion quarantine before materializing the provider', async () => { + const database = new TestSessionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id }); + await database.setMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, 'true'); + getStateManager(svc).deleteSession(session.toString()); + + await assert.rejects( + svc.restoreSession(session), + /could not be detached from an untrusted working directory/, + ); + assert.strictEqual(getStateManager(svc).getSessionState(session.toString()), undefined); + }); + test('marks only an explicit restore as an activating metadata read', async () => { class LazyMetadataAgent extends MockAgent { ambientReads = 0; diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 95bfea811c8efa..8e3247bdb60e1d 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -216,6 +216,7 @@ export function createTestAgentService( options, accessor, instantiationService, + services, logService, sessionDataService, foundation, diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 03fac7f9484045..c63233f6b41e1c 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -51,9 +51,11 @@ import { IAgentHostProviderService } from '../../node/agentHostProviderService.j import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type IAgentHostAskQuestionsToolInvokedEvent } from '../../node/agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from '../../node/agentHostTurnTracker.js'; +import { AgentHostTurnService, IAgentHostTurnService } from '../../node/agentHostTurnService.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; @@ -178,6 +180,13 @@ function createTestSideEffects( [IAgentHostWorktreeIsolation, new NoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], ); + services.set(ISessionWorkspaceConversionService, { + _serviceBrand: undefined, + requestSessionWorkspaceUpdate: () => { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }); const titleController = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: options.sessionDataService, isActiveAgentTitleGenerationEnabled: () => configService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true, @@ -187,6 +196,7 @@ function createTestSideEffects( const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions: IAgentHostChatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); services.set(IAgentHostChatContributions, chatContributions); + services.set(IAgentHostTurnService, new AgentHostTurnService(stateManager, chatContributions, instantiationService)); const telemetryReporter = new AgentHostTelemetryReporter(telemetryService); services.set(IAgentHostTelemetryReporter, telemetryReporter); const turnTracker = disposables.add(instantiationService.createInstance(AgentHostTurnTracker)); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 13c33567e8a619..71ae61375b3006 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -43,6 +43,8 @@ import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/loca import { registerBuiltInChatContributions } from '../../node/chatContributions/builtInChatContributions.js'; import { LocalCommandContribution } from '../../node/chatContributions/localCommand/localCommandContribution.js'; import { QueueDrainContribution } from '../../node/chatContributions/queueDrain/queueDrainContribution.js'; +import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { SessionTitleContribution } from '../../node/chatContributions/sessionTitle/sessionTitleContribution.js'; import { SideChatContribution } from '../../node/chatContributions/sideChat/sideChatContribution.js'; import { TurnDelegationContribution } from '../../node/chatContributions/turnDelegation/turnDelegationContribution.js'; @@ -832,6 +834,13 @@ function createBuiltInContributions(disposables: ReturnType { }, + isPending: () => false, + cancel: () => { }, + updateSessionWorkspace: async () => { observed?.push('sessionWorkspaceConversion'); }, + }); services.set(IAgentHostSessionTitleController, new RecordingTitleController(observed, enableSendInstructions ? 'rename instruction' : undefined)); const queueAgent = new MockAgent(); services.set(IAgentHostProviderService, createTestAgentHostProviderService(() => queueAgent)); @@ -875,6 +884,14 @@ function createQueueDrainContributions(disposables: ReturnType { }, + isPending: () => conversionPending, + cancel: () => { }, + updateSessionWorkspace: async () => { }, + }); const mockAgent = new MockAgent(); let agent: MockAgent | undefined = mockAgent; const pendingMessages: (PendingMessage | undefined)[] = []; @@ -900,8 +917,9 @@ function createQueueDrainContributions(disposables: ReturnType admitted.push({ channel: options.turnChannel, message: options.message, clientId: options.senderClientId, hostLaunchKind: options.clientContext.hostLaunchKind }), })); disposables.add(service.registerContribution(LocalCommandContribution as unknown as IConstructorSignature & { readonly id: string })); + disposables.add(service.registerContribution(SessionWorkspaceConversionContribution as unknown as IConstructorSignature & { readonly id: string })); disposables.add(service.registerContribution(QueueDrainContribution as unknown as IConstructorSignature & { readonly id: string })); - return { service, stateManager, session, chat, pendingMessages, admitted, titleController, telemetryService, clearAgent: () => agent = undefined }; + return { service, stateManager, session, chat, pendingMessages, admitted, titleController, telemetryService, clearAgent: () => agent = undefined, setConversionPending: (pending: boolean) => conversionPending = pending }; } function appliedClientAction(channel: string, session: string, action: IAppliedClientAction['action'], clientId = 'client'): IAppliedClientAction { @@ -1098,6 +1116,32 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual([active.admitted, steering.admitted, empty.admitted], [[], [], []]); }); + test('queue drain waits while session workspace conversion is pending', () => { + const queue = createQueueDrainContributions(disposables); + queue.stateManager.dispatchServerAction(queue.chat, queuedMessage('queued', 'queued')); + queue.setConversionPending(true); + + queue.service.turnEnd({ session: queue.session, channel: queue.chat, turnId: 'conversion-turn', reason: { kind: 'success' } }); + const admission = queue.service.incomingRequest(incomingRequest(queue.session, queue.chat)); + + assert.deepStrictEqual({ + admitted: queue.admitted, + queuedMessages: queue.stateManager.getSessionState(queue.chat)?.queuedMessages?.map(message => message.message.text), + admission, + }, { + admitted: [], + queuedMessages: ['queued'], + admission: { + kind: 'reject', + error: { + errorType: 'workspaceConversionPending', + message: 'Wait for workspace setup to finish before sending another message.', + }, + stage: 'validation', + }, + }); + }); + test('queue drain captures senders, handles pending actions, and honors reordering', () => { const queue = createQueueDrainContributions(disposables); queue.stateManager.dispatchServerAction(queue.chat, { @@ -1263,7 +1307,7 @@ suite('AgentHostChatContributions', () => { const contributions = createBuiltInContributions(disposables, observed); contributions.service.turnEnd(turnEnd('built-in-order')); - assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); + assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'sessionWorkspaceConversion', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); }); test('resumable errors defer checkpoint capture until the logical turn ends', () => { diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 20c3ddb68d7b9d..42012b007a6a26 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -1305,8 +1305,17 @@ suite('ClaudeAgent', () => { const { agent } = createTestContext(disposables); const desc = agent.getDescriptor(); assert.deepStrictEqual( - { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0 }, - { provider: 'claude', displayName: 'Claude', hasDescription: true }, + { provider: desc.provider, displayName: desc.displayName, hasDescription: desc.description.length > 0, agentHostCapabilities: agent.agentHostCapabilities }, + { provider: 'claude', displayName: 'Claude', hasDescription: true, agentHostCapabilities: { workspaceConversion: false } }, + ); + }); + + test('setWorkingDirectory rejects because Claude does not advertise workspace conversion', async () => { + const { agent } = createTestContext(disposables); + + await assert.rejects( + () => agent.setWorkingDirectory(URI.parse('claude:/chat'), URI.parse('claude:/session'), URI.file('/workspace')), + /Claude does not support changing the working directory/, ); }); @@ -6880,6 +6889,10 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { name: 'viewUnreviewedComments', description: 'View unreviewed comments', inputSchema: { type: 'object', properties: {} }, + }, { + name: 'set_workspace', + description: 'Set workspace', + inputSchema: { type: 'object', properties: {} }, }]; readonly toolNames = this.definitions.map(definition => definition.name); confirmationRequiredForSession = false; @@ -6996,6 +7009,40 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { }); }); + test('set_workspace uses a plain-language confirmation without raw input', async () => { + const host = new FakeServerToolHost(); + host.confirmationRequiredForSession = true; + const { ctx, canUseTool } = await materialize(undefined, host); + const signals: AgentSignal[] = []; + disposables.add(ctx.agent.onDidChatProgress(signal => signals.push(signal))); + + const input = { workspaceFolder: '/workspace/app', isolation: true }; + const resultPromise = canUseTool('mcp__host__set_workspace', input, makeOptions('tu_set_workspace')); + await tick(); + ctx.agent.respondToPermissionRequest('tu_set_workspace', true); + const confirmation = signals.find(signal => signal.kind === 'pending_confirmation'); + + assert.deepStrictEqual({ + result: await resultPromise, + confirmation: confirmation?.kind === 'pending_confirmation' ? { + displayName: confirmation.state.displayName, + invocationMessage: confirmation.state.invocationMessage, + toolInput: confirmation.state.toolInput, + confirmationTitle: confirmation.state.confirmationTitle, + permissionKind: confirmation.permissionKind, + } : undefined, + }, { + result: { behavior: 'allow', updatedInput: input }, + confirmation: { + displayName: 'Set Workspace', + invocationMessage: 'Continue this session in /workspace/app with changes isolated from the existing folder?', + toolInput: undefined, + confirmationTitle: 'Continue in app?', + permissionKind: 'mcp', + }, + }); + }); + // Tests 3 and 4 (bypassPermissions / acceptEdits auto-allow) intentionally // omitted: the SDK auto-approves under those modes BEFORE invoking // `canUseTool`, so there is no host-side branch to exercise. See diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index dc9f5abc7d256a..61fd1575cb4b39 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -332,7 +332,22 @@ suite('CodexAgent createChat', () => { test('advertises chat fork and side-chat support', async () => { const agent = await createAgent(disposables); - assert.deepStrictEqual(agent.getDescriptor().capabilities?.multipleChats, { fork: true, sideChat: true }); + assert.deepStrictEqual({ + multipleChats: agent.getDescriptor().capabilities?.multipleChats, + agentHostCapabilities: agent.agentHostCapabilities, + }, { + multipleChats: { fork: true, sideChat: true }, + agentHostCapabilities: { workspaceConversion: false }, + }); + }); + + test('setWorkingDirectory rejects because Codex does not advertise workspace conversion', async () => { + const agent = await createAgent(disposables); + + await assert.rejects( + () => agent.setWorkingDirectory(URI.parse('codex:/chat'), URI.parse('codex:/session'), URI.file('/workspace')), + /Codex does not support changing the working directory/, + ); }); test('fresh: binds the exact target chat during creation, never leaving the runtime unbound', async () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 37b5e0c5720111..bb6887b953d88f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -23,6 +23,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { FileService } from '../../../files/common/fileService.js'; import { IFileService, type IStat } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -388,10 +389,51 @@ class TestCopilotApiService implements ICopilotApiService { } } +class TestSessionDatabase extends SessionDatabase { + private _metadataWriteFailure: { readonly key: string; readonly error: Error } | undefined; + private _metadataWriteGate: { readonly key: string; readonly wait: Promise; readonly entered: DeferredPromise } | undefined; + readonly metadataWrites: { readonly key: string; readonly value: string }[] = []; + + failNextMetadataWrite(key: string, error: Error): void { + this._metadataWriteFailure = { key, error }; + } + + gateNextMetadataWrite(key: string, wait: Promise, entered: DeferredPromise): void { + this._metadataWriteGate = { key, wait, entered }; + } + + override async setMetadata(key: string, value: string): Promise { + this.metadataWrites.push({ key, value }); + await this._beforeMetadataWrite([key]); + await super.setMetadata(key, value); + } + + override async setMetadataValues(values: Readonly>): Promise { + const entries = Object.entries(values); + this.metadataWrites.push(...entries.map(([key, value]) => ({ key, value }))); + await this._beforeMetadataWrite(entries.map(([key]) => key)); + await super.setMetadataValues(values); + } + + private async _beforeMetadataWrite(keys: readonly string[]): Promise { + if (this._metadataWriteGate && keys.includes(this._metadataWriteGate.key)) { + const gate = this._metadataWriteGate; + this._metadataWriteGate = undefined; + gate.entered.complete(); + await gate.wait; + } + if (this._metadataWriteFailure && keys.includes(this._metadataWriteFailure.key)) { + const error = this._metadataWriteFailure.error; + this._metadataWriteFailure = undefined; + throw error; + } + } +} + class TestSessionDataService extends Disposable implements ISessionDataService { declare readonly _serviceBrand: undefined; - private readonly _databases = new Map(); + private readonly _databases = new Map(); readonly openedSessions: string[] = []; getSessionDataDir(session: URI): URI { return URI.from({ scheme: 'test', path: `/session-data/${AgentSession.id(session)}` }); } @@ -402,7 +444,7 @@ class TestSessionDataService extends Disposable implements ISessionDataService { this.openedSessions.push(sessionId); let db = this._databases.get(sessionId); if (!db) { - db = this._register(new SessionDatabase(':memory:')); + db = this._register(new TestSessionDatabase(':memory:')); this._databases.set(sessionId, db); } return { object: db, dispose: () => { } }; @@ -413,6 +455,24 @@ class TestSessionDataService extends Disposable implements ISessionDataService { return db ? { object: db, dispose: () => { } } : undefined; } + failNextMetadataWrite(session: URI, key: string, error: Error): void { + const db = this._databases.get(AgentSession.id(session)); + assert.ok(db, `No database exists for ${session.toString()}`); + db.failNextMetadataWrite(key, error); + } + + gateNextMetadataWrite(session: URI, key: string, wait: Promise, entered: DeferredPromise): void { + const db = this._databases.get(AgentSession.id(session)); + assert.ok(db, `No database exists for ${session.toString()}`); + db.gateNextMetadataWrite(key, wait, entered); + } + + metadataWrites(session: URI): readonly { readonly key: string; readonly value: string }[] { + const db = this._databases.get(AgentSession.id(session)); + assert.ok(db, `No database exists for ${session.toString()}`); + return db.metadataWrites; + } + deleteSessionData(): Promise { return Promise.resolve(); } readonly onWillDeleteSessionData = Event.None; cleanupOrphanedData(): Promise { return Promise.resolve(); } @@ -612,6 +672,9 @@ interface ICredentialUpdateSession { class MockCopilotSession { readonly sessionId = 'test-session-1'; + readonly workingDirectoryCalls: string[] = []; + readonly workingDirectoryErrors: Array = []; + readonly workingDirectoryResults: string[] = []; readonly rpc = { eventLog: { registerInterest: async () => ({ handle: 'sampling-interest' }), @@ -626,6 +689,16 @@ class MockCopilotSession { permissions: { setMode: async ({ mode }: { mode: PermissionMode }) => ({ success: true, mode }), }, + metadata: { + setWorkingDirectory: async ({ workingDirectory }: { workingDirectory: string }) => { + this.workingDirectoryCalls.push(workingDirectory); + const error = this.workingDirectoryErrors.shift(); + if (error) { + throw error; + } + return { workingDirectory: this.workingDirectoryResults.shift() ?? workingDirectory }; + }, + }, }; private readonly _handlers = new Set(); private readonly _typedHandlers = new Map) => void>>(); @@ -770,6 +843,12 @@ class TestProxyResolver implements IAgentHostProxyResolver { readonly fetch: typeof globalThis.fetch = (input, init) => globalThis.fetch(input, init); } +class TestDiskFileSystemProvider extends DiskFileSystemProvider { + override watch(): IDisposable { + return Disposable.None; + } +} + class ResumePathCopilotAgent extends CopilotAgent { constructor( private readonly _copilotClient: ITestCopilotClient, @@ -974,16 +1053,16 @@ function createTestAgent(disposables: Pick, options?: { type CopilotCreateSessionOptions = Parameters[0]; -function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { +function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot; readonly workingDirectory?: URI; readonly additionalDirectories?: readonly URI[] }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); - const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); + const shellManager = instantiationService.createInstance(ShellManager, sessionUri, options?.workingDirectory); let createOptions: CopilotCreateSessionOptions | undefined; const mockSession = options?.mockSession ?? new MockCopilotSession(); const agentInternals = (agent as unknown as { _getOrCreateActiveClient: (session: URI, directory: URI | undefined) => { readonly toolSet: ActiveClientToolSet }; _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown) => CopilotAgentSession; }); - const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, undefined); + const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, options?.workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { @@ -998,14 +1077,15 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic // needs an isolated registry passes its own. activeClientToolSet: options?.activeClientToolSet ?? activeClient.toolSet, sessionId: 'test-session-1', - workingDirectory: undefined, + workingDirectory: options?.workingDirectory, + additionalDirectories: options?.additionalDirectories, resolvedAgentName: undefined, snapshot: options?.snapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager, githubToken: 'token', model: undefined, }; - return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), activeClient, createOptions: () => createOptions }; + return { session: agentInternals._createAgentSession(launchPlan, options?.workingDirectory, activeClient), activeClient, createOptions: () => createOptions }; } function withoutUndefinedProperties(metadata: IAgentChatMetadata): Record { @@ -1481,11 +1561,17 @@ suite('CopilotAgent', () => { test('advertises Copilot as its display name', async () => { const agent = createTestAgent(disposables); try { - assert.deepStrictEqual(agent.getDescriptor(), { - provider: 'copilotcli', - displayName: 'Copilot', - description: 'Copilot SDK agent running in the local agent host process', - capabilities: { multipleChats: { fork: true, sideChat: true } }, + assert.deepStrictEqual({ + descriptor: agent.getDescriptor(), + agentHostCapabilities: agent.agentHostCapabilities, + }, { + descriptor: { + provider: 'copilotcli', + displayName: 'Copilot', + description: 'Copilot SDK agent running in the local agent host process', + capabilities: { multipleChats: { fork: true, sideChat: true } }, + }, + agentHostCapabilities: { workspaceConversion: true }, }); } finally { await disposeAgent(agent); @@ -3367,6 +3453,1062 @@ suite('CopilotAgent', () => { meta: [repoA.toString()], }); }); + + test('changes an exact live default chat from a bare owning session URI and persists provider-owned working-directory state', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const originalSession = getPeerChatStub(agent, chat); + const originalSdkSessionId = chatBackings(agent).get(chat.toString())?.sdkSessionId; + + await agent.setWorkingDirectory(chat, session, next); + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + 'agentHost.workspaceless': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + sameSession: getPeerChatStub(agent, chat) === originalSession, + sdkSessionId: chatBackings(agent).get(chat.toString())?.sdkSessionId, + originalSdkSessionId, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadata, + }, { + sameSession: true, + sdkSessionId: 'test-session-1', + originalSdkSessionId: 'test-session-1', + liveWorkingDirectory: next.toString(), + sdkCalls: [next.fsPath], + pluginDirectory: next.toString(), + pluginAdditionalDirectories: [], + metadata: { + 'copilot.workingDirectory': next.toString(), + 'copilot.workingDirectories': JSON.stringify([next.toString()]), + 'copilot.customizationDirectory': next.toString(), + 'agentHost.workspaceless': 'true', + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('resumes the SDK session with the new workspace customizations and existing client tools before the next turn', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-resume-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const database = sessionDataService.openDatabase(session); + await database.object.setMetadata('copilot.workingDirectory', previous.toString()); + await database.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await database.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await database.object.setMetadata('copilot.project.resolved', 'true'); + database.dispose(); + + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new TestDiskFileSystemProvider(new NullLogService())))); + await fileService.createFolder(URI.joinPath(previous, '.github', 'skills', 'previous-skill')); + await fileService.createFolder(URI.joinPath(next, '.github', 'skills', 'workspace-skill')); + await fileService.createFolder(URI.joinPath(next, '.github', 'instructions')); + await fileService.writeFile( + URI.joinPath(previous, '.github', 'skills', 'previous-skill', 'SKILL.md'), + VSBuffer.fromString('---\nname: previous-skill\ndescription: Previous workspace skill\n---\nbody'), + ); + await fileService.writeFile( + URI.joinPath(previous, '.mcp.json'), + VSBuffer.fromString('{"mcpServers":{"previous-server":{"command":"previous-server"}}}'), + ); + await fileService.writeFile( + URI.joinPath(next, '.github', 'skills', 'workspace-skill', 'SKILL.md'), + VSBuffer.fromString('---\nname: workspace-skill\ndescription: Converted workspace skill\n---\nbody'), + ); + await fileService.writeFile( + URI.joinPath(next, '.github', 'instructions', 'workspace.instructions.md'), + VSBuffer.fromString('---\napplyTo: "**/*.ts"\ndescription: Converted workspace instruction\n---\nbody'), + ); + await fileService.writeFile( + URI.joinPath(next, '.mcp.json'), + VSBuffer.fromString('{"mcpServers":{"workspace-server":{"command":"workspace-server"}}}'), + ); + const client = new TestCopilotClient([sdkSession('test-session-1', previous.fsPath)]); + const resumedConfigs: Array<{ + workingDirectory: string | undefined; + skillDirectories: readonly string[] | undefined; + instructionDirectories: readonly string[] | undefined; + mcpServerNames: readonly string[]; + hasClientTool: boolean; + }> = []; + client.resumeSession = async (_sessionId, options) => { + resumedConfigs.push({ + workingDirectory: options?.workingDirectory, + skillDirectories: options?.skillDirectories, + instructionDirectories: options?.instructionDirectories, + mcpServerNames: Object.keys(options?.mcpServers ?? {}), + hasClientTool: options?.tools?.some(tool => tool.name === 'workspace_client_tool') ?? false, + }); + return new MockCopilotSession() as unknown as CopilotSession; + }; + const { agent, instantiationService } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + useRealResumePath: true, + fileService, + }); + const created = createAgentSessionThroughAgent(agent, instantiationService, { + mockSession: new MockCopilotSession(), + workingDirectory: previous, + }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + agent.getOrCreateActiveClient(chat, exactChatContext(session, chat, session), { clientId: 'client' }).tools = [{ + name: 'workspace_client_tool', + description: 'Existing client tool', + inputSchema: { type: 'object', properties: {} }, + }]; + + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await agent.chats.sendMessage(chat, 'Continue', next, undefined, 'turn-2', undefined, undefined, exactChatContext(session, chat, session)); + + assert.deepStrictEqual(resumedConfigs, [{ + workingDirectory: next.fsPath, + skillDirectories: [URI.joinPath(next, '.github', 'skills', 'workspace-skill').fsPath], + instructionDirectories: [URI.joinPath(next, '.github', 'instructions').fsPath], + mcpServerNames: ['workspace-server'], + hasClientTool: true, + }]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('holds the session turn reservation until provider metadata persistence completes', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-reservation-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const writeGate = new DeferredPromise(); + const writeEntered = new DeferredPromise(); + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + sessionDataService.gateNextMetadataWrite(session, 'copilot.workingDirectory', writeGate.p, writeEntered); + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await writeEntered.p; + let sendFailure: string | undefined; + try { + await created.session.send('blocked'); + } catch (error) { + sendFailure = error instanceof Error ? error.message : String(error); + } + writeGate.complete(); + await change; + + assert.deepStrictEqual({ + sendFailure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + sendFailure: 'Cannot start a turn while the working directory is changing', + sdkCalls: [next.fsPath], + liveWorkingDirectory: next.toString(), + }); + } finally { + writeGate.complete(); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects invalid and multi-root targets before mutating the live SDK session', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-reject-`); + const previous = URI.file(join(root, 'previous')); + const secondary = URI.file(join(root, 'secondary')); + const notDirectory = URI.file(join(root, 'file.txt')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(secondary.fsPath)]); + await fs.writeFile(notDirectory.fsPath, 'not a directory'); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString(), secondary.toString()])); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous, additionalDirectories: [secondary] }); + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + + const failures: string[] = []; + for (const target of [ + URI.from({ scheme: Schemas.inMemory, path: '/not-local' }), + notDirectory, + secondary, + ]) { + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), target); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + + assert.deepStrictEqual({ + failures, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + failures: [ + 'Cannot change the working directory to non-local or relative resource \'inmemory:/not-local\'', + `Cannot change the working directory because '${notDirectory.fsPath}' is not an existing directory`, + `Cannot change the working directory for multi-root chat '${chat.toString()}'`, + ], + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects an exact chat without a live backing instead of resuming it', async () => { + const root = URI.file(await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-not-live-`)); + const session = AgentSession.uri('copilotcli', 'not-live'); + const chat = defaultChatUri(session); + const agent = createTestAgent(disposables); + try { + await assert.rejects( + () => agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), root), + error => error instanceof Error && error.message === `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + ); + } finally { + await fs.rm(root.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects mismatched live configuration and resource contexts without mutation', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-context-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const otherSession = AgentSession.uri('copilotcli', 'other-session'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + const failures: string[] = []; + + for (const mismatchedContext of [ + exactChatContext(otherSession, chat, otherSession), + exactChatContext(session, chat, chat), + ]) { + try { + await agent.setWorkingDirectory(chat, mismatchedContext, next); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + + assert.deepStrictEqual({ + failures, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failures: [ + `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + ], + liveWorkingDirectory: previous.toString(), + sdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects an exact live peer chat without mutation', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-peer-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService }); + const activeClient = (agent as unknown as { + _getOrCreateActiveClient(session: URI, directory: URI): { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + })._getOrCreateActiveClient(session, previous) as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + const peerSdkCalls: string[] = []; + try { + setPeerChatStub(agent, peerChat, { + workingDirectory: previous, + appliedAdditionalDirectories: [], + setWorkingDirectory: async (directory: URI) => { peerSdkCalls.push(directory.toString()); }, + }, 'peer-sdk-session'); + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(peerChat, exactChatContext(session, peerChat, peerChat), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + peerSdkCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + openedSessions: sessionDataService.openedSessions, + }, { + failure: `Cannot change the working directory for peer chat '${peerChat.toString()}': live working-directory changes are only supported for the owning default chat`, + peerSdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + openedSessions: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects a default chat while another live chat shares its configuration', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sibling-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + setLiveChatStub(agent, 'peer-sdk-session', { + sessionId: 'peer-sdk-session', + ownerSessionUri: session, + sessionUri: peerChat, + resourceUri: peerChat, + chatChannelUri: peerChat, + dispose: () => { }, + }, peerChat); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`, + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects shared customization additional roots absent from session and metadata state', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-controller-roots-`); + const previous = URI.file(join(root, 'previous')); + const secondary = URI.file(join(root, 'secondary')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(secondary.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + setAdditionalDirectories(directories: readonly URI[]): void; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + activeClient.pluginController.setAdditionalDirectories([secondary]); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory for multi-root chat '${chat.toString()}'`, + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [secondary.toString()], + metadataWrites: [], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects peer creation before recording while a configuration working-directory change is prepared', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-create-barrier-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const writeGate = new DeferredPromise(); + const writeEntered = new DeferredPromise(); + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + sessionDataService.gateNextMetadataWrite(session, 'copilot.workingDirectory', writeGate.p, writeEntered); + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await writeEntered.p; + let failure: string | undefined; + try { + await agent.chats.createChat(peerChat, exactChatContext(session, peerChat, peerChat), { workingDirectories: [previous] }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + const recordedBeforeRelease = chatScopes(agent).has(peerChat.toString()); + const backingBeforeRelease = chatBackings(agent).has(peerChat.toString()); + writeGate.complete(); + await change; + + assert.deepStrictEqual({ + failure, + recordedBeforeRelease, + backingBeforeRelease, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + failure: `Cannot create or resume chat '${peerChat.toString()}' while its configuration working directory is changing`, + recordedBeforeRelease: false, + backingBeforeRelease: false, + sdkCalls: [next.fsPath], + liveWorkingDirectory: next.toString(), + }); + } finally { + writeGate.complete(); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rejects cold peer resume without live registration while a configuration working-directory change is prepared', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-resume-barrier-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const writeGate = new DeferredPromise(); + const writeEntered = new DeferredPromise(); + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + sessionDataService.gateNextMetadataWrite(session, 'copilot.workingDirectory', writeGate.p, writeEntered); + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await writeEntered.p; + await agent.materializeChat(peerChat, exactChatContext(session, peerChat, peerChat), JSON.stringify({ sdkSessionId: 'peer-sdk-session' })); + let failure: string | undefined; + try { + await agent.chats.getMessages(peerChat, exactChatContext(session, peerChat, peerChat)); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + const liveBeforeRelease = hasLiveChat(agent, peerChat); + writeGate.complete(); + await change; + + assert.deepStrictEqual({ + failure, + liveBeforeRelease, + liveAfterRelease: hasLiveChat(agent, peerChat), + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + }, { + failure: `Cannot create or resume chat '${peerChat.toString()}' while its configuration working directory is changing`, + liveBeforeRelease: false, + liveAfterRelease: false, + sdkCalls: [next.fsPath], + liveWorkingDirectory: next.toString(), + }); + } finally { + writeGate.complete(); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rechecks recorded peer scopes after waiting behind the chat sequencer', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-recorded-peer-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const peerChat = URI.parse(buildChatUri(session, 'peer')); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + const blockerGate = new DeferredPromise(); + const blockerEntered = new DeferredPromise(); + let blocker: Promise | undefined; + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + blocker = (agent as unknown as { + _queueChat(sessionId: string, chatKey: string, operation: string, task: () => Promise): Promise; + })._queueChat(AgentSession.id(session), created.session.sessionId, 'testBlocker', async () => { + blockerEntered.complete(); + await blockerGate.p; + }); + await blockerEntered.p; + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await timeout(0); + await agent.materializeChat(peerChat, exactChatContext(session, peerChat, peerChat), JSON.stringify({ sdkSessionId: 'peer-sdk-session' })); + blockerGate.complete(); + await blocker; + + let failure: string | undefined; + try { + await change; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + sdkCalls: mockSession.workingDirectoryCalls, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + peerRecorded: chatScopes(agent).get(peerChat.toString())?.toString(), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory for chat '${chat.toString()}' while another recorded chat shares its configuration`, + sdkCalls: [], + liveWorkingDirectory: previous.toString(), + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + peerRecorded: session.toString(), + metadataWrites: [], + }); + } finally { + blockerGate.complete(); + await blocker?.catch(() => undefined); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('waits behind the chat sequencer and rejects a replaced backing without mutation', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sequencer-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + const blockerGate = new DeferredPromise(); + const blockerEntered = new DeferredPromise(); + let blocker: Promise | undefined; + let change: Promise | undefined; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + blocker = (agent as unknown as { + _queueChat(sessionId: string, chatKey: string, operation: string, task: () => Promise): Promise; + })._queueChat(AgentSession.id(session), created.session.sessionId, 'testBlocker', async () => { + blockerEntered.complete(); + await blockerGate.p; + }); + await blockerEntered.p; + + change = agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + await timeout(0); + chatBackings(agent).set(chat.toString(), { sdkSessionId: 'replacement-sdk-session' }); + blockerGate.complete(); + await blocker; + let failure: string | undefined; + try { + await change; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + }, { + failure: `Cannot change the working directory: chat '${chat.toString()}' is unknown, not live, or does not match the supplied context`, + liveWorkingDirectory: previous.toString(), + sdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [], + }); + } finally { + blockerGate.complete(); + await blocker?.catch(() => undefined); + await change?.catch(() => undefined); + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('rolls live, customization, and provider metadata state back after persistence fails', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-rollback-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + sessionDataService.failNextMetadataWrite(session, 'copilot.workingDirectories', new Error('metadata write failed')); + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + 'agentHost.workspaceless': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + metadata, + }, { + failure: 'provider metadata: metadata write failed', + liveWorkingDirectory: previous.toString(), + sdkCalls: [], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [ + { key: 'copilot.workingDirectory', value: next.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.workingDirectory', value: previous.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, + { key: 'copilot.customizationDirectory', value: previous.toString() }, + ], + metadata: { + 'copilot.workingDirectory': previous.toString(), + 'copilot.workingDirectories': JSON.stringify([previous.toString()]), + 'copilot.customizationDirectory': previous.toString(), + 'agentHost.workspaceless': 'true', + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('restores customization and provider metadata state when the SDK rejects the target', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sdk-reject-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + mockSession.workingDirectoryErrors.push(new Error('SDK rejected target')); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + metadata, + }, { + failure: 'SDK rejected target', + liveWorkingDirectory: previous.toString(), + sdkCalls: [next.fsPath], + pluginDirectory: previous.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [ + { key: 'copilot.workingDirectory', value: next.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.workingDirectory', value: previous.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, + { key: 'copilot.customizationDirectory', value: previous.toString() }, + ], + metadata: { + 'copilot.workingDirectory': previous.toString(), + 'copilot.workingDirectories': JSON.stringify([previous.toString()]), + 'copilot.customizationDirectory': previous.toString(), + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('adopts an SDK-returned working directory mismatch and reconciles provider state', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-sdk-mismatch-`); + const previous = URI.file(join(root, 'previous')); + const next = URI.file(join(root, 'next')); + const actual = URI.file(join(root, 'actual')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath), fs.mkdir(actual.fsPath)]); + const session = AgentSession.uri('copilotcli', 'test-session-1'); + const chat = defaultChatUri(session); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + db.dispose(); + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const mockSession = new MockCopilotSession(); + mockSession.workingDirectoryResults.push(actual.fsPath); + const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); + const activeClient = created.activeClient as { + readonly pluginController: { + readonly directory: URI | undefined; + readonly additionalDirectories: readonly URI[]; + }; + }; + try { + await created.session.initializeSession(); + (agent as unknown as { + _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void; + })._registerLiveChat(chat, created.session, created.activeClient); + const writeStart = sessionDataService.metadataWrites(session).length; + + let failure: string | undefined; + try { + await agent.setWorkingDirectory(chat, exactChatContext(session, chat, session), next); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + const stored = sessionDataService.openDatabase(session); + const metadata = await stored.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + }); + stored.dispose(); + assert.deepStrictEqual({ + failure, + liveWorkingDirectory: created.session.workingDirectory?.toString(), + sdkCalls: mockSession.workingDirectoryCalls, + pluginDirectory: activeClient.pluginController.directory?.toString(), + pluginAdditionalDirectories: activeClient.pluginController.additionalDirectories.map(directory => directory.toString()), + metadataWrites: sessionDataService.metadataWrites(session).slice(writeStart), + metadata, + }, { + failure: `The SDK returned working directory '${actual.fsPath}' instead of '${next.fsPath}'`, + liveWorkingDirectory: actual.toString(), + sdkCalls: [next.fsPath], + pluginDirectory: actual.toString(), + pluginAdditionalDirectories: [], + metadataWrites: [ + { key: 'copilot.workingDirectory', value: next.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.workingDirectory', value: actual.toString() }, + { key: 'copilot.workingDirectories', value: JSON.stringify([actual.toString()]) }, + { key: 'copilot.customizationDirectory', value: actual.toString() }, + ], + metadata: { + 'copilot.workingDirectory': actual.toString(), + 'copilot.workingDirectories': JSON.stringify([actual.toString()]), + 'copilot.customizationDirectory': actual.toString(), + }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); }); suite('prewarmSessionMetadata cache', () => { @@ -10157,6 +11299,73 @@ suite('CopilotAgent', () => { } }); + test('sendMessage preserves provider-owned workspace-less working directories when the caller still supplies the scratch directory', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]), useRealResumePath: true }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'route-converted-quick-chat'); + const chatUri = defaultChatUri(session); + const sdkSessionId = 'converted-quick-chat-sdk'; + const scratchDirectory = URI.file('/quick-chat-scratch'); + const providerWorkingDirectory = URI.file('/selected-workspace'); + await agent.materializeChat(chatUri, exactChatContext(session, chatUri, session), JSON.stringify({ sdkSessionId })); + const dbRef = sessionDataService.openDatabase(session); + await dbRef.object.setMetadata('copilot.workingDirectory', providerWorkingDirectory.toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([providerWorkingDirectory.toString()])); + await dbRef.object.setMetadata('copilot.customizationDirectory', providerWorkingDirectory.toString()); + await dbRef.object.setMetadata('agentHost.workspaceless', 'true'); + dbRef.dispose(); + + const internals = agent as unknown as ChatInternals; + const launches: { sessionId: string; workingDirectory: string | undefined; additionalDirectories: string[] | undefined }[] = []; + let recorder: IFakeChatRecorder | undefined; + internals._createAgentSession = (launchPlan, _customizationDirectory, _activeClient, identity) => { + launches.push({ + sessionId: launchPlan.sessionId, + workingDirectory: launchPlan.workingDirectory?.toString(), + additionalDirectories: launchPlan.additionalDirectories?.map(directory => directory.toString()), + }); + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + recorder = built.rec; + (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; + (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; + return built.fake; + }; + + await agent.chats.sendMessage(chatUri, 'continue in the selected workspace', [scratchDirectory], undefined, 'turn-1', undefined, exactChatContext(session, chatUri, session)); + const storedRef = sessionDataService.openDatabase(session); + const metadata = await storedRef.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + 'agentHost.workspaceless': true, + }); + storedRef.dispose(); + + assert.deepStrictEqual({ + launches, + sentPrompts: recorder?.sends.map(send => send.prompt), + metadata, + }, { + launches: [{ + sessionId: sdkSessionId, + workingDirectory: providerWorkingDirectory.toString(), + additionalDirectories: [], + }], + sentPrompts: ['continue in the selected workspace'], + metadata: { + 'copilot.workingDirectory': providerWorkingDirectory.toString(), + 'copilot.workingDirectories': JSON.stringify([providerWorkingDirectory.toString()]), + 'copilot.customizationDirectory': providerWorkingDirectory.toString(), + 'agentHost.workspaceless': 'true', + }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('sendMessage throws for a chat with no backing chat', async () => { const agent = createTestAgent(disposables); try { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index c65d2d30af552b..5b46ad04d277bc 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -44,8 +44,9 @@ import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js'; -import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; +import { CopilotAgentSession, type ICopilotWorkingDirectoryChangeTransaction } from '../../node/copilot/copilotAgentSession.js'; import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; +import { ShellManager } from '../../node/copilot/copilotShellTools.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { buildSandboxConfigForSdk, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; @@ -76,6 +77,12 @@ import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpo import type { IAgentHostRestrictedTelemetry, IAgentHostRestrictedTelemetryContext, IAgentHostInternalTelemetryContext, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; +const noOpWorkingDirectoryChangeTransaction: ICopilotWorkingDirectoryChangeTransaction = { + prepare: async () => { }, + rollback: async () => { }, + reconcile: async () => { }, +}; + // ---- Mock CopilotSession (SDK level) ---------------------------------------- /** @@ -98,6 +105,15 @@ class MockCopilotSession { gitHubCredentialUpdateError: Error | undefined; readonly collectLogsCalls: Parameters[0][] = []; readonly collectLogsResults: Awaited>[] = []; + readonly workingDirectorySetCalls: Parameters[0][] = []; + readonly workingDirectorySetResults: Awaited>[] = []; + readonly workingDirectorySetErrors: Array = []; + workingDirectorySetGate: Promise | undefined; + onWorkingDirectorySet: (() => void) | undefined; + readonly workingDirectoryOptionUpdateCalls: string[] = []; + readonly workingDirectoryOptionUpdateErrors: Array = []; + workingDirectoryOptionUpdateSuccess = true; + onWorkingDirectoryOptionUpdate: (() => void) | undefined; readonly experimentalModeUpdates: boolean[] = []; experimentalModeUpdateSuccess = true; sandboxConfigUpdateSuccess = true; @@ -301,6 +317,19 @@ class MockCopilotSession { : { kind: 'archive' as const, path: destination.outputPath, entries: [] }; }, }, + metadata: { + setWorkingDirectory: async (params: Parameters[0]) => { + this.operationLog.push('metadata.setWorkingDirectory'); + this.workingDirectorySetCalls.push(params); + this.onWorkingDirectorySet?.(); + await this.workingDirectorySetGate; + const error = this.workingDirectorySetErrors.shift(); + if (error) { + throw error; + } + return this.workingDirectorySetResults.shift() ?? { workingDirectory: params.workingDirectory }; + }, + }, mode: { get: async () => ({ mode: 'interactive' as const }), set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { @@ -443,7 +472,7 @@ class MockCopilotSession { cancelSamplingExecution: async () => { /* no-op */ }, }, options: { - update: async (params: { sandboxConfig?: unknown; isExperimentalMode?: boolean; shell?: { initScripts?: unknown } }) => { + update: async (params: Parameters[0]) => { if (params.sandboxConfig !== undefined) { this.operationLog.push('options.update:sandbox'); this.sandboxConfigUpdates.push(params.sandboxConfig); @@ -456,6 +485,16 @@ class MockCopilotSession { this.shellInitScriptUpdates.push(params.shell.initScripts); return { success: this.shellInitScriptUpdateSuccess }; } + if (params.workingDirectory !== undefined) { + this.operationLog.push('options.update:workingDirectory'); + this.workingDirectoryOptionUpdateCalls.push(params.workingDirectory); + this.onWorkingDirectoryOptionUpdate?.(); + const error = this.workingDirectoryOptionUpdateErrors.shift(); + if (error) { + throw error; + } + return { success: this.workingDirectoryOptionUpdateSuccess }; + } return { success: params.sandboxConfig !== undefined ? this.sandboxConfigUpdateSuccess : this.experimentalModeUpdateSuccess }; }, }, @@ -730,6 +769,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { telemetryService?: ITelemetryService; captureRuntime?: { current?: ICopilotSessionRuntime }; workingDirectory?: URI; + customizationDirectory?: URI; + shellManager?: ShellManager; /** Per-key effective config values returned by the fake configuration service. */ configValues?: Record; /** Per-key root config values returned by the fake configuration service's `getRootValue`. */ @@ -833,7 +874,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory: options?.workingDirectory, resolvedAgentName: undefined, snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, - shellManager: undefined, + shellManager: options?.shellManager, githubToken: options?.githubToken, isEphemeral: options?.isEphemeral, hasScopedEditSurface: options?.hasScopedEditSurface, @@ -1048,7 +1089,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { onDidSessionProgress: progressEmitter, sessionLauncher, launchPlan, - shellManager: undefined, + shellManager: options?.shellManager, clientSnapshot: options?.clientSnapshot, activeClientToolSet: options?.activeClientToolSet, // The owning session's last host-published customization snapshot @@ -1056,6 +1097,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { // through this accessor instead of shared host state. hostCustomizations: () => options?.sessionCustomizations?.() ?? [], workingDirectory: options?.workingDirectory, + customizationDirectory: options?.customizationDirectory, serverToolHost: options?.serverToolHost, platform: options?.platform ?? 'linux', isLaunchTokenCurrent: options?.isLaunchTokenCurrent, @@ -1121,6 +1163,35 @@ function expectedSnapshotReadonlyNote(paths: string[]): string { const TEST_SHELL_INIT_DIRECTORY = URI.file('/mock-userdata/agentHost/shellInit/test-session-1'); const TEST_SHELL_INIT_DIR = TEST_SHELL_INIT_DIRECTORY.fsPath; +function createTestShellManager(disposables: DisposableStore, workingDirectory: URI, preflightError?: Error, setErrors: Array = []): { + readonly shellManager: ShellManager; + readonly preflightCalls: () => number; + readonly setCalls: readonly URI[]; +} { + const associationEmitter = disposables.add(new Emitter<{ toolCallId: string; terminalUri: string; displayName: string }>()); + const setCalls: URI[] = []; + let preflightCalls = 0; + const shellManager = { + onDidAssociateTerminal: associationEmitter.event, + assertCanSetWorkingDirectory: () => { + preflightCalls++; + if (preflightError) { + throw preflightError; + } + }, + setWorkingDirectory: (directory: URI) => { + const error = setErrors.shift(); + if (error) { + throw error; + } + setCalls.push(directory); + }, + get workingDirectory() { return setCalls.at(-1) ?? workingDirectory; }, + dispose: () => { }, + } as unknown as ShellManager; + return { shellManager, preflightCalls: () => preflightCalls, setCalls }; +} + suite('CopilotAgentSession', () => { const disposables = new DisposableStore(); @@ -1184,6 +1255,373 @@ suite('CopilotAgentSession', () => { }); }); + test('changes the SDK and shell working directory without replacing the session', async () => { + const originalWorkingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next/../next'); + const shell = createTestShellManager(disposables, originalWorkingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { + workingDirectory: originalWorkingDirectory, + customizationDirectory: URI.file('/workspace/customizations'), + shellManager: shell.shellManager, + }); + const sessionId = session.sessionId; + mockSession.workingDirectorySetResults.push({ workingDirectory: '/workspace/next' }); + + await session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction); + + assert.deepStrictEqual({ + sessionId: session.sessionId, + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellPreflightCalls: shell.preflightCalls(), + shellSetCalls: shell.setCalls.map(uri => uri.toString()), + }, { + sessionId, + workingDirectory: nextWorkingDirectory.toString(), + customizationDirectory: nextWorkingDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [URI.file('/workspace/next').fsPath], + shellPreflightCalls: 1, + shellSetCalls: [nextWorkingDirectory.toString()], + }); + }); + + test('leaves local working-directory state unchanged when the SDK rejects the change', async () => { + const originalWorkingDirectory = URI.file('/workspace/original'); + const customizationDirectory = URI.file('/workspace/customizations'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const shell = createTestShellManager(disposables, originalWorkingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { + workingDirectory: originalWorkingDirectory, + customizationDirectory, + shellManager: shell.shellManager, + }); + mockSession.workingDirectorySetErrors.push(new Error('SDK rejected cwd')); + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /SDK rejected cwd/); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellSetCalls: shell.setCalls, + }, { + workingDirectory: originalWorkingDirectory.toString(), + customizationDirectory: customizationDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [], + shellSetCalls: [], + }); + }); + + test('rejects active-turn and shell-busy working-directory changes before the SDK RPC', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const workspaceless = await createAgentSession(disposables); + await assert.rejects(() => workspaceless.session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /without an existing working directory/); + + const active = await createAgentSession(disposables, { workingDirectory }); + active.session.resetTurnState('turn-1'); + await assert.rejects(() => active.session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /while a turn is active/); + + const busyShell = createTestShellManager(disposables, workingDirectory, new Error('shell is busy')); + const busy = await createAgentSession(disposables, { workingDirectory, shellManager: busyShell.shellManager }); + await assert.rejects(() => busy.session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), /shell is busy/); + + assert.deepStrictEqual({ + workspacelessRpcCalls: workspaceless.mockSession.workingDirectorySetCalls, + activeRpcCalls: active.mockSession.workingDirectorySetCalls, + busyRpcCalls: busy.mockSession.workingDirectorySetCalls, + busyPreflightCalls: busyShell.preflightCalls(), + }, { + workspacelessRpcCalls: [], + activeRpcCalls: [], + busyRpcCalls: [], + busyPreflightCalls: 1, + }); + }); + + test('reserves the idle session while a working-directory change is in flight', async () => { + const gate = new DeferredPromise(); + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + mockSession.workingDirectorySetGate = gate.p; + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction); + await assert.rejects(() => session.setWorkingDirectory(URI.file('/workspace/other'), noOpWorkingDirectoryChangeTransaction), /another working directory change is in progress/); + await assert.rejects(() => session.send('hello', undefined, 'turn-direct'), /working directory is changing/); + gate.complete(); + await mutation; + + assert.deepStrictEqual({ + rpcCalls: mockSession.workingDirectorySetCalls, + sendRequests: mockSession.sendRequests, + hasActiveTurn: session.hasActiveTurn, + }, { + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + sendRequests: [], + hasActiveTurn: false, + }); + }); + + test('prepares before the single SDK mutation and keeps host sends reserved', async () => { + const prepareStarted = new DeferredPromise(); + const prepareGate = new DeferredPromise(); + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + const operationLog: string[] = []; + mockSession.onWorkingDirectorySet = () => operationLog.push('metadata.setWorkingDirectory'); + mockSession.onWorkingDirectoryOptionUpdate = () => operationLog.push('options.update:workingDirectory'); + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { + operationLog.push('prepare'); + prepareStarted.complete(); + await prepareGate.p; + }, + rollback: async () => { operationLog.push('rollback'); }, + reconcile: async () => { operationLog.push('reconcile'); }, + }); + await prepareStarted.p; + await assert.rejects(() => session.send('hello', undefined, 'turn-direct'), /working directory is changing/); + assert.deepStrictEqual(mockSession.workingDirectorySetCalls, []); + prepareGate.complete(); + await mutation; + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + operationLog, + rpcCalls: mockSession.workingDirectorySetCalls, + sendRequests: mockSession.sendRequests, + }, { + workingDirectory: nextWorkingDirectory.toString(), + operationLog: ['prepare', 'metadata.setWorkingDirectory', 'options.update:workingDirectory'], + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + sendRequests: [], + }); + }); + + test('rolls back preparation without calling the SDK when preparation fails', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + const calls: string[] = []; + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { calls.push('prepare'); throw new Error('prepare failed'); }, + rollback: async () => { calls.push('rollback'); }, + reconcile: async () => { calls.push('reconcile'); }, + }), /prepare failed/); + + assert.deepStrictEqual({ + calls, + rpcCalls: mockSession.workingDirectorySetCalls, + workingDirectory: session.workingDirectory?.toString(), + }, { + calls: ['prepare', 'rollback'], + rpcCalls: [], + workingDirectory: workingDirectory.toString(), + }); + }); + + test('preserves preparation and rollback failures without calling the SDK', async () => { + const workingDirectory = URI.file('/workspace/original'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + + await assert.rejects(() => session.setWorkingDirectory(URI.file('/workspace/next'), { + prepare: async () => { throw new Error('prepare failed'); }, + rollback: async () => { throw new Error('rollback failed'); }, + reconcile: async () => { }, + }), /preparation failed.*prepare failed.*failed to roll back.*rollback failed/); + + assert.deepStrictEqual(mockSession.workingDirectorySetCalls, []); + }); + + test('keeps an SDK-started turn visible and reverts before the SDK mutation', async () => { + const prepareStarted = new DeferredPromise(); + const prepareGate = new DeferredPromise(); + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession, signals } = await createAgentSession(disposables, { workingDirectory }); + const calls: string[] = []; + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { + calls.push('prepare'); + prepareStarted.complete(); + await prepareGate.p; + }, + rollback: async () => { calls.push('rollback'); }, + reconcile: async () => { calls.push('reconcile'); }, + }); + await prepareStarted.p; + mockSession.fire('system.notification', { + content: '\nShell command completed\n', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + } as SessionEventPayload<'system.notification'>['data']); + prepareGate.complete(); + await assert.rejects(mutation, /while a turn is active/); + + const turnStarted = getActions(signals).find(action => action.type === ActionType.ChatTurnStarted); + assert.ok(turnStarted); + assert.deepStrictEqual({ + calls, + currentTurnId: session.currentTurnId, + turnStartedId: turnStarted.turnId, + rpcCalls: mockSession.workingDirectorySetCalls, + workingDirectory: session.workingDirectory?.toString(), + }, { + calls: ['prepare', 'rollback'], + currentTurnId: turnStarted.turnId, + turnStartedId: turnStarted.turnId, + rpcCalls: [], + workingDirectory: workingDirectory.toString(), + }); + }); + + test('rolls back prepared provider state when the single SDK mutation rejects', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + mockSession.workingDirectorySetErrors.push(new Error('SDK rejected cwd')); + const calls: string[] = []; + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { calls.push('prepare'); }, + rollback: async () => { calls.push('rollback'); }, + reconcile: async () => { calls.push('reconcile'); }, + }), /SDK rejected cwd/); + + assert.deepStrictEqual({ + calls, + rpcCalls: mockSession.workingDirectorySetCalls, + workingDirectory: session.workingDirectory?.toString(), + }, { + calls: ['prepare', 'rollback'], + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + workingDirectory: workingDirectory.toString(), + }); + }); + + test('preserves SDK rejection and rollback failures', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory }); + mockSession.workingDirectorySetErrors.push(new Error('SDK rejected cwd')); + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { }, + rollback: async () => { throw new Error('rollback failed'); }, + reconcile: async () => { }, + }), /SDK working directory.*SDK rejected cwd.*failed to roll back.*rollback failed/); + + assert.deepStrictEqual(mockSession.workingDirectorySetCalls, [{ workingDirectory: nextWorkingDirectory.fsPath }]); + }); + + test('adopts and reconciles a mismatched authoritative SDK working directory', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const actualWorkingDirectory = URI.file('/workspace/actual'); + const shell = createTestShellManager(disposables, workingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory, shellManager: shell.shellManager }); + mockSession.workingDirectorySetResults.push({ workingDirectory: '/workspace/actual/../actual' }); + const reconcileCalls: URI[] = []; + const reconcileStarted = new DeferredPromise(); + const reconcileGate = new DeferredPromise(); + + const mutation = session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { }, + rollback: async () => { }, + reconcile: async authoritativeWorkingDirectory => { + reconcileCalls.push(authoritativeWorkingDirectory); + reconcileStarted.complete(); + await reconcileGate.p; + }, + }); + await reconcileStarted.p; + await assert.rejects(() => session.send('hello', undefined, 'turn-direct'), /working directory is changing/); + reconcileGate.complete(); + await assert.rejects(mutation, /returned working directory.*actual.*instead of.*next/); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + reconcileCalls: reconcileCalls.map(uri => uri.toString()), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellSetCalls: shell.setCalls.map(uri => uri.toString()), + sendRequests: mockSession.sendRequests, + }, { + workingDirectory: actualWorkingDirectory.toString(), + customizationDirectory: actualWorkingDirectory.toString(), + reconcileCalls: [actualWorkingDirectory.toString()], + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [actualWorkingDirectory.fsPath], + shellSetCalls: [actualWorkingDirectory.toString()], + sendRequests: [], + }); + }); + + test('preserves mismatch, runtime, local, and reconciliation failures while adopting SDK state', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const actualWorkingDirectory = URI.file('/workspace/actual'); + const shell = createTestShellManager(disposables, workingDirectory, undefined, [new Error('shell alignment failed')]); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory, shellManager: shell.shellManager }); + mockSession.workingDirectorySetResults.push({ workingDirectory: actualWorkingDirectory.fsPath }); + mockSession.workingDirectoryOptionUpdateErrors.push(new Error('runtime alignment failed')); + + await assert.rejects(() => session.setWorkingDirectory(nextWorkingDirectory, { + prepare: async () => { }, + rollback: async () => { }, + reconcile: async () => { throw new Error('reconciliation failed'); }, + }), /returned working directory.*actual.*runtime alignment failed.*failed to align.*shell alignment failed.*failed to reconcile.*reconciliation failed/); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + }, { + workingDirectory: actualWorkingDirectory.toString(), + customizationDirectory: actualWorkingDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [actualWorkingDirectory.fsPath], + }); + }); + + test('reports runtime alignment failure after adopting the requested SDK working directory', async () => { + const workingDirectory = URI.file('/workspace/original'); + const nextWorkingDirectory = URI.file('/workspace/next'); + const shell = createTestShellManager(disposables, workingDirectory); + const { session, mockSession } = await createAgentSession(disposables, { workingDirectory, shellManager: shell.shellManager }); + mockSession.workingDirectoryOptionUpdateSuccess = false; + + await assert.rejects( + () => session.setWorkingDirectory(nextWorkingDirectory, noOpWorkingDirectoryChangeTransaction), + /runtime alignment failed.*SDK rejected the runtime working directory update/i + ); + + assert.deepStrictEqual({ + workingDirectory: session.workingDirectory?.toString(), + customizationDirectory: session.customizationDirectory?.toString(), + rpcCalls: mockSession.workingDirectorySetCalls, + runtimeOptionCalls: mockSession.workingDirectoryOptionUpdateCalls, + shellSetCalls: shell.setCalls.map(uri => uri.toString()), + }, { + workingDirectory: nextWorkingDirectory.toString(), + customizationDirectory: nextWorkingDirectory.toString(), + rpcCalls: [{ workingDirectory: nextWorkingDirectory.fsPath }], + runtimeOptionCalls: [nextWorkingDirectory.fsPath], + shellSetCalls: [nextWorkingDirectory.toString()], + }); + }); + test('collects SDK debug logs with process logs', async () => { const { session, mockSession } = await createAgentSession(disposables); const outputDirectory = URI.file('/tmp/agent-host-debug'); @@ -6215,6 +6653,37 @@ Use the attached image as context. }); }); + test('late aborted idle completes a running replacement turn without cancelling it', async () => { + const abortGate = new DeferredPromise(); + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-1' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.abortGate = abortGate.p; + + const abortPromise = session.abort(); + await timeout(0); + abortGate.complete(); + await abortPromise; + session.resetTurnState('turn-2'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.fire('assistant.message', { + messageId: 'replacement-message', + content: 'Replacement response', + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + abortCalls: mockSession.abortCalls, + actions: getActions(signals).filter(action => action.type === ActionType.ChatResponsePart || action.type === ActionType.ChatTurnComplete).map(action => action.type), + }, { + active: false, + abortCalls: 1, + actions: [ActionType.ChatResponsePart, ActionType.ChatTurnComplete], + }); + }); + test('quarantines late cancelled events until the next provider turn starts', async () => { const abortGate = new DeferredPromise(); const logService = new CapturingLogService(); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index ec64414bf30ba2..8bfdee31a470b4 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -41,6 +41,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { readonly writes: { uri: string; data: string }[] = []; readonly sentTexts: { uri: string; data: string; options: ISendTextOptions }[] = []; readonly existingTerminalUris = new Set(); + readonly disposedTerminalUris: string[] = []; commandDetectionSupported = false; readonly commandFinishedListenerRegistered = new DeferredPromise(); private readonly _onCommandFinished = new Emitter(); @@ -87,7 +88,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); } supportsCommandDetection(): boolean { return this.commandDetectionSupported; } - disposeTerminal(): void { } + disposeTerminal(uri: string): void { + this.disposedTerminalUris.push(uri); + this.existingTerminalUris.delete(uri); + } getTerminalInfos(): TerminalInfo[] { return []; } getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise { return this.defaultShell; } @@ -255,6 +259,101 @@ suite('CopilotShellTools', () => { ]); }); + test('setWorkingDirectory disposes idle shells, resets associations, and uses the new cwd', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const newWorkingDirectory = URI.file('/workspace/reanchored'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const initialShell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(initialShell.object.terminalUri); + initialShell.dispose(); + + shellManager.setWorkingDirectory(newWorkingDirectory); + const shellCountAfterReanchor = shellManager.listShells().length; + const reanchoredShell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-2', 'tool-2'); + + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + disposedTerminalUris: terminalManager.disposedTerminalUris, + oldAssociation: shellManager.getTerminalUriForToolCall('tool-1'), + shellCountAfterReanchor, + createdCwds: terminalManager.created.map(entry => entry.params.cwd), + }, { + workingDirectory: newWorkingDirectory.toString(), + disposedTerminalUris: [initialShell.object.terminalUri], + oldAssociation: undefined, + shellCountAfterReanchor: 0, + createdCwds: [initialWorkingDirectory.fsPath, newWorkingDirectory.fsPath], + }); + reanchoredShell.dispose(); + }); + + test('setWorkingDirectory rejects while a shell is busy without changing state', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const shell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(shell.object.terminalUri); + + assert.throws(() => shellManager.setWorkingDirectory(URI.file('/workspace/rejected')), /while a shell is busy/); + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + shellIds: shellManager.listShells().map(shell => shell.id), + toolCallTerminalUri: shellManager.getTerminalUriForToolCall('tool-1'), + disposedTerminalUris: terminalManager.disposedTerminalUris, + }, { + workingDirectory: initialWorkingDirectory.toString(), + shellIds: [shell.object.id], + toolCallTerminalUri: shell.object.terminalUri, + disposedTerminalUris: [], + }); + shell.dispose(); + }); + + test('assertCanSetWorkingDirectory rejects without changing shell state', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const shell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(shell.object.terminalUri); + + assert.throws(() => shellManager.assertCanSetWorkingDirectory(), /while a shell is busy/); + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + shellIds: shellManager.listShells().map(shell => shell.id), + toolCallTerminalUri: shellManager.getTerminalUriForToolCall('tool-1'), + disposedTerminalUris: terminalManager.disposedTerminalUris, + }, { + workingDirectory: initialWorkingDirectory.toString(), + shellIds: [shell.object.id], + toolCallTerminalUri: shell.object.terminalUri, + disposedTerminalUris: [], + }); + shell.dispose(); + }); + + test('setWorkingDirectory rejects a held shell even after its reference is released', async () => { + const { instantiationService, terminalManager } = createServices(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const shell = await shellManager.getOrCreateShell('bash', TEST_CHAT_URI, 'turn-1', 'tool-1'); + terminalManager.existingTerminalUris.add(shell.object.terminalUri); + shellManager.holdShellUntilCommandFinishes(shell.object); + shell.dispose(); + + assert.throws(() => shellManager.setWorkingDirectory(URI.file('/workspace/rejected')), /while a shell is busy/); + assert.deepStrictEqual({ + workingDirectory: shellManager.workingDirectory?.toString(), + toolCallTerminalUri: shellManager.getTerminalUriForToolCall('tool-1'), + disposedTerminalUris: terminalManager.disposedTerminalUris, + }, { + workingDirectory: initialWorkingDirectory.toString(), + toolCallTerminalUri: shell.object.terminalUri, + disposedTerminalUris: [], + }); + terminalManager.fireCommandFinished({ commandId: 'cmd-1', exitCode: 0, command: 'sleep 100', output: '' }); + }); + test('opts every managed shell into shell-history suppression and non-interactive mode', async () => { const { instantiationService, terminalManager } = createServices(); const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); @@ -714,6 +813,39 @@ suite('CopilotShellTools', () => { assert.strictEqual(engineA, engineB, 'Sandbox engine should be cached across calls'); }); + test('setWorkingDirectory invalidates the captured sandbox engine roots', async () => { + const createdFiles = new Map(); + const initialWorkingDirectory = URI.file('/workspace/initial'); + const newWorkingDirectory = URI.file('/workspace/reanchored'); + const { instantiationService } = createServices({ sandboxEnabled: true, createdFiles }); + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), initialWorkingDirectory)); + const engine = shellManager.getOrCreateSandboxEngine(); + await engine.wrapCommand('echo initial'); + const sandboxConfigPath = [...createdFiles.keys()].find(path => /vscode-sandbox-settings-.*\.json$/.test(path)); + assert.ok(sandboxConfigPath); + const initialConfig = JSON.parse(createdFiles.get(sandboxConfigPath)!); + + shellManager.setWorkingDirectory(newWorkingDirectory); + await engine.wrapCommand('echo reanchored'); + const reanchoredConfig = JSON.parse(createdFiles.get(sandboxConfigPath)!); + const initialWritablePaths: string[] = platform.isWindows ? initialConfig.filesystem.readwritePaths : initialConfig.filesystem.allowWrite; + const reanchoredWritablePaths: string[] = platform.isWindows ? reanchoredConfig.filesystem.readwritePaths : reanchoredConfig.filesystem.allowWrite; + const initialPath = platform.isWindows ? '\\workspace\\initial' : '/workspace/initial'; + const reanchoredPath = platform.isWindows ? '\\workspace\\reanchored' : '/workspace/reanchored'; + + assert.deepStrictEqual({ + enginePreserved: shellManager.getOrCreateSandboxEngine() === engine, + initialRootPresent: initialWritablePaths.includes(initialPath), + oldRootPresent: reanchoredWritablePaths.includes(initialPath), + newRootPresent: reanchoredWritablePaths.includes(reanchoredPath), + }, { + enginePreserved: true, + initialRootPresent: true, + oldRootPresent: false, + newRootPresent: true, + }); + }); + 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)); diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 69e5909a14c271..20d7952b92b479 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -211,6 +211,27 @@ suite('getPermissionDisplay — read confirmation title', () => { }); }); +suite('getPermissionDisplay — server tool confirmation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses the plain-language set_workspace confirmation without raw input', () => { + assert.deepStrictEqual( + getPermissionDisplay(customToolPermissionRequest('set_workspace', { + workspaceFolder: '/workspace/app', + isolation: false, + })), + { + confirmationTitle: 'Continue in app?', + invocationMessage: 'Continue this session in /workspace/app and make changes directly in that folder?', + toolInput: undefined, + permissionKind: 'custom-tool', + permissionPath: undefined, + }, + ); + }); +}); + suite('getPermissionDisplay — cd-prefix stripping', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 8d34b8f9ff8c73..224bd3aa3ca002 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index 37aae4df211bf0..88d3faa90ed745 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index 3c2a7d3467547d..fc7650db5b9191 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 6f19917d9e2249..a8f698a3b51f4c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md index fe4ddef229f643..4f6e9c308c01ea 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 9b3e859aa64ec3..38fc3b56132e62 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index ce19249380cf46..3bde585137ef70 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 032e34e9af926b..2d306031176b46 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index 6b5bf42ca69b25..969abbe8fb4f5a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -771,7 +771,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "input_schema": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 1eea02ad187dbd..7bec1bc62d0236 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 59827e7e0394ab..4589a2396b3f13 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index b13a39c93b8676..063a87373067e8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index ea898ee4fee22c..180e0a9031009c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index ee2e9593818879..3cf34126e2f449 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index 80c0b6c5d6e9b0..b5fb7809f3f48a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 426025d22f64ca..2c3011c9f5e57e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -805,7 +805,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index 56cba54b3e3514..75162a227fa56c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 3a7e678d0bc5a4..d09c2dd4d3f13c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index 1dc4aa5972edb2..30f1d3b7ad4919 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -766,7 +766,7 @@ }, { "name": "send_message", - "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. The message is delivered asynchronously — this tool does not wait for or return the reply.", + "description": "Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link; a link carrying a chat id targets that specific chat. If the target chat is busy, the message is queued and starts after the active turn completes successfully. Delivery is asynchronous — this tool does not wait for or return the reply.", "parameters": { "type": "object", "properties": { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index b888346e968e7e..fe321991e33c82 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -11,7 +11,7 @@ import { join } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentChatMigrationResult, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentChatConfigCompletionsParams, type IAgentChatContext, type IAgentChatMetadata, type IAgentChats, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentModelInfo, type IAgentResolveChatConfigParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal, resolveAgentChatContext } from '../../common/agent.js'; +import { AgentSession, type AgentChatMigrationResult, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentCapabilities, type IAgentChatConfigCompletionsParams, type IAgentChatContext, type IAgentChatMetadata, type IAgentChats, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentModelInfo, type IAgentResolveChatConfigParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal, resolveAgentChatContext } from '../../common/agent.js'; import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; @@ -128,7 +128,11 @@ export class MockAgent implements IAgent { /** Optional overrides applied to session metadata from listSessions. */ sessionMetadataOverrides: Partial> = {}; - constructor(readonly id: AgentProvider = 'mock') { + constructor( + readonly id: AgentProvider = 'mock', + private readonly _capabilities: IAgentCapabilities = { multipleChats: { fork: true } }, + readonly agentHostCapabilities: IAgent['agentHostCapabilities'] = { workspaceConversion: false }, + ) { queueMicrotask(() => { void this.listExternalChats().then(chats => { if (chats) { @@ -143,7 +147,11 @@ export class MockAgent implements IAgent { } getDescriptor(): IAgentDescriptor { - return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, capabilities: { multipleChats: { fork: true } } }; + return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, capabilities: this._capabilities }; + } + + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error(`Agent '${this.id}' does not support changing the working directory of an existing session.`); } getProtectedResources(): ProtectedResourceMetadata[] { @@ -494,6 +502,7 @@ export class ScriptedMockAgent implements IAgent { private readonly _discoveredChatsEmitter = new Emitter(); readonly onDidDiscoverChats = this._discoveredChatsEmitter.event; readonly id: AgentProvider = 'mock'; + readonly agentHostCapabilities = { workspaceConversion: false } as const; private readonly _onDidChatProgress = new Emitter(); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -554,6 +563,10 @@ export class ScriptedMockAgent implements IAgent { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' }; } + async setWorkingDirectory(_chat: URI, _context: URI | IAgentChatContext, _workingDirectory: URI): Promise { + throw new Error('The scripted mock agent does not support changing the working directory of an existing session.'); + } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { return []; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index def42ccb473007..a5cf88db06497c 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -17,6 +17,7 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; +import { RequestAgentHostWorkspaceTrustExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; @@ -349,6 +350,25 @@ function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | un return sent.find(message => isJsonRpcResponse(message) && message.id === id); } +function findRequest(sent: ProtocolMessage[], method: string): { readonly jsonrpc: '2.0'; readonly id: number; readonly method: string; readonly params?: unknown } | undefined { + for (const message of sent) { + if ( + hasKey(message, { id: true, method: true }) + && typeof message.id === 'number' + && typeof message.method === 'string' + && message.method === method + ) { + return { + jsonrpc: '2.0', + id: message.id, + method: message.method, + ...(hasKey(message, { params: true }) ? { params: message.params } : {}), + }; + } + } + return undefined; +} + function waitForResponse(transport: MockProtocolTransport, id: number): Promise { return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id)); } @@ -453,6 +473,53 @@ suite('ProtocolServerHandler', () => { }); }); + test('routes a workspace trust request to the initiating client', async () => { + const transport = connectClient('client-1'); + while (!findResponse(transport.sent, 1)) { + await Promise.resolve(); + } + + const trustPromise = clientConnections.requestWorkspaceTrust('client-1', { + workspace: 'file:///workspace/project', + }); + const reverseRequest = findRequest(transport.sent, RequestAgentHostWorkspaceTrustExtensionMethod); + if (!reverseRequest) { + assert.fail('Expected a reverse workspace trust request.'); + } + transport.simulateMessage({ + jsonrpc: '2.0', + id: reverseRequest.id, + result: { trusted: true }, + }); + + assert.deepStrictEqual({ + request: reverseRequest, + trusted: await trustPromise, + }, { + request: { + jsonrpc: '2.0', + id: reverseRequest.id, + method: RequestAgentHostWorkspaceTrustExtensionMethod, + params: { workspace: 'file:///workspace/project' }, + }, + trusted: true, + }); + }); + + test('rejects a pending workspace trust request when the client disconnects', async () => { + const transport = connectClient('client-1'); + while (!findResponse(transport.sent, 1)) { + await Promise.resolve(); + } + + const trustPromise = clientConnections.requestWorkspaceTrust('client-1', { + workspace: 'file:///workspace/project', + }); + transport.simulateClose(); + + await assert.rejects(trustPromise, /disconnected/); + }); + test('handshake advertises only implemented automation capabilities', () => { agentService.automationCapabilities = { create: {}, runCancellation: {} }; const transport = connectClient('automation-client'); diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index 16fc5fa0fd05f6..829bfaf62a92ac 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -66,8 +66,8 @@ function expectedSandboxConfig(options?: { addCurrentWorkingDirectory: true, allowDevToolAccess: true, auth: { - git: false, - gh: false, + git: true, + gh: true, }, userPolicy: { filesystem: { @@ -78,7 +78,7 @@ function expectedSandboxConfig(options?: { }, network: { allowOutbound: options?.allowOutbound === true, - allowLocalNetwork: true, + allowLocalNetwork: false, }, }, }; @@ -120,6 +120,12 @@ suite('buildSandboxConfigForSdk', () => { } }); + test('preserves an explicit outbound network restriction', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, undefined, false)), expectedSandboxConfig({ allowOutbound: false })); + } + }); + test('maps the unsandboxed commands setting to SDK bypass', () => { assert.deepStrictEqual([ buildSandboxConfigForSdk('linux', { @@ -228,7 +234,7 @@ suite('buildSandboxConfigForSdk', () => { for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy?.network, { allowOutbound: false, - allowLocalNetwork: true, + allowLocalNetwork: false, }, platform); } }); @@ -237,7 +243,7 @@ suite('buildSandboxConfigForSdk', () => { for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }, true))?.userPolicy?.network, { allowOutbound: true, - allowLocalNetwork: true, + allowLocalNetwork: false, }, platform); } }); @@ -245,7 +251,7 @@ suite('buildSandboxConfigForSdk', () => { test('ignores empty host lists', () => { assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: [], blockedHosts: [] }))?.userPolicy?.network, { allowOutbound: false, - allowLocalNetwork: true, + allowLocalNetwork: false, }); }); }); diff --git a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts index 563b1573bcebb0..9937e8af939c0d 100644 --- a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts +++ b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts @@ -69,6 +69,33 @@ suite('serverToolGroups display', () => { }); }); + test('set_workspace resolves to outcome-oriented confirmation display', () => { + const confirmation = (isolation: boolean) => { + const display = getServerToolDisplay('set_workspace', { workspaceFolder: '/workspace/app', isolation }); + return { + title: display?.confirmationTitle, + message: text(display?.confirmationMessage), + hideInput: display?.hideConfirmationInput, + }; + }; + + assert.deepStrictEqual({ + direct: confirmation(false), + isolated: confirmation(true), + }, { + direct: { + title: 'Continue in app?', + message: 'Continue this session in /workspace/app and make changes directly in that folder?', + hideInput: true, + }, + isolated: { + title: 'Continue in app?', + message: 'Continue this session in /workspace/app with changes isolated from the existing folder?', + hideInput: true, + }, + }); + }); + test('fast tools omit a duplicate completion message', () => { const past = (resultText?: string) => text(getServerToolDisplay('listComments', undefined, { text: resultText, success: true })?.pastTenseMessage); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 1bec533c4334cc..c1313b937fb882 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -763,6 +763,27 @@ suite('SessionDatabase', () => { assert.strictEqual(await db.getMetadata('customTitle'), 'Second'); }); + test('deleteMetadata removes only the requested keys', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValues({ + customTitle: 'Title', + customTitleSource: 'user', + unrelated: 'preserved', + }); + + await db.deleteMetadata(['customTitle', 'customTitleSource']); + + assert.deepStrictEqual(await db.getMetadataObject({ + customTitle: true, + customTitleSource: true, + unrelated: true, + }), { + customTitle: undefined, + customTitleSource: undefined, + unrelated: 'preserved', + }); + }); + test('setMetadataValues rolls back every key when one write fails', async () => { const database = disposables.add(await TestableSessionDatabase.open(':memory:')); db = database; diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index f4e3969ea7782f..327df814b41267 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -12,15 +12,17 @@ import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, PendingMessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; +import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { AgentServerToolHost, type IServerToolGroup } from '../../node/shared/agentServerToolHost.js'; import { applyCreateChatTool, applyCreateSessionTool, + applySetWorkspaceTool, applyDeleteSessionTool, applyRenameChatTool, applySendMessageTool, @@ -29,6 +31,7 @@ import { getCreateChatArgs, getCreateSessionArgs, getDeleteSessionArgs, + getSetWorkspaceArgs, getRenameChatArgs, getSendMessageArgs, getSessionContextArgs, @@ -62,6 +65,7 @@ suite('SessionServerTools', () => { const depths = overrides?.depths ?? new Map(); return { isActiveAgentTitleGenerationEnabled: overrides?.isActiveAgentTitleGenerationEnabled ?? (() => true), + canConvertWorkspace: overrides?.canConvertWorkspace ?? (() => true), listSessions: overrides?.listSessions ?? (async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)]), getSession: overrides?.getSession ?? (async session => session.toString() === 'copilot:/s1' ? sessionMeta('s1', SessionStatus.InProgress, workspace) : undefined), createSession: overrides?.createSession ?? (async config => { overrides?.onCreate?.(config); return URI.parse('copilot:/new'); }), @@ -75,6 +79,7 @@ suite('SessionServerTools', () => { getChatContext: overrides?.getChatContext ?? (async () => undefined), getSessionSpawnDepth: overrides?.getSessionSpawnDepth ?? (session => depths.get(session.toString()) ?? 0), setSessionSpawnDepth: overrides?.setSessionSpawnDepth ?? ((session, depth) => { depths.set(session.toString(), depth); }), + requestSessionWorkspaceUpdate: overrides?.requestSessionWorkspaceUpdate ?? (() => { }), }; } @@ -91,11 +96,13 @@ suite('SessionServerTools', () => { } test('definitions and confirmation', () => { - assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, SessionServerToolName.CreateSession, SessionServerToolName.RenameChat, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, SessionServerToolName.DeleteSession]); + assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.RenameChat, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, SessionServerToolName.DeleteSession]); assert.match(sessionServerToolDefinitions.find(definition => definition.name === SessionServerToolName.ListSessions)?.description ?? '', /`openLink` for clickable Markdown links/); + assert.match(sessionServerToolDefinitions.find(definition => definition.name === SessionServerToolName.SendMessage)?.description ?? '', /target chat is busy.*message is queued/); assert.deepStrictEqual(sessionServerToolDefinitions.filter(definition => definition.enabledForEphemeralSessions).map(definition => definition.name), []); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.CreateSession), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.CreateChat), true); + assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.SetWorkspace), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.SendMessage), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.DeleteSession), true); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.RenameChat), false); @@ -117,6 +124,29 @@ suite('SessionServerTools', () => { }, required: ['relationship', 'prompt', 'title'], }); + const setWorkspaceDefinition = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.SetWorkspace); + assert.deepStrictEqual({ + title: setWorkspaceDefinition?.title, + description: setWorkspaceDefinition?.description, + inputSchema: setWorkspaceDefinition?.inputSchema, + }, { + title: 'Set Workspace', + description: 'Set the current session\'s workspace when the task should continue in a workspace not yet attached to this session. This preserves the session, chat, and conversation history. Immediately before every call to this tool, always use the available user-input tool to ask the user to confirm both the workspace and whether the work should be isolated, even if the user previously mentioned or requested those choices. Tool approval is separate and does not replace this confirmation. Set `isolation` to true to create a managed Git worktree, or false to work directly in the folder. The workspace change is deferred until the current turn ends, then the host automatically continues the original task in the selected workspace. Make this the final tool call of the turn.', + inputSchema: { + type: 'object', + properties: { + workspaceFolder: { + type: 'string', + description: 'Absolute local folder path or file URI to set as the current session\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', + }, + isolation: { + type: 'boolean', + description: 'Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool.', + }, + }, + required: ['workspaceFolder', 'isolation'], + }, + }); assert.strictEqual(sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.ListSessions)?.inputSchema?.properties?.label, undefined); const renameDefinition = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.RenameChat); assert.deepStrictEqual([{ name: renameDefinition?.name, required: renameDefinition?.inputSchema?.required }], [ @@ -130,6 +160,48 @@ suite('SessionServerTools', () => { assert.ok(renameDescription?.includes('peer-chat titles remain independent')); }); + test('set_workspace accepts only exact local workspace folders', () => { + assert.deepStrictEqual({ + absolutePath: getSetWorkspaceArgs({ workspaceFolder: '/workspace/app', isolation: false }), + fileUri: getSetWorkspaceArgs({ workspaceFolder: 'file:///workspace/other', isolation: true }), + }, { + absolutePath: { workspaceFolder: URI.parse('file:///workspace/app'), isolation: false }, + fileUri: { workspaceFolder: URI.parse('file:///workspace/other'), isolation: true }, + }); + assert.throws(() => getSetWorkspaceArgs({ workspaceFolder: 'workspace/app', isolation: false }), /absolute local path or file URI/); + assert.throws(() => getSetWorkspaceArgs({ workspaceFolder: 'vscode-remote://ssh-remote+host/workspace/app', isolation: false }), /absolute local path or file URI/); + assert.throws(() => getSetWorkspaceArgs({ workspaceFolder: '/workspace/app' }), /isolation must be a boolean/); + }); + + test('set_workspace requests an update for the current chat and active turn', () => { + const requests: { chat: string; turnId: string; workspaceFolder: string; isolation: boolean }[] = []; + const chat = URI.parse(buildDefaultChatUri('copilot:/s1')); + const accessor = createAccessor({ + requestSessionWorkspaceUpdate: (targetChat, turnId, workspaceFolder, isolation) => requests.push({ + chat: targetChat.toString(), + turnId, + workspaceFolder: workspaceFolder.toString(), + isolation, + }), + }); + + const result = applySetWorkspaceTool(accessor, { workspaceFolder: '/workspace/app', isolation: true }, chat, 'turn-1'); + + assert.deepStrictEqual({ + requests, + result, + }, { + requests: [{ + chat: chat.toString(), + turnId: 'turn-1', + workspaceFolder: 'file:///workspace/app', + isolation: true, + }], + result: 'An isolated worktree will be created from file:///workspace/app and set as the workspace after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the isolated workspace.', + }); + assert.throws(() => applySetWorkspaceTool(accessor, { workspaceFolder: '/workspace/app', isolation: false }, chat, undefined), /must run from an active chat turn/); + }); + test('ephemeral sessions advertise no default session-management tools', () => { const stateManager = new AgentHostStateManager(new NullLogService()); const session = 'copilot:/ephemeral'; @@ -197,6 +269,7 @@ suite('SessionServerTools', () => { disabledTools: [ SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, + SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, @@ -234,6 +307,70 @@ suite('SessionServerTools', () => { stateManager.dispose(); }); + test('set_workspace is not advertised or executable when the provider cannot change working directory', async () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'claude:/s1'; + stateManager.createSession({ + resource: session, + provider: 'claude', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + const host = new AgentServerToolHost(stateManager, [ + createSessionServerToolGroup(createAccessor({ canConvertWorkspace: () => false })), + ]); + + host.advertise(session); + const advertisedTools = stateManager.getSessionState(session)?.serverTools?.map(tool => tool.name); + stateManager.dispatchServerAction(session, { + type: ActionType.SessionServerToolsChanged, + tools: sessionServerToolDefinitions, + }); + + await assert.rejects( + async () => host.executeTool(buildDefaultChatUri(session), SessionServerToolName.SetWorkspace, { workspaceFolder: '/workspace', isolation: false }), + /Server tool "set_workspace" is disabled/, + ); + assert.deepStrictEqual({ + advertisedTools, + restoredTools: host.getDefinitionsForSession(session).map(tool => tool.name), + }, { + advertisedTools: sessionServerToolDefinitions.filter(tool => tool.name !== SessionServerToolName.SetWorkspace).map(tool => tool.name), + restoredTools: sessionServerToolDefinitions.filter(tool => tool.name !== SessionServerToolName.SetWorkspace).map(tool => tool.name), + }); + stateManager.dispose(); + }); + + test('set_workspace is removed when the session can no longer be converted', async () => { + const stateManager = new AgentHostStateManager(new NullLogService()); + const session = 'copilot:/s1'; + stateManager.createSession({ + resource: session, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + let canConvertWorkspace = true; + const host = new AgentServerToolHost(stateManager, [ + createSessionServerToolGroup(createAccessor({ canConvertWorkspace: () => canConvertWorkspace })), + ]); + + host.advertise(session); + canConvertWorkspace = false; + host.advertise(session); + + await assert.rejects( + async () => host.executeTool(buildDefaultChatUri(session), SessionServerToolName.SetWorkspace, { workspaceFolder: '/workspace', isolation: false }), + /Server tool "set_workspace" is disabled/, + ); + assert.ok(!stateManager.getSessionState(session)?.serverTools?.some(tool => tool.name === SessionServerToolName.SetWorkspace)); + stateManager.dispose(); + }); + test('re-advertise updates dynamic groups while materialized session tools stay fixed', () => { let sessionToolsEnabled = false; let dynamicToolsEnabled = false; @@ -250,6 +387,7 @@ suite('SessionServerTools', () => { const dynamicGroup: IServerToolGroup = { definitions: [{ name: 'dynamic_tool', description: 'Dynamic tool.', inputSchema: { type: 'object', properties: {} } }], isEnabled: () => dynamicToolsEnabled, + isEnabledForSession: () => true, execute: () => '', }; const host = new AgentServerToolHost(stateManager, [ @@ -272,6 +410,7 @@ suite('SessionServerTools', () => { enabledTools: [ SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, + SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, @@ -281,6 +420,7 @@ suite('SessionServerTools', () => { disabledTools: [ SessionServerToolName.ListSessions, SessionServerToolName.GetCurrentSession, + SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.SendMessage, SessionServerToolName.GetSessionContext, @@ -1426,6 +1566,138 @@ suite('SessionServerTools', () => { assert.throws(() => getSendMessageArgs({ session: 'copilot:/s2' }, []), /message/); }); + test('send_message queues agent-originated messages in FIFO order while the target chat is busy', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const targetSession = 'copilot:/s2'; + const targetChat = buildDefaultChatUri(targetSession); + stateManager.createSession({ + resource: targetSession, + provider: 'copilot', + title: 'Target', + status: SessionStatus.InProgress, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + stateManager.dispatchServerAction(targetChat, { + type: ActionType.ChatTurnStarted, + turnId: 'active-turn', + startedAt: new Date(0).toISOString(), + message: { text: 'running', origin: { kind: MessageKind.User } }, + }); + const prompts: string[] = []; + const group = createSessionServerToolGroup(createAccessor({ + listSessions: async () => [sessionMeta('s1', SessionStatus.InProgress, workspace), sessionMeta('s2', SessionStatus.InProgress, workspace)], + onPrompt: (_session, _chat, prompt) => { prompts.push(prompt); }, + })); + const context = executionContext('copilot:/s1'); + + const firstResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: targetSession, message: 'first' }); + const secondResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: targetSession, message: 'second' }); + + const targetState = stateManager.getChatState(targetChat); + assert.deepStrictEqual({ + results: [firstResult, secondResult], + activeTurn: targetState?.activeTurn?.id, + queuedMessages: targetState?.queuedMessages?.map(queued => ({ + text: queued.message.text, + origin: queued.message.origin, + delegation: readAgentMessageDelegationMeta(queued.message), + })), + prompts, + }, { + results: [ + 'Message queued (agent-host-session://copilot/s2).', + 'Message queued (agent-host-session://copilot/s2).', + ], + activeTurn: 'active-turn', + queuedMessages: [ + { + text: 'first', + origin: { kind: MessageKind.Agent }, + delegation: { + sourceSession: 'copilot:/s1', + sourceChat: buildDefaultChatUri('copilot:/s1'), + sourceTurnId: 'turn-1', + }, + }, + { + text: 'second', + origin: { kind: MessageKind.Agent }, + delegation: { + sourceSession: 'copilot:/s1', + sourceChat: buildDefaultChatUri('copilot:/s1'), + sourceTurnId: 'turn-1', + }, + }, + ], + prompts: [], + }); + store.dispose(); + }); + + test('send_message queues behind pending messages when the target chat has no active turn', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const queuedSession = 'copilot:/s2'; + const steeringSession = 'copilot:/s3'; + for (const resource of [queuedSession, steeringSession]) { + stateManager.createSession({ + resource, + provider: 'copilot', + title: 'Target', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + }); + } + const queuedChat = buildDefaultChatUri(queuedSession); + stateManager.dispatchServerAction(queuedChat, { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Queued, + id: 'older-message', + message: { text: 'older', origin: { kind: MessageKind.User } }, + }); + const steeringChat = buildDefaultChatUri(steeringSession); + stateManager.dispatchServerAction(steeringChat, { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Steering, + id: 'steering-message', + message: { text: 'steering', origin: { kind: MessageKind.User } }, + }); + const prompts: string[] = []; + const group = createSessionServerToolGroup(createAccessor({ + listSessions: async () => [ + sessionMeta('s1', SessionStatus.InProgress, workspace), + sessionMeta('s2', SessionStatus.Idle, workspace), + sessionMeta('s3', SessionStatus.Idle, workspace), + ], + onPrompt: (_session, _chat, prompt) => { prompts.push(prompt); }, + })); + const context = executionContext('copilot:/s1'); + + const queuedResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: queuedSession, message: 'after queued' }); + const steeringResult = await group.execute(stateManager, context, SessionServerToolName.SendMessage, { session: steeringSession, message: 'after steering' }); + + assert.deepStrictEqual({ + results: [queuedResult, steeringResult], + queuedMessages: stateManager.getChatState(queuedChat)?.queuedMessages?.map(message => message.message.text), + steeringQueuedMessages: stateManager.getChatState(steeringChat)?.queuedMessages?.map(message => message.message.text), + steeringMessage: stateManager.getChatState(steeringChat)?.steeringMessage?.message.text, + prompts, + }, { + results: [ + 'Message queued (agent-host-session://copilot/s2).', + 'Message queued (agent-host-session://copilot/s3).', + ], + queuedMessages: ['older', 'after queued'], + steeringQueuedMessages: ['after steering'], + steeringMessage: 'steering', + prompts: [], + }); + store.dispose(); + }); + suite('get_session_context', () => { const toolCall = (toolName: string, input: object): ToolCallState => ({ toolCallId: 't', toolName, displayName: toolName, diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts new file mode 100644 index 00000000000000..43caf85bbeaf1a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -0,0 +1,974 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../base/common/async.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 { NullLogService } from '../../../log/common/log.js'; +import { AgentWorkingDirectoryChangedError, type IAgent } from '../../common/agent.js'; +import { schemaProperty } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isMessageHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withSessionWorkspaceless, type ErrorInfo, type Message } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; +import type { IAgentHostTurnService, IDeferredAgentHostTurn } from '../../node/agentHostTurnService.js'; +import { SessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import type { IAgentHostServerToolService } from '../../node/shared/agentServerToolHost.js'; +import { NullAgentHostWorktreeIsolation, type IIsolationConfigContribution, type IResolveIsolationConfigRequest, type IResolveWorkingDirectoryRequest, type ISessionWorktree } from '../../node/shared/worktreeIsolation.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { MockAgent } from './mockAgent.js'; +import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; + +class TestWorktreeIsolation extends NullAgentHostWorktreeIsolation { + override readonly supported = true; + readonly requests: IResolveWorkingDirectoryRequest[] = []; + readonly createdWorktrees: URI[] = []; + readonly removedWorktrees: ISessionWorktree[] = []; + + constructor(readonly worktree: URI, readonly repository = URI.file('/workspace/project')) { + super(); + } + + override async resolveIsolationConfig(_request: IResolveIsolationConfigRequest): Promise { + return { + isolationProperty: schemaProperty<'folder' | 'worktree'>({ + type: 'string', + title: 'Isolation', + description: 'Isolation', + enum: ['folder', 'worktree'], + default: 'worktree', + }), + branchProperty: schemaProperty({ + type: 'string', + title: 'Branch', + description: 'Branch', + default: 'main', + }), + worktreeBranchPrefixProperty: undefined, + worktreeIncludeFilesProperty: undefined, + worktreeBranchTrackProperty: undefined, + worktreeCreateNewBranchProperty: undefined, + isolationValue: 'worktree', + branchDefault: 'main', + branchValue: 'main', + }; + } + + override async resolveOnFirstSend(request: IResolveWorkingDirectoryRequest): Promise { + this.requests.push(request); + await request.onWillCreate?.({ + repositoryRoot: this.repository, + worktreePath: this.worktree, + baseBranch: 'main', + branchName: 'feature', + }); + this.createdWorktrees.push(this.worktree); + return this.worktree; + } + + override sessionWorktreeProject(_sessionId: string): { uri: URI; displayName: string } { + return { uri: this.repository, displayName: 'project' }; + } + + override async prepareSessionDeletion(_sessionUri: URI, _sessionId: string): Promise { + return { repositoryRoot: this.repository, worktree: this.worktree }; + } + + override async removeSessionWorktree(_sessionId: string, worktree: ISessionWorktree | undefined): Promise { + if (worktree) { + this.removedWorktrees.push(worktree); + } + } + + override async discardSessionWorktree(_sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise { + await this.removeSessionWorktree(sessionId, worktree); + } +} + +class GatedConversionDatabase extends TestSessionDatabase { + readonly writeStarted = new DeferredPromise(); + readonly releaseWrite = new DeferredPromise(); + + override async setMetadataValues(values: Readonly>): Promise { + this.writeStarted.complete(); + await this.releaseWrite.p; + await super.setMetadataValues(values); + } +} + +suite('SessionWorkspaceConversionService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness( + worktreeIsolation = new NullAgentHostWorktreeIsolation(), + requestWorkspaceTrust: IAgentHostClientConnectionService['requestWorkspaceTrust'] = async () => true, + database = new TestSessionDatabase(), + ) { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const sessionDataService = createSessionDataService(database); + const agent = new MockAgent('copilot', { multipleChats: { fork: true } }, { workspaceConversion: true }); + disposables.add({ dispose: () => agent.dispose() }); + const providerService = createTestAgentHostProviderService(() => agent); + const trustRequests: { clientId: string; workspace: string; trustedParent?: string }[] = []; + const clientConnections = new class extends mock() { + override async requestWorkspaceTrust(clientId: string, request: { readonly workspace: string; readonly trustedParent?: string }): Promise { + trustRequests.push({ clientId, ...request }); + return requestWorkspaceTrust(clientId, request); + } + }(); + const continuations: { chat: string; message: Message }[] = []; + const deferredContinuations: { chat: string; message: Message; turnId: string }[] = []; + const failedContinuations: { chat: string; error: ErrorInfo; turnId: string }[] = []; + let deferredTurnCounter = 0; + const turnService = new class extends mock() { + override beginDeferredTurnMessage(targetChat: URI, message: Message): IDeferredAgentHostTurn { + const turnId = `continuation-${++deferredTurnCounter}`; + stateManager.dispatchServerAction(targetChat.toString(), { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date(2).toISOString(), + message, + }); + deferredContinuations.push({ chat: targetChat.toString(), message, turnId }); + return { turnId }; + } + + override continueDeferredTurnMessage(targetChat: URI, turn: IDeferredAgentHostTurn, message: Message): boolean { + if (stateManager.getActiveTurnId(targetChat.toString()) !== turn.turnId) { + return false; + } + continuations.push({ chat: targetChat.toString(), message }); + return true; + } + + override failDeferredTurnMessage(targetChat: URI, turn: IDeferredAgentHostTurn, error: ErrorInfo): boolean { + if (stateManager.getActiveTurnId(targetChat.toString()) !== turn.turnId) { + return false; + } + failedContinuations.push({ chat: targetChat.toString(), error, turnId: turn.turnId }); + stateManager.dispatchServerAction(targetChat.toString(), { + type: ActionType.ChatError, + turnId: turn.turnId, + duration: 1, + part: createErrorResponsePart(error), + }); + return true; + } + }(); + const refreshedServerTools: string[] = []; + const serverToolHost = new class extends mock() { + override advertise(targetSession: string): void { + refreshedServerTools.push(targetSession); + } + }(); + const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, clientConnections, turnService, serverToolHost, logService)); + const session = URI.parse('copilot:/workspace-less'); + const chat = URI.parse(buildDefaultChatUri(session)); + const scratch = URI.file('/tmp/copilot-scratch/workspace-less'); + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Workspace-less Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectories: [scratch.toString()], + _meta: withSessionWorkspaceless(undefined, true), + }); + return { service, stateManager, database, agent, session, chat, scratch, continuations, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools }; + } + + function startTurn(stateManager: AgentHostStateManager, chat: URI, turnId = 'turn-1'): void { + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date(1).toISOString(), + message: { text: 'Implement the feature', origin: { kind: MessageKind.User } }, + }); + } + + function completeTurn(stateManager: AgentHostStateManager, chat: URI, turnId = 'turn-1'): void { + stateManager.dispatchServerAction(chat.toString(), { + type: ActionType.ChatTurnComplete, + turnId, + duration: 1, + }); + } + + function updateSessionWorkspace(harness: ReturnType): Promise { + return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); + } + + test('keeps a visible continuation in progress while converting after the invoking turn', async () => { + const trustDecision = new DeferredPromise(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); + const workspaceFolder = URI.file('/workspace/project'); + const providerMutation = new DeferredPromise(); + const providerCalls: { chat: string; session: string; workspaceFolder: string }[] = []; + const customization = { + type: CustomizationType.Plugin, + id: customizationId('file:///workspace/project/plugin'), + uri: 'file:///workspace/project/plugin', + name: 'Workspace Plugin', + load: { kind: CustomizationLoadStatus.Loaded }, + } as const; + let stateWhenCustomizationsRefreshed: { workingDirectories: readonly string[] | undefined; workspaceless: boolean } | undefined; + harness.agent.getSessionCustomizations = async () => { + const state = harness.stateManager.getSessionState(harness.session.toString()); + stateWhenCustomizationsRefreshed = { + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + }; + return [customization]; + }; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (chat, context, workingDirectory) => { + providerCalls.push({ + chat: chat.toString(), + session: URI.isUri(context) ? context.toString() : context.resource.toString(), + workspaceFolder: workingDirectory.toString(), + }); + await providerMutation.p; + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await Promise.resolve(); + const stateDuringSetup = harness.stateManager.getSessionState(harness.session.toString()); + const chatDuringSetup = harness.stateManager.getChatState(harness.chat.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + providerCalls, + sessionStatus: stateDuringSetup?.status, + chatStatus: chatDuringSetup?.status, + activity: chatDuringSetup?.activity, + activeTurnId: chatDuringSetup?.activeTurn?.id, + responseParts: chatDuringSetup?.activeTurn?.responseParts, + deferredContinuations: harness.deferredContinuations.map(entry => ({ + chat: entry.chat, + hidden: isMessageHiddenFromTranscript(entry.message), + label: readMessageSystemInitiatedLabel(entry.message), + origin: entry.message.origin.kind, + text: entry.message.text, + turnId: entry.turnId, + })), + continuations: harness.continuations, + }, { + pending: true, + providerCalls: [], + sessionStatus: SessionStatus.InProgress, + chatStatus: SessionStatus.InProgress, + activity: undefined, + activeTurnId: 'continuation-1', + responseParts: [], + deferredContinuations: [{ + chat: harness.chat.toString(), + hidden: false, + label: 'Continue in Requested Workspace', + origin: MessageKind.SystemNotification, + text: 'Continue in the requested workspace.', + turnId: 'continuation-1', + }], + continuations: [], + }); + trustDecision.complete(true); + await Promise.resolve(); + providerMutation.complete(); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; + assert.deepStrictEqual({ + providerCalls, + trustRequests: harness.trustRequests, + pending: harness.service.isPending(harness.chat.toString()), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + refreshedServerTools: harness.refreshedServerTools, + stateWhenCustomizationsRefreshed, + customizations: state?.customizations, + activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + continuations: harness.continuations.map(entry => ({ + chat: entry.chat, + hidden: isMessageHiddenFromTranscript(entry.message), + label: readMessageSystemInitiatedLabel(entry.message), + origin: entry.message.origin.kind, + text: entry.message.text, + })), + }, { + providerCalls: [{ + chat: harness.chat.toString(), + session: harness.session.toString(), + workspaceFolder: 'file:///workspace/project', + }], + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/project', + }], + pending: false, + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + persistedWorkspaceless: 'false', + refreshedServerTools: [harness.session.toString()], + stateWhenCustomizationsRefreshed: { + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + }, + customizations: [customization], + activity: undefined, + activeTurnId: 'continuation-1', + outcomeNotifications: ['Workspace Set'], + continuations: [{ + chat: harness.chat.toString(), + hidden: false, + label: 'Workspace Set', + origin: MessageKind.SystemNotification, + text: `The current session is now attached to ${workspaceFolder.fsPath}. Continue the user's original task in this workspace. Do not request another session or workspace conversion.`, + }], + }); + }); + + test('creates an isolated worktree and sets it as the workspace', async () => { + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); + const harness = createHarness(worktreeIsolation); + const workspaceFolder = URI.file('/workspace/project'); + const providerCalls: string[] = []; + const projectNotifications: Array<{ uri: string; displayName: string } | undefined> = []; + disposables.add(harness.stateManager.onDidChangeSessionSummary(event => { + if (event.session === harness.session.toString()) { + projectNotifications.push(event.changes.project); + } + })); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + await timeout(120); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const summary = harness.stateManager.getSessionSummary(harness.session.toString()); + assert.deepStrictEqual({ + worktreeRequests: worktreeIsolation.requests.map(request => ({ + session: request.sessionUri.toString(), + workspaceFolder: request.workingDirectory?.toString(), + prompt: request.prompt, + isolation: request.config?.[SessionConfigKey.Isolation], + branch: request.config?.[SessionConfigKey.Branch], + })), + trustRequests: harness.trustRequests, + providerCalls, + sessionStateProject: state?.project, + summaryProject: summary?.project, + workingDirectories: state?.workingDirectories, + projectNotifications, + isolation: state?.config?.values[SessionConfigKey.Isolation], + branch: state?.config?.values[SessionConfigKey.Branch], + persistedConfig: JSON.parse((await harness.database.getMetadata('configValues')) ?? '{}'), + continuationText: harness.continuations[0]?.message.text, + }, { + worktreeRequests: [{ + session: harness.session.toString(), + workspaceFolder: workspaceFolder.toString(), + prompt: 'Implement the feature', + isolation: 'worktree', + branch: 'main', + }], + trustRequests: [{ + clientId: 'client-1', + workspace: workspaceFolder.toString(), + }, { + clientId: 'client-1', + workspace: worktreeIsolation.worktree.toString(), + trustedParent: workspaceFolder.toString(), + }], + providerCalls: [worktreeIsolation.worktree.toString()], + sessionStateProject: undefined, + summaryProject: { + uri: workspaceFolder.toString(), + displayName: 'project', + }, + workingDirectories: [worktreeIsolation.worktree.toString()], + projectNotifications: [{ + uri: workspaceFolder.toString(), + displayName: 'project', + }], + isolation: 'worktree', + branch: 'main', + persistedConfig: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'main', + }, + continuationText: `The current session is now attached to ${worktreeIsolation.worktree.fsPath} in an isolated worktree. Continue the user's original task in this workspace. Do not request another session or workspace conversion.`, + }); + }); + + test('keeps the session workspace-less when workspace trust is declined', async () => { + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); + const workspaceFolder = URI.file('/workspace/project'); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + providerCalls, + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + continuation: harness.continuations.map(entry => ({ + label: readMessageSystemInitiatedLabel(entry.message), + text: entry.message.text, + })), + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/project', + }], + providerCalls: [], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + persistedWorkspaceless: 'true', + continuation: [{ + label: 'Workspace Setup Failed', + text: `The requested workspace setup did not complete successfully: Workspace trust was not granted for '${workspaceFolder.fsPath}'. Do not run the user's task. Tell the user that workspace setup failed and include this error.`, + }], + }); + }); + + test('does not create a worktree when trust for it is declined', async () => { + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); + let trustRequestCount = 0; + const harness = createHarness(worktreeIsolation, async () => ++trustRequestCount === 1); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _context, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + await harness.database.setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + providerCalls, + createdWorktrees: worktreeIsolation.createdWorktrees, + removedWorktrees: worktreeIsolation.removedWorktrees, + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/project', + }, { + clientId: 'client-1', + workspace: worktreeIsolation.worktree.toString(), + trustedParent: 'file:///workspace/project', + }], + providerCalls: [], + createdWorktrees: [], + removedWorktrees: [], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + }); + }); + + test('trusts the repository root before creating an isolated worktree', async () => { + const repository = URI.file('/workspace/project'); + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature'), repository); + const harness = createHarness(worktreeIsolation); + const workspaceFolder = URI.file('/workspace/project/packages/app'); + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', workspaceFolder, true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + createdWorktrees: worktreeIsolation.createdWorktrees, + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: workspaceFolder.toString(), + }, { + clientId: 'client-1', + workspace: repository.toString(), + }, { + clientId: 'client-1', + workspace: worktreeIsolation.worktree.toString(), + trustedParent: repository.toString(), + }], + createdWorktrees: [worktreeIsolation.worktree], + }); + }); + + test('removes a newly created worktree when provider mutation fails', async () => { + const worktreeIsolation = new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/implement-feature')); + const harness = createHarness(worktreeIsolation); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new Error('provider failed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + removedWorktrees: worktreeIsolation.removedWorktrees.map(entry => ({ + repositoryRoot: entry.repositoryRoot.toString(), + worktree: entry.worktree.toString(), + })), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuationText: harness.continuations[0]?.message.text, + }, { + removedWorktrees: [{ + repositoryRoot: 'file:///workspace/project', + worktree: worktreeIsolation.worktree.toString(), + }], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuationText: 'The requested workspace setup did not complete successfully: provider failed. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', + }); + }); + + test('keeps the session workspace-less and continues with a visible failure explanation request', async () => { + const harness = createHarness(); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new Error('provider failed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuationHidden: harness.continuations[0] ? isMessageHiddenFromTranscript(harness.continuations[0].message) : undefined, + continuationLabel: harness.continuations[0] ? readMessageSystemInitiatedLabel(harness.continuations[0].message) : undefined, + continuationOrigin: harness.continuations[0]?.message.origin.kind, + continuationText: harness.continuations[0]?.message.text, + outcomeNotifications: activeTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + }, { + pending: false, + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuationHidden: false, + continuationLabel: 'Workspace Setup Failed', + continuationOrigin: MessageKind.SystemNotification, + continuationText: 'The requested workspace setup did not complete successfully: provider failed. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', + outcomeNotifications: ['Workspace Setup Failed'], + }); + }); + + test('adopts an irreversible provider directory before reporting an alignment failure', async () => { + const harness = createHarness(); + const authoritative = URI.file('/workspace/authoritative'); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(authoritative, 'SDK returned a different directory'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/requested'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + persistedWorkspaceless: await harness.database.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + continuationText: harness.continuations[0]?.message.text, + }, { + workingDirectories: ['file:///workspace/authoritative'], + workspaceless: false, + persistedWorkspaceless: 'false', + continuationText: `The requested workspace setup did not complete successfully: The workspace changed to '${authoritative.fsPath}', but conversion did not complete cleanly: SDK returned a different directory. Do not run the user's task. Tell the user that workspace setup failed and include this error.`, + }); + }); + + test('disposes the provider without continuing when its authoritative directory is not trusted', async () => { + let trustRequestCount = 0; + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => ++trustRequestCount === 1); + const authoritative = URI.file('/workspace/authoritative'); + const disposedChats: { session: string; chat: string }[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(authoritative, 'SDK returned a different directory'); + }; + harness.agent.disposeChat = async (session, chat) => { + disposedChats.push({ session: session.toString(), chat: chat.toString() }); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/requested'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + const endedTurn = harness.stateManager.getChatState(harness.chat.toString())?.turns.at(-1); + assert.deepStrictEqual({ + trustRequests: harness.trustRequests, + disposedChats, + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await harness.database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + failedContinuations: harness.failedContinuations, + activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + outcomeNotifications: endedTurn?.responseParts.flatMap(part => part.kind === ResponsePartKind.SystemNotification ? [part.content] : []), + }, { + trustRequests: [{ + clientId: 'client-1', + workspace: 'file:///workspace/requested', + }, { + clientId: 'client-1', + workspace: authoritative.toString(), + }], + disposedChats: [{ + session: harness.session.toString(), + chat: harness.chat.toString(), + }], + pending: true, + persistedQuarantine: 'true', + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuations: [], + failedContinuations: [{ + chat: harness.chat.toString(), + error: { + errorType: 'workspaceConversionFailed', + message: `The provider changed to an untrusted working directory and was disposed: Workspace trust was not granted for '${authoritative.fsPath}'`, + }, + turnId: 'continuation-1', + }], + activity: undefined, + activeTurnId: undefined, + outcomeNotifications: ['Workspace Setup Failed'], + }); + }); + + test('does not continue a setup turn that the user cancelled during conversion', async () => { + const trustDecision = new DeferredPromise(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await Promise.resolve(); + harness.stateManager.dispatchServerAction(harness.chat.toString(), { + type: ActionType.ChatTurnCancelled, + turnId: 'continuation-1', + duration: 1, + }); + trustDecision.complete(true); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + failedContinuations: harness.failedContinuations, + activity: harness.stateManager.getChatState(harness.chat.toString())?.activity, + activeTurnId: harness.stateManager.getActiveTurnId(harness.chat.toString()), + }, { + workingDirectories: ['file:///workspace/project'], + workspaceless: false, + continuations: [], + failedContinuations: [], + activity: undefined, + activeTurnId: undefined, + }); + }); + + test('durably quarantines the session when an untrusted provider cannot be disposed', async () => { + let trustRequestCount = 0; + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => ++trustRequestCount === 1); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(URI.file('/workspace/authoritative'), 'SDK returned a different directory'); + }; + harness.agent.disposeChat = async () => { + throw new Error('dispose failed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/requested'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await harness.database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: 'true', + continuations: [], + }); + }); + + test('atomically persists conversion metadata or quarantines before publishing state', async () => { + class FailingConversionDatabase extends TestSessionDatabase { + override async setMetadataValues(): Promise { + throw new Error('atomic commit failed'); + } + } + const database = new FailingConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: 'true', + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuations: [], + }); + }); + + test('keeps the session quarantined in memory when durable quarantine persistence fails', async () => { + class FailingQuarantineDatabase extends TestSessionDatabase { + override async setMetadataValues(): Promise { + throw new Error('atomic commit failed'); + } + + override async setMetadata(key: string, value: string): Promise { + if (key === AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY) { + throw new Error('quarantine persistence failed'); + } + await super.setMetadata(key, value); + } + } + const database = new FailingQuarantineDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: undefined, + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuations: [], + }); + }); + + test('does not mutate the provider when the session is archived before conversion starts', async () => { + const harness = createHarness(); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async (_chat, _session, workingDirectory) => { + providerCalls.push(workingDirectory.toString()); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionIsArchivedChanged, + isArchived: true, + }); + completeTurn(harness.stateManager, harness.chat); + + await updateSessionWorkspace(harness); + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + archived: state ? (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived : undefined, + pending: harness.service.isPending(harness.chat.toString()), + providerCalls, + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + continuationText: harness.continuations[0]?.message.text, + }, { + archived: true, + pending: false, + providerCalls: [], + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + continuationText: 'The requested workspace setup did not complete successfully: An archived session cannot be converted to a workspace session. Do not run the user\'s task. Tell the user that workspace setup failed and include this error.', + }); + }); + + test('quarantines without publishing when session state changes during conversion metadata persistence', async () => { + const database = new GatedConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const disposedChats: { session: string; chat: string }[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + harness.agent.disposeChat = async (session, chat) => { + disposedChats.push({ session: session.toString(), chat: chat.toString() }); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await database.writeStarted.p; + const replacement = URI.file('/workspace/other'); + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: harness.scratch.toString(), + replacement: replacement.toString(), + }); + database.releaseWrite.complete(); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + disposedChats, + continuations: harness.continuations, + }, { + pending: true, + persistedQuarantine: 'true', + workingDirectories: [replacement.toString()], + workspaceless: true, + disposedChats: [{ + session: harness.session.toString(), + chat: harness.chat.toString(), + }], + continuations: [], + }); + }); + + test('quarantines without publishing when the session is archived during conversion metadata persistence', async () => { + const database = new GatedConversionDatabase(); + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, database); + const disposedChats: { session: string; chat: string }[] = []; + const provider: IAgent = harness.agent; + provider.setWorkingDirectory = async () => { }; + harness.agent.disposeChat = async (session, chat) => { + disposedChats.push({ session: session.toString(), chat: chat.toString() }); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + + const conversion = updateSessionWorkspace(harness); + await database.writeStarted.p; + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionIsArchivedChanged, + isArchived: true, + }); + database.releaseWrite.complete(); + await conversion; + + const state = harness.stateManager.getSessionState(harness.session.toString()); + assert.deepStrictEqual({ + archived: state ? (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived : undefined, + pending: harness.service.isPending(harness.chat.toString()), + persistedQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + workingDirectories: state?.workingDirectories, + workspaceless: readSessionWorkspaceless(state?._meta), + disposedChats, + continuations: harness.continuations, + }, { + archived: true, + pending: true, + persistedQuarantine: 'true', + workingDirectories: [harness.scratch.toString()], + workspaceless: true, + disposedChats: [{ + session: harness.session.toString(), + chat: harness.chat.toString(), + }], + continuations: [], + }); + }); + + test('rejects invalid requests and clears cancelled conversions', async () => { + const harness = createHarness(); + startTurn(harness.stateManager, harness.chat); + + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'other-turn', URI.file('/workspace/project'), false, 'client-1'), /active turn/); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.parse('vscode-remote://host/workspace/project'), false, 'client-1'), /absolute local path or file URI/); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), false, 'client-1'); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/other'), false, 'client-1'), /already pending/); + completeTurn(harness.stateManager, harness.chat); + + harness.service.cancel(harness.chat.toString(), 'turn-1'); + + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + continuations: harness.continuations, + }, { + pending: false, + continuations: [], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index f091cf29180679..c2d3705bed5ffd 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -13,7 +13,7 @@ import { basename, getComparisonKey } from '../../../../../base/common/resources import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../log/common/log.js'; -import { GitRefType, IAgentHostGitService, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; +import { GitRefType, IAgentHostGitService, META_DIFF_BASE_BRANCH, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type Turn } from '../../../common/state/sessionState.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; @@ -1103,6 +1103,31 @@ suite('WorktreeIsolation', () => { }); }); + test('discardSessionWorktree removes provisional worktree metadata', async () => { + const isolation = createIsolation(disposables); + const worktree = await isolation.resolveWorkingDirectory({ sessionUri, sessionId, workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' } }); + + await isolation.discardSessionWorktree(sessionUri, sessionId, await isolation.prepareSessionDeletion(sessionUri, sessionId)); + + assert.deepStrictEqual({ + removeCalls: removeCalls.map(call => ({ worktree: call.worktree.toString(), force: call.force })), + metadata: await db.getMetadataObject({ + 'copilot.worktree.branchName': true, + 'copilot.worktree.path': true, + 'copilot.worktree.repositoryRoot': true, + [META_DIFF_BASE_BRANCH]: true, + }), + }, { + removeCalls: [{ worktree: worktree!.toString(), force: true }], + metadata: { + 'copilot.worktree.branchName': undefined, + 'copilot.worktree.path': undefined, + 'copilot.worktree.repositoryRoot': undefined, + [META_DIFF_BASE_BRANCH]: undefined, + }, + }); + }); + test('session deletion removes a persisted worktree after a process restart', async () => { const worktree = URI.joinPath(worktreesRoot, 'persisted-worktree'); mkdirSync(worktree.fsPath, { recursive: true }); diff --git a/src/vs/platform/github/common/githubTransport.ts b/src/vs/platform/github/common/githubTransport.ts index a7ccf5cd8a11b8..e7452b6d590d3c 100644 --- a/src/vs/platform/github/common/githubTransport.ts +++ b/src/vs/platform/github/common/githubTransport.ts @@ -212,7 +212,6 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { for (let redirectCount = 0; redirectCount <= maximumRedirects; redirectCount++) { const headers: Record = { 'Accept': authenticated ? 'application/vnd.github+json' : 'text/plain, application/octet-stream', - 'Cache-Control': 'no-store', 'X-GitHub-Api-Version': defaultApiVersion, }; if (authenticated) { @@ -322,7 +321,6 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${token}`, - 'Cache-Control': 'no-store', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': defaultApiVersion, }, @@ -415,7 +413,6 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { const headers: Record = { 'Accept': request.accept ?? 'application/vnd.github+json', 'Authorization': `Bearer ${token}`, - 'Cache-Control': 'no-store', 'X-GitHub-Api-Version': request.apiVersion ?? defaultApiVersion, }; if (cached) { diff --git a/src/vs/platform/github/test/node/githubTransport.test.ts b/src/vs/platform/github/test/node/githubTransport.test.ts index 4b40dc6737197b..a3c2cda1ed7dcd 100644 --- a/src/vs/platform/github/test/node/githubTransport.test.ts +++ b/src/vs/platform/github/test/node/githubTransport.test.ts @@ -33,7 +33,7 @@ suite('GitHubTransport', () => { } } - test('always reaches the injected fetch with explicit no-store behavior', async () => { + test('uses fetch cache mode without a Cache-Control request header', async () => { await withServer(async server => { server.enqueue( gitHubRestStep({ method: 'GET', path: '/repos/o/r/issues/1', response: gitHubJsonResponse({ value: 1 }) }), @@ -60,8 +60,8 @@ suite('GitHubTransport', () => { values: [1, 2], serverRequests: 2, fetchOptions: [ - { cache: 'no-store', cacheControl: 'no-store' }, - { cache: 'no-store', cacheControl: 'no-store' }, + { cache: 'no-store', cacheControl: undefined }, + { cache: 'no-store', cacheControl: undefined }, ], }); server.assertSatisfied(); @@ -245,7 +245,7 @@ suite('GitHubTransport', () => { errors: [{ message: 'field denied', type: 'FORBIDDEN', path: ['repository', 'viewerPermission'] }], rateLimit: { limit: 5000, remaining: 7, used: 3, resetAt: Date.parse('2030-01-01T00:00:00.000Z') }, authorizationIsExpected: true, - requestHeaders: { cacheControl: 'no-store', authorization: '******' }, + requestHeaders: { cacheControl: undefined, authorization: '******' }, }); server.assertSatisfied(); }); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 8fe945cbcda2c4..da7352e8f19245 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -69,6 +69,9 @@ export const COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY = 'forceRemoteSettingsRef */ export const COPILOT_SANDBOX_ENABLED_KEY = 'sandbox.enabled'; +/** Managed-settings key that permits explicitly bypassing the sandbox. */ +export const COPILOT_SANDBOX_ALLOW_BYPASS_KEY = 'sandbox.allowBypass'; + /** * Managed-settings controls consumed by the delivery pipeline itself rather than by a * configuration policy. Native MDM must watch these even though no setting declares them. @@ -76,6 +79,7 @@ export const COPILOT_SANDBOX_ENABLED_KEY = 'sandbox.enabled'; export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinitions = { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, }; /** Policy-only configuration delivery slot for {@link COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY}. */ diff --git a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts index 9d98995a30bbeb..9e88d1c884768a 100644 --- a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts @@ -10,7 +10,7 @@ import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; import { NativeManagedSettingsChannelClient } from '../../common/nativeManagedSettingsIpc.js'; import { PolicyValue } from '../../common/policy.js'; import { NativeManagedSettingsService, NativePolicyWatcherFactory } from '../../node/nativeManagedSettingsService.js'; @@ -27,6 +27,7 @@ suite('NativeManagedSettingsService', () => { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' }, [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, }); onDidChange = callback; callback({}); @@ -68,6 +69,7 @@ suite('NativeManagedSettingsService', () => { watchedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, + [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, }, managedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, }); diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index efdcd9e56de16f..dc0c5601229260 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -179,6 +179,7 @@ suite('TerminalSandboxEngine', () => { fileService = new MockFileService(); sandboxSettings.set(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + sandboxSettings.set(AgentSandboxSettingId.AgentSandboxAllowNetwork, false); sandboxSettings.set(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); instantiationService.stub(IFileService, fileService); diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index dd6bc1ad8af62d..1843789366c4ae 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -13,10 +13,13 @@ import { localize } from '../../../nls.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../platform/instantiation/common/serviceCollection.js'; +import { IContextKey, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { getChatSessionArchiveActionPresentation, getChatSessionArchiveActionWording } from '../../../platform/chat/common/sessionArchiveActions.js'; import { ChatInteractivity, IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { UNARCHIVE_SESSION_COMMAND_ID } from '../../common/sessionCommands.js'; +import { SessionFocusedChatIsRenameTargetContext } from '../../common/contextkeys.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ChatCompositeBar, IChatCompositeBarDelegate } from './chatCompositeBar.js'; import { type IRemoteHostUnavailableEmptyStateContent, RemoteHostUnavailableEmptyState } from './remoteHostUnavailableEmptyState.js'; @@ -97,6 +100,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { private readonly _currentView = this._register(new MutableDisposable()); private readonly _contextDisposables = this._register(new DisposableStore()); + private readonly _scopedInstantiationService: IInstantiationService; + private readonly _focusedChatIsRenameTargetKey: IContextKey; private readonly _connection: SessionRemoteConnection; /** The configured wording for the archive/unarchive action (Archive vs Delete). */ @@ -120,8 +125,12 @@ export class ChatGroupView extends Disposable implements ISerializableView { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ICommandService private readonly _commandService: ICommandService, @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + const scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); + this._scopedInstantiationService = this._register(this._instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + this._focusedChatIsRenameTargetKey = SessionFocusedChatIsRenameTargetContext.bindTo(scopedContextKeyService); // Assigned here rather than as a field initializer: `_instantiationService` // is a parameter property of this class, which class-field semantics @@ -137,7 +146,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._barContainer = $('.chat-group-view-bar'); this.element.appendChild(this._barContainer); - this._compositeBar = this._register(this._instantiationService.createInstance(ChatCompositeBar, undefined)); + this._compositeBar = this._register(this._scopedInstantiationService.createInstance(ChatCompositeBar, undefined)); this._barContainer.appendChild(this._compositeBar.element); // Single status banner, shown flush below this group's tab bar when the @@ -179,6 +188,7 @@ export class ChatGroupView extends Disposable implements ISerializableView { this._connection.setSession(context?.session); if (!context) { + this._focusedChatIsRenameTargetKey.reset(); this._compositeBar.setGroup(undefined); this._currentView.clear(); this._setRemoteHostUnavailableEmptyState(undefined); @@ -203,6 +213,11 @@ export class ChatGroupView extends Disposable implements ISerializableView { const activeResource = context.activeChatResource.read(reader); return context.chats.read(reader).find(c => c.resource.toString() === activeResource); }); + this._contextDisposables.add(autorun(reader => { + const activeResource = context.activeChatResource.read(reader); + const mainResource = context.mainChatResource.read(reader); + this._focusedChatIsRenameTargetKey.set(activeResource !== mainResource); + })); const currentView = observableValue(this._contextDisposables, this._currentView.value); const readOnlyContent = derived(reader => { @@ -238,7 +253,6 @@ export class ChatGroupView extends Disposable implements ISerializableView { if (recovery) { return { banner: undefined, recovery }; } - return { banner: this._connection.bannerContent.read(reader), recovery: undefined }; }); @@ -260,8 +274,8 @@ export class ChatGroupView extends Disposable implements ISerializableView { let view = this._currentView.value; if (!view || view.kind !== desiredKind) { view = desiredKind === 'chat' - ? this._chatViewFactory.createChatView() - : this._chatViewFactory.createNewChatView(desiredKind === 'newChatInSession', context.options); + ? this._chatViewFactory.createChatView(this._scopedInstantiationService) + : this._chatViewFactory.createNewChatView(desiredKind === 'newChatInSession', context.options, this._scopedInstantiationService); this._contentContainer.replaceChildren(view.element, this._remoteHostUnavailableEmptyState.domNode); this._currentView.value = view; currentView.set(view, undefined); diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index da55bf5bce6c39..5298c64ae5d3e3 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatGroupsView.css'; -import { $, size } from '../../../base/browser/dom.js'; +import { $, isAncestorOfActiveElement, size } from '../../../base/browser/dom.js'; import { Color } from '../../../base/common/color.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; @@ -669,6 +669,19 @@ export class ChatGroupsView extends Themable { this._persistLayout(); } + getFocusedChat(): IChat | undefined { + const group = this._getFocusedGroup(); + if (!group) { + return undefined; + } + const activeResource = group.activeResourceId.get(); + return group.chats.get().find(chat => chat.resource.toString() === activeResource); + } + + private _getFocusedGroup(): IGroupEntry | undefined { + return this._groups.find(group => isAncestorOfActiveElement(group.view.element)); + } + /** * Handles focus entering a group: promotes it to the active group and, when * that group is currently collapsed to its minimum size in a split, expands it diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 7b8e1e96e96411..5513c53b22ffdc 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -14,6 +14,7 @@ import { ServiceCollection } from '../../../platform/instantiation/common/servic import { IContextKey, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; +import { IChat } from '../../services/sessions/common/session.js'; import { AbstractChatView, IChatViewOptions } from './chatView.js'; import { ChatGroupsView } from './chatGroupsView.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; @@ -284,6 +285,14 @@ export class SessionView extends Disposable implements ISerializableView { return this._isVisible && this._header.startTitleEditing(); } + getFocusedChat(): IChat | undefined { + return this._groupsView.getFocusedChat(); + } + + getSession(): IActiveSession | undefined { + return this._currentSession; + } + selectWorkspace(folderUri: URI, providerId?: string): void { const standaloneView = this._standaloneView.value; standaloneView ? standaloneView.selectWorkspace(folderUri, providerId) : this._groupsView.selectWorkspace(folderUri, providerId); diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index d1c0c890c7bff7..413b18caa54043 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -15,7 +15,7 @@ import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js' import { Direction, SerializableGrid, Sizing } from '../../../base/browser/ui/grid/grid.js'; import { Part } from '../../../workbench/browser/part.js'; import { ActiveSessionsContext, MultipleSessionsVisibleContext, SessionsFocusContext } from '../../common/contextkeys.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, trackFocus } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, isAncestorOfActiveElement, trackFocus } from '../../../base/browser/dom.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { SessionView } from './sessionView.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; @@ -288,6 +288,10 @@ export class SessionsPart extends Part { return this._slots.find(s => s.boundSessionId === sessionId)?.view; } + getFocusedSessionView(): SessionView | undefined { + return this._slots.find(slot => isAncestorOfActiveElement(slot.view.element))?.view; + } + /** * Moves keyboard focus into the session view hosting the given session id (or * the placeholder view when `sessionId` is `undefined`), first revealing it in diff --git a/src/vs/sessions/browser/parts/sessionsParts.ts b/src/vs/sessions/browser/parts/sessionsParts.ts index 39791dd3ee5b90..54fe1f4da0c32c 100644 --- a/src/vs/sessions/browser/parts/sessionsParts.ts +++ b/src/vs/sessions/browser/parts/sessionsParts.ts @@ -77,6 +77,10 @@ export class SessionsParts extends Disposable implements ISessionsPartService { return this._mainPart.getSessionView(sessionId); } + getFocusedSessionView(): SessionView | undefined { + return this._mainPart.getFocusedSessionView(); + } + getProgressIndicator(): IProgressIndicator { return this._mainPart.getProgressIndicator(); } diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b7ed0dd55b1c54..b07fe04791440e 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -82,9 +82,20 @@ import { ICustomViewGridPartService } from '../services/customView/browser/custo import { ICustomViewDescriptor } from '../services/customView/browser/customView.js'; import { ISessionsSetUpService } from './sessionsSetUpService.js'; import { AGENTS_FLOATING_PANEL_GAP } from '../common/layoutConstants.js'; +import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; const PHONE_NOTIFICATION_ROW_HEIGHT = 44; +type SessionsWindowLayoutEvent = { + layout: string; +}; + +type SessionsWindowLayoutClassification = { + owner: 'sandy081'; + comment: 'Tracks the layout selected when an Agents window opens.'; + layout: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Agents window layout selected at startup: classic or sidePane.' }; +}; + //#region Workbench Options export interface IWorkbenchOptions { @@ -604,6 +615,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic })); SinglePaneLayoutEnabledContext.bindTo(contextKeyService).set(this.isSinglePaneLayoutEnabled); + this.logWindowLayout(accessor.get(ITelemetryService)); // Virtual keyboard tracking (visualViewport): publishes the // keyboard height as an observable, mirrors it onto the @@ -652,6 +664,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } } + private logWindowLayout(telemetryService: ITelemetryService): void { + telemetryService.publicLog2('agents/windowLayout', { + layout: this.isSinglePaneLayoutEnabled ? 'sidePane' : 'classic' + }); + } + private initServices(serviceCollection: ServiceCollection): IInstantiationService { // Layout Service serviceCollection.set(IAgentWorkbenchLayoutService, this); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 151124e19ced65..3c195263dd6747 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -35,6 +35,7 @@ export const SessionHasSideChatsContext = new RawContextKey('sessionHas export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); +export const SessionFocusedChatIsRenameTargetContext = new RawContextKey('sessionFocusedChatIsRenameTarget', false, localize('sessionFocusedChatIsRenameTarget', "Whether the focused chat group's visible chat is a non-main chat that should receive the chat-specific rename command instead of the session rename command")); export const SessionActiveChatIsDeletableContext = new RawContextKey('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); export const SessionIsReadContext = new RawContextKey('sessionIsRead', true, localize('sessionIsRead', "Whether the session has been marked as read")); export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); diff --git a/src/vs/sessions/common/sessionCommands.ts b/src/vs/sessions/common/sessionCommands.ts index fd1ef1b11b64a9..8d3af813129950 100644 --- a/src/vs/sessions/common/sessionCommands.ts +++ b/src/vs/sessions/common/sessionCommands.ts @@ -16,6 +16,9 @@ export const UNARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.unarchiveSession'; /** Renames a session. Registered in `sessionsViewActions.ts`. */ export const RENAME_SESSION_COMMAND_ID = 'sessionsViewPane.renameSession'; +/** Renames a chat. Registered in `sessionsActions.ts`. */ +export const RENAME_CHAT_COMMAND_ID = 'sessions.chatCompositeBar.renameChat'; + /** Archives one or more sessions. Registered in `sessionsViewActions.ts`. */ export const ARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.archiveSession'; diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css index 297b2904768994..4e5d1ec6c8560b 100644 --- a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditorInput.css @@ -18,6 +18,5 @@ color: var(--vscode-agentsBadge-foreground); font-size: var(--vscode-fontSize-label3); font-weight: var(--vscode-fontWeight-semiBold); - line-height: normal; opacity: 1; } diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 6d2df37212794a..93c5cd32fb595c 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -732,11 +732,11 @@ export class ChatViewFactory implements IChatViewFactory { @IInstantiationService private readonly instantiationService: IInstantiationService ) { } - createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions): AbstractChatView { - return this.instantiationService.createInstance(NewChatView, isNewChatInSession, options); + createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions, instantiationService = this.instantiationService): AbstractChatView { + return instantiationService.createInstance(NewChatView, isNewChatInSession, options); } - createChatView(): AbstractChatView { - return this.instantiationService.createInstance(ChatView); + createChatView(instantiationService = this.instantiationService): AbstractChatView { + return instantiationService.createInstance(ChatView); } } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatView.css b/src/vs/sessions/contrib/chat/browser/media/chatView.css index 85dfa9afa1930c..c62c86613a223c 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatView.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatView.css @@ -250,6 +250,19 @@ display: none; } +.monaco-workbench.agent-sessions-workbench .interactive-session .chat-input-picker-item > .action-label:focus, +.monaco-workbench.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot > .action-label:focus, +.monaco-workbench.agent-sessions-workbench .interactive-session .agent-host-chat-input-picker-slot > .action-label:focus { + outline: none; +} + +.monaco-workbench.agent-sessions-workbench .interactive-session .chat-input-picker-item > .action-label:focus-visible, +.monaco-workbench.agent-sessions-workbench .interactive-session .sessions-chat-picker-slot > .action-label:focus-visible, +.monaco-workbench.agent-sessions-workbench .interactive-session .agent-host-chat-input-picker-slot > .action-label:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + .agent-sessions-workbench .interactive-session .compact-picker .sessions-chat-dropdown-label { display: none; } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 2ee020e540240d..75b92c4c4a82ca 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -16,7 +16,7 @@ import { FOCUS_AI_CUSTOMIZATION_VIEW_ID } from '../../aiCustomizationTreeView/br import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { REPLACE_PROMPT_TEMPLATE_PLACEHOLDER_COMMAND_ID } from './promptTemplatePlaceholder.js'; -import { ARCHIVE_SESSION_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { ARCHIVE_SESSION_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 120; readonly name = 'sessionsChat'; @@ -77,7 +77,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.goForward', "Go forward through visited sessions{0}.", '')); content.push(localize('sessionsChat.navigatePreviousSession', "Navigate to the previous session in the list{0}.", '')); content.push(localize('sessionsChat.navigateNextSession', "Navigate to the next session in the list{0}.", '')); - content.push(localize('sessionsChat.renameSession', "To rename a session, focus it in the Sessions list or focus its chat transcript or input, then invoke Rename{0}. You can also double-click its title in the Sessions list or open its context menu and choose Rename.", ``)); + content.push(localize('sessionsChat.renameSession', "To rename a session, focus its row in the Sessions list or focus its main chat transcript or input, then invoke Rename{0}. You can also double-click its title in the Sessions list or open its context menu and choose Rename.", ``)); + content.push(localize('sessionsChat.renameChat', "When Rename is available for a non-main chat, focus its transcript or input or its nested row in the Sessions list, then invoke Rename{0}.", ``)); content.push(localize('sessionsChat.archiveSession', "To archive or mark one or more sessions as done, focus them in the Sessions list and invoke Archive or Mark as Done{0}.", ``)); content.push(localize('sessionsChat.deleteSession', "To permanently delete a session, open its context menu and choose Delete. This is destructive and cannot be undone.")); content.push(localize('sessionsChat.changes', "Focus the Changes view{0}.", '')); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 4015523a2a47a4..1944b6b622f501 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -35,7 +35,7 @@ import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMet import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isChatAction, isSessionAction, NotificationType, type SessionSummaryChanges } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -632,6 +632,12 @@ function toPresentedSessionStatus(owner: object, status: IObservable; +type AgentHostSessionSummaryWorkspaceMetadata = { + project?: IAgentSessionMetadata['project']; + workingDirectories?: IAgentSessionMetadata['workingDirectories']; +}; + /** * Maps the protocol {@link ProtocolChatInteractivity} to the provider-agnostic * {@link ChatInteractivity}. Absent interactivity defaults to {@link @@ -912,9 +918,9 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { */ readonly backendUri: URI; - // Retained so we can rebuild `workspace` when only `_meta` changes via - // a `SessionMetaChanged` action dispatched on session open (without a full - // list refresh). See `_applySessionMetaFromState` / `setMeta`. + // Retained so we can rebuild `workspace` when session state changes via + // actions dispatched on session open (without a full list refresh). + // See `_applySessionMetadataFromState` / `applySessionStateMetadata`. private _project: IAgentSessionMetadata['project']; private _workingDirectories: readonly URI[] | undefined; /** Working-directory set used to resolve session customizations. */ @@ -927,11 +933,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { private _meta: SessionMeta | undefined; /** The latest session metadata used to build startup-cache presentation state. */ get sessionMeta(): SessionMeta | undefined { return this._meta; } - /** - * Whether this session is a workspace-less quick chat. Seeded from the - * constructor metadata and only ever promoted by - * {@link _promoteToQuickChatIfWorkspaceless}. - */ + /** Settable so authoritative session metadata can change the session kind in place. */ private readonly _isQuickChat: ISettableObservable; /** Session-kind strategy (quick chat vs. workspace), derived from {@link _isQuickChat}. */ private get _kind(): IAgentHostSessionKind { return sessionKind(this._isQuickChat.get()); } @@ -1674,9 +1676,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * Returns `true` iff the activity observable changed. Callers inside a * transaction MUST pass it — see {@link setChangesSummary}. */ - setActivity(activity: string | undefined, tx?: ITransaction): boolean { - if (this._activity.get() !== activity) { - this._activity.set(activity, tx); + setActivity(activity: string | null | undefined, tx?: ITransaction): boolean { + const value = activity ?? undefined; + if (this._activity.get() !== value) { + this._activity.set(value, tx); return true; } @@ -1685,11 +1688,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { /** * Apply a `_meta` delta (the shared session-state / session-summary bag, - * fed from `_applySessionMetaFromState` or a `SessionSummaryChanged` - * notification), promote the session kind if the delta reports it - * workspace-less, and rebuild the workspace if the git state changed. + * fed from `_applySessionMetadataFromState` or a `SessionSummaryChanged` + * notification), synchronize the session kind, and rebuild the workspace. * Returns `true` iff anything observable changed, so the list regroups a - * session that became a quick chat without ever having had a workspace. + * session whose kind changed even when its workspace did not. * * Callers that are already inside a transaction MUST pass it: a plain * `transaction()` here would finish (and therefore notify) mid-way through @@ -1703,7 +1705,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { let didChange = metaChanged; subtransaction(tx, tx => { this._metaObs.set(this._meta, tx); - if (this._promoteToQuickChatIfWorkspaceless(tx)) { + if (this._syncQuickChatFromMeta(tx)) { didChange = true; } const workspace = this._computeWorkspace(); @@ -1714,6 +1716,61 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return didChange; } + /** + * Applies the workspace-bearing fields from authoritative session state in + * one transaction so observers cannot see a converted session paired with + * its previous working directory. On the first state snapshot, absent + * optional fields retain catalogue metadata for compatibility; a later + * present-to-absent transition clears them. + */ + applySessionStateMetadata(metadata: AgentHostSessionStateMetadata, previous: SessionState | undefined): boolean { + let didChange = false; + transaction(tx => { + if (metadata.project !== undefined || previous?.project !== undefined) { + this._project = metadata.project; + } + if (metadata.workingDirectories !== undefined || previous?.workingDirectories !== undefined) { + this._workingDirectories = metadata.workingDirectories; + } + if (metadata._meta !== undefined || previous?._meta !== undefined) { + didChange = this.setMeta(metadata._meta, tx); + } else { + didChange = this._setWorkspace(this._computeWorkspace(), tx); + } + }); + return didChange; + } + + /** + * Applies project and working-directory fields from a session-summary delta. + * Property presence distinguishes an omitted field from an explicit clear. + */ + applySessionSummaryWorkspaceMetadata(metadata: AgentHostSessionSummaryWorkspaceMetadata, tx: ITransaction): boolean { + let didChange = false; + if (Object.prototype.hasOwnProperty.call(metadata, 'project')) { + const project = metadata.project; + const projectMatches = this._project === project + || (!!this._project && !!project && this._project.displayName === project.displayName && isEqual(this._project.uri, project.uri)); + if (!projectMatches) { + this._project = project; + didChange = true; + } + } + if (Object.prototype.hasOwnProperty.call(metadata, 'workingDirectories')) { + const workingDirectories = metadata.workingDirectories; + const directoriesMatch = this._workingDirectories === workingDirectories + || (!!this._workingDirectories && !!workingDirectories && arrayEquals(this._workingDirectories, workingDirectories, (a, b) => isEqual(a, b))); + if (!directoriesMatch) { + this._workingDirectories = workingDirectories; + didChange = true; + } + } + if (didChange) { + this._setWorkspace(this._computeWorkspace(), tx); + } + return didChange; + } + refreshWorkspace(): boolean { let didChange = false; transaction(tx => { @@ -1731,17 +1788,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._worktreeIsolation.set(isolated, undefined); } - /** - * Heal an adapter born mis-classified because the path that materialized it - * carried no `_meta` (a stale persisted cache, an older host). One-way: an - * absent marker means "not included", never "cleared", so a quick chat is - * never demoted back into a workspace session rooted at its scratch cwd. - */ - private _promoteToQuickChatIfWorkspaceless(tx: ITransaction): boolean { - if (this._isQuickChat.get() || !readSessionWorkspaceless(this._meta)) { + private _syncQuickChatFromMeta(tx: ITransaction): boolean { + const isQuickChat = readSessionWorkspaceless(this._meta); + if (this._isQuickChat.get() === isQuickChat) { return false; } - this._isQuickChat.set(true, tx); + this._isQuickChat.set(isQuickChat, tx); return true; } @@ -5438,7 +5490,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeCustomizations.fire(); } this._seedRunningConfigFromState(sessionId, state); - this._applySessionMetaFromState(sessionId, state); + this._applySessionMetadataFromState(sessionId, state, previous); this._applyChatCatalogFromState(sessionId, state); if (!previous) { @@ -5541,7 +5593,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _applySessionMetaFromState(sessionId: string, state: SessionState): void { + private _applySessionMetadataFromState(sessionId: string, state: SessionState, previous: SessionState | undefined): void { const rawId = this._rawIdFromChatId(sessionId); if (!rawId) { return; @@ -5551,7 +5603,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } - if (cached.setMeta(state._meta)) { + const metadata: AgentHostSessionStateMetadata = { + project: state.project ? { + displayName: state.project.displayName, + uri: this.mapProjectUri(URI.parse(state.project.uri)), + } : undefined, + workingDirectories: state.workingDirectories?.map(directory => this.mapWorkingDirectoryUri(URI.parse(directory))), + _meta: state._meta, + }; + if (cached.applySessionStateMetadata(metadata, previous)) { this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } } @@ -5699,6 +5759,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement changes: adapter.changesSummary.get(), // A project assigned by `backfillProject` lives only on the adapter. project: adapter.project ?? base.project, + // Session-state and summary updates can relocate an existing session. + workingDirectories: adapter.workingDirectories, status: withSessionStatusFlag( withSessionStatusFlag(base.status ?? ProtocolSessionStatus.Idle, ProtocolSessionStatus.IsRead, adapter.isRead.get()), ProtocolSessionStatus.IsArchived, @@ -6097,7 +6159,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } } - private _handleSessionSummaryChanged(session: string, changes: Partial): void { + private _handleSessionSummaryChanged(session: string, changes: SessionSummaryChanges): void { // Set when a delta clears the adoptable-legacy marker so we can reopen the // passive state subscription after the transaction commits (the observable // updates in `_ensureSessionStateSubscription` must not run nested in `tx`). @@ -6148,6 +6210,20 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement didChange = true; } + const workspaceMetadata: AgentHostSessionSummaryWorkspaceMetadata = {}; + if (Object.prototype.hasOwnProperty.call(changes, 'project')) { + workspaceMetadata.project = changes.project ? { + displayName: changes.project.displayName, + uri: this.mapProjectUri(URI.parse(changes.project.uri)), + } : undefined; + } + if (Object.prototype.hasOwnProperty.call(changes, 'workingDirectories')) { + workspaceMetadata.workingDirectories = changes.workingDirectories?.map(directory => this.mapWorkingDirectoryUri(URI.parse(directory))); + } + if (cached.applySessionSummaryWorkspaceMetadata(workspaceMetadata, tx)) { + didChange = true; + } + if (Object.prototype.hasOwnProperty.call(changes, '_meta')) { // Keep the guard map in sync (mirrors `updateAdapter`) so a cleared // adoptable-legacy marker reopens the passive session-state diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts index f0731be1bf72f4..d2b1d1f92d88a1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostPermissionPickerDelegate.test.ts @@ -134,6 +134,7 @@ function setup(store: Pick, activeSession: IActiveSessio _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced, + managedSandboxAllowsBypass: constObservable(false), }); const delegate = store.add(insta.createInstance(AgentHostPermissionPickerDelegate, activeSessionObs)); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index c8018bc4f46a29..74b3ab89d16b3e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -20,10 +20,10 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSes import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; -import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction, type SessionSummaryChangedParams } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -409,7 +409,7 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; status?: ProtocolSessionStatus; activity?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean; multiRoot?: { workspaceFile: string }; adoptable?: boolean; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { let _meta = opts?._meta; _meta = opts?.quickChat ? withSessionWorkspaceless(_meta, true) : _meta; _meta = withSessionMultiRootMetadata(_meta, opts?.multiRoot); @@ -421,6 +421,8 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, + status: opts?.status, + activity: opts?.activity, project: opts?.project, workingDirectories: opts?.workingDirectory ? [opts?.workingDirectory] : undefined, _meta, @@ -620,7 +622,7 @@ function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, prov }); } -function fireSessionSummaryChanged(agentHost: MockAgentHostService, rawId: string, changes: Partial, provider = 'copilotcli'): void { +function fireSessionSummaryChanged(agentHost: MockAgentHostService, rawId: string, changes: SessionSummaryChangedParams['changes'], provider = 'copilotcli'): void { const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ channel: 'ahp-root://', @@ -3311,6 +3313,112 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('authoritative session state converts a quick chat to a workspace session in place', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const scratchDirectory = URI.file('/tmp/copilot-scratch/quick-converted'); + const workspaceDirectory = URI.file('/home/user/project'); + agentHost.addSession(createSession('quick-converted', { + summary: 'Quick Chat', + workingDirectory: scratchDirectory, + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions()[0] as AgentHostSessionAdapter; + provider.getSessionConfig(session.sessionId); + const sessionUri = AgentSession.uri('copilotcli', 'quick-converted').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('quick-converted', 'copilotcli', { + provider: 'copilotcli', + title: 'Quick Chat', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat, + workingDirectories: [scratchDirectory.toString()], + _meta: withSessionWorkspaceless(undefined, true), + chats: [{ resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }], + }); + const observed: Array<{ isQuickChat: boolean | undefined; workspace: string | undefined }> = []; + disposables.add(autorun(reader => { + observed.push({ + isQuickChat: session.isQuickChat?.read(reader), + workspace: session.workspace.read(reader)?.uri.toString(), + }); + })); + const changed: string[] = []; + disposables.add(provider.onDidChangeSessions(event => changed.push(...event.changed.map(candidate => candidate.sessionId)))); + + agentHost.setSessionState('quick-converted', 'copilotcli', { + provider: 'copilotcli', + title: 'Quick Chat', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat, + workingDirectories: [workspaceDirectory.toString()], + chats: [{ resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }], + }); + + assert.deepStrictEqual({ + observed, + workingDirectories: session.workingDirectories.map(directory => directory.toString()), + announced: changed.includes(session.sessionId), + sameAdapter: provider.getSessions()[0] === session, + }, { + observed: [ + { isQuickChat: true, workspace: undefined }, + { isQuickChat: false, workspace: workspaceDirectory.toString() }, + ], + workingDirectories: [workspaceDirectory.toString()], + announced: true, + sameAdapter: true, + }); + })); + + test('isolated conversion groups by repository while running in the worktree', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const scratchDirectory = URI.file('/tmp/copilot-scratch/quick-isolated'); + const repository = URI.file('/home/user/project'); + const worktree = URI.file('/home/user/project.worktrees/implement-feature'); + agentHost.addSession(createSession('quick-isolated', { + summary: 'Quick Chat', + workingDirectory: scratchDirectory, + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]; + assert.ok(session); + + fireSessionSummaryChanged(agentHost, 'quick-isolated', { + project: { uri: repository.toString(), displayName: 'project' }, + workingDirectories: [worktree.toString()], + _meta: withSessionWorkspaceless(undefined, false), + }); + await timeout(0); + + const workspace = session.workspace.get(); + assert.deepStrictEqual({ + isQuickChat: session.isQuickChat?.get(), + workspace: workspace?.uri.toString(), + folderRoot: workspace?.folders[0]?.root.toString(), + workingDirectory: workspace?.folders[0]?.workingDirectory.toString(), + workTreeUri: workspace?.folders[0]?.gitRepository?.workTreeUri?.toString(), + sameAdapter: provider.getSessions()[0] === session, + }, { + isQuickChat: false, + workspace: repository.toString(), + folderRoot: repository.toString(), + workingDirectory: worktree.toString(), + workTreeUri: worktree.toString(), + sameAdapter: true, + }); + })); + test('committed quick chat announced via sessionAdded stays workspace-less despite a scratch working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // Regression: when a quick-chat draft graduates, the host announces the // committed session via a `sessionAdded` notification whose summary diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts index 513bbb1323075c..7d2d3cf370d922 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSubagentChat.test.ts @@ -10,6 +10,9 @@ import { Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.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 { ChatConfiguration } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { workbenchInstantiationService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; @@ -235,8 +238,9 @@ suite('OpenSubagentChatActionViewItem', () => { }); }); - test('renders the credit cost alongside the model', () => { + test('renders the credit cost alongside the model when enabled', () => { const instantiationService = workbenchInstantiationService(undefined, store); + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.SubagentsShowCreditUsage, true); instantiationService.stub(ISessionsService, { activeSession: observableValue('activeSession', undefined), visibleSessions: observableValue('visibleSessions', []), diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts index 96e0056fdc4dd7..376aa64666b18c 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/mobilePermissionPicker.ts @@ -7,6 +7,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { localize } from '../../../../../nls.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -42,8 +43,9 @@ export class MobilePermissionPicker extends PermissionPicker { @ITelemetryService telemetryService: ITelemetryService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, ) { - super(_delegate, actionWidgetService, configurationService, dialogService, openerService, storageService, telemetryService, hoverService); + super(_delegate, actionWidgetService, configurationService, dialogService, openerService, storageService, telemetryService, hoverService, agentHostEnablementService); } override showPicker(): void { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts index e0ab11b8eaded3..948d2b0c8d8cc5 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/permissionPicker.ts @@ -14,6 +14,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -149,6 +150,9 @@ export class PermissionPicker extends Disposable { protected _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; protected _triggerElement: HTMLElement | undefined; protected readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _pickerDisposables = this._register(new DisposableStore()); + private readonly _sandboxToggleDisabled = derived(this, reader => this._delegate.managedSandboxEnforced?.read(reader) === true + && !this.agentHostEnablementService.managedSandboxAllowsBypass.read(reader)); constructor( protected readonly _delegate: IPermissionPickerDelegate, @@ -159,6 +163,7 @@ export class PermissionPicker extends Disposable { @IStorageService protected readonly storageService: IStorageService, @ITelemetryService protected readonly telemetryService: ITelemetryService, @IHoverService protected readonly hoverService: IHoverService, + @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, ) { super(); } @@ -347,6 +352,7 @@ export class PermissionPicker extends Disposable { } }, onHide: () => { + this._pickerDisposables.clear(); triggerElement.focus(); }, }; @@ -365,6 +371,20 @@ export class PermissionPicker extends Disposable { }, listOptions, ); + if (sandboxToggle) { + this._pickerDisposables.add(autorun(reader => { + this._delegate.managedSandboxEnforced?.read(reader); + this._sandboxToggleDisabled.read(reader); + const standaloneToggle = this._getSandboxStandaloneToggle(); + const disabled = standaloneToggle?.disabled === true; + this.actionWidgetService.updateItems(items.map(item => item.standaloneToggle ? { + ...item, + standaloneToggle, + disabled, + hover: disabled ? { content: localize('permissions.policyDescription', "Disabled by enterprise policy") } : undefined, + } : item)); + })); + } } protected _isResolving(): boolean { @@ -438,15 +458,18 @@ export class PermissionPicker extends Disposable { return undefined; } const managed = this._isSandboxManaged(); + const disabled = this._isSandboxToggleDisabled(); return { label: localize('permissionPicker.sandboxToggle', "Sandboxing for terminal"), title: managed - ? localize('permissionPicker.managedSandboxToggleTitle', "Sandboxing is managed by your organization") + ? disabled + ? localize('permissionPicker.requiredSandboxToggleTitle', "Sandboxing is required by your organization") + : localize('permissionPicker.editableManagedSandboxToggleTitle', "Sandboxing is enabled by your organization, but you may disable it") : localize('permissionPicker.sandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this._isSandboxingEnabled(), - disabled: managed, + disabled, onChange: (checked: boolean) => { - if (this._isSandboxManaged()) { + if (this._isSandboxToggleDisabled()) { return; } const settingId = this._delegate.getSandboxToggleSettingId?.(); @@ -478,6 +501,10 @@ export class PermissionPicker extends Disposable { return this._delegate.managedSandboxEnforced?.get() === true; } + private _isSandboxToggleDisabled(): boolean { + return this._sandboxToggleDisabled.get(); + } + private _affectsSandboxToggle(event: IConfigurationChangeEvent): boolean { const settingId = this._delegate.getSandboxToggleSettingId?.(); return event.affectsConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled) diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts index ee9ea1507d4233..a2760fe9f1154b 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/permissionPicker.test.ts @@ -9,10 +9,14 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IActionListDelegate, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, IManagedSettingsService } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { constObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { AgentSandboxEnabledValue } from '../../../../../../platform/sandbox/common/settings.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; @@ -21,6 +25,12 @@ import { DEFAULT_PERMISSION_LEVELS, getPermissionLevelMeta, IPermissionPickerDel suite('Copilot PermissionPicker', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + const unmanagedEnablementService: IAgentHostEnablementService = { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced: constObservable(false), + managedSandboxAllowsBypass: constObservable(false), + }; test('restores trigger focus after pointer and keyboard activation', () => { let onHide: (() => void) | undefined; @@ -46,6 +56,7 @@ suite('Copilot PermissionPicker', () => { new class extends mock() { override setupDelayedHover() { return { dispose: () => { } }; } }(), + unmanagedEnablementService, )); const container = document.createElement('div'); picker.render(container); @@ -72,6 +83,124 @@ suite('Copilot PermissionPicker', () => { }); }); + test('sandbox toggle editability follows managed bypass policy', async () => { + const sandboxSettingId = 'test.sandbox.enabled'; + const writes: unknown[] = []; + const configurationService = new class extends TestConfigurationService { + override async updateValue(key: string, value: unknown): Promise { + writes.push({ key, value }); + } + }(); + store.add(configurationService.onDidChangeConfigurationEmitter); + await configurationService.setUserConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled, true); + const managedSandboxEnforced = observableValue('managedSandboxEnforced', false); + let allowBypass: boolean | undefined; + const managedSettingsChanged = store.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsChanged.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY ? allowBypass : undefined, + }; + const enablementService: IAgentHostEnablementService = { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced, + managedSandboxAllowsBypass: observableFromEvent(managedSettingsService, managedSettingsChanged.event, () => allowBypass === true), + }; + const visibleStates: { disabled: boolean | undefined; rowDisabled: boolean | undefined; title: string | undefined; hasHover: boolean }[] = []; + let onHide: (() => void) | undefined; + const recordVisibleState = (items: readonly IActionListItem[]) => { + const item = items.find(item => item.standaloneToggle); + assert.ok(item?.standaloneToggle); + visibleStates.push({ disabled: item.standaloneToggle.disabled, rowDisabled: item.disabled, title: item.standaloneToggle.title, hasHover: !!item.hover }); + }; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + onHide = delegate.onHide; + recordVisibleState(items); + } + override updateItems(items: readonly IActionListItem[]): void { + recordVisibleState(items); + } + }(); + const picker = store.add(new PermissionPicker( + { + getPermissionLevelMeta: (_level, meta) => meta, + setPermissionLevel: () => { }, + sandboxTogglePresentation: 'standalone', + isSandboxToggleApplicable: () => true, + getSandboxToggleSettingId: () => sandboxSettingId, + managedSandboxEnforced, + }, + actionWidgetService, + configurationService, + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestStorageService()), + NullTelemetryService, + new class extends mock() { }(), + enablementService, + )); + + for (const managed of [false, true]) { + managedSandboxEnforced.set(managed, undefined); + for (const bypass of [undefined, false, true]) { + allowBypass = bypass; + for (const configured of [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On]) { + await configurationService.setUserConfiguration(sandboxSettingId, configured); + const toggle = picker['_getSandboxStandaloneToggle']()!; + writes.length = 0; + toggle.onChange(false); + toggle.onChange(true); + const disabled = managed && bypass !== true; + assert.deepStrictEqual({ checked: toggle.checked, disabled: toggle.disabled, title: toggle.title, writes }, { + checked: managed || configured === AgentSandboxEnabledValue.On, + disabled, + title: managed + ? disabled ? 'Sandboxing is required by your organization' : 'Sandboxing is enabled by your organization, but you may disable it' + : 'Run terminal commands inside a sandbox that restricts file system and network access', + writes: disabled ? [] : [ + { key: sandboxSettingId, value: AgentSandboxEnabledValue.Off }, + { key: sandboxSettingId, value: AgentSandboxEnabledValue.On }, + ], + }); + } + } + } + + const toggle = picker['_getSandboxStandaloneToggle']()!; + allowBypass = false; + writes.length = 0; + toggle.onChange(false); + assert.deepStrictEqual({ writes, disabled: picker['_getSandboxStandaloneToggle']()!.disabled }, { writes: [], disabled: true }); + allowBypass = true; + assert.strictEqual(picker['_getSandboxStandaloneToggle']()!.disabled, false); + + picker['_triggerElement'] = document.createElement('div'); + allowBypass = false; + picker.showPicker(); + allowBypass = true; + managedSettingsChanged.fire(); + managedSettingsChanged.fire(); + managedSandboxEnforced.set(false, undefined); + managedSandboxEnforced.set(true, undefined); + allowBypass = false; + managedSettingsChanged.fire(); + assert.ok(onHide); + onHide(); + allowBypass = true; + managedSettingsChanged.fire(); + assert.deepStrictEqual(visibleStates, [ + { disabled: true, rowDisabled: true, title: 'Sandboxing is required by your organization', hasHover: true }, + { disabled: true, rowDisabled: true, title: 'Sandboxing is required by your organization', hasHover: true }, + { disabled: false, rowDisabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it', hasHover: false }, + { disabled: false, rowDisabled: false, title: 'Run terminal commands inside a sandbox that restricts file system and network access', hasHover: false }, + { disabled: false, rowDisabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it', hasHover: false }, + { disabled: true, rowDisabled: true, title: 'Sandboxing is required by your organization', hasHover: true }, + ]); + }); + test('uses descriptions aligned with the agent host permission picker', () => { assert.deepStrictEqual(DEFAULT_PERMISSION_LEVELS.map(level => ({ level, @@ -119,6 +248,7 @@ suite('Copilot PermissionPicker', () => { store.add(new TestStorageService()), NullTelemetryService, new class extends mock() { }(), + unmanagedEnablementService, )); const container = document.createElement('div'); picker.render(container); diff --git a/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css b/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css index 17ca4d416afd1c..69542790edcf24 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css +++ b/src/vs/sessions/contrib/sessions/browser/media/newSessionActionViewItem.css @@ -59,6 +59,37 @@ unscoped so the widget renders identically wherever it is mounted. */ box-shadow: none; } +.agent-sessions-compact-new-button.lightweight .new-session-keybinding-hint { + display: inline-flex; + align-items: center; + font-family: inherit; + font-size: var(--vscode-fontSize-label2, 11px); + line-height: inherit; + padding: 0; + border: 0; + border-radius: 0; + background-color: transparent; + color: inherit; +} + +.agent-sessions-compact-new-button.lightweight .new-session-keybinding-hint .monaco-keybinding { + gap: 0; + line-height: inherit; +} + +.agent-sessions-compact-new-button.lightweight .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key { + margin: 0; + font-family: inherit; + font-size: inherit; +} + +.agent-sessions-compact-new-button.lightweight-keybinding-background .new-session-keybinding-hint { + padding: 0 var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-xSmall); + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); +} + .agent-sessions-compact-new-button .new-session-keybinding-hint .monaco-keybinding { line-height: 1; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 4a2518f6be12f9..a539ec8ccb533f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,7 +13,7 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { SessionsTelemetryContribution } from './sessionsTelemetry.contribution.js'; -import { NewSessionActionViewItemContribution, SessionConversationActionsContribution } from './sessionsActions.js'; +import { NEW_SESSION_BUTTON_STYLE_SETTING, NEW_SESSION_BUTTON_STYLE_TREATMENT, NewSessionActionViewItemContribution, SessionConversationActionsContribution } from './sessionsActions.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { AutomationsCustomViewContribution } from './views/automationsView.js'; import './views/sessionsViewActions.js'; @@ -83,6 +83,19 @@ Registry.as(ConfigurationExtensions.Configuration).regis }, description: localize('sessions.automations.newBadgeStyle', "Controls the visual style of the Automations first-use badge."), }, + [NEW_SESSION_BUTTON_STYLE_SETTING]: { + type: 'string', + enum: ['default', 'lightweight', 'lightweightWithKeybindingBackground'], + default: 'default', + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental'], + experiment: { + mode: 'auto', + name: NEW_SESSION_BUTTON_STYLE_TREATMENT, + }, + description: localize('sessions.newSessionButton.style', "Controls the visual style of the New Session button."), + }, }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 6d415191959cda..eb07462e50a693 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -5,31 +5,38 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { fromNow } from '../../../../base/common/date.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { autorun, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService, isConfigured } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { WorkbenchListFocusContextKey } from '../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; -import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { EditorAreaFocusContext, FocusedViewContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionHasSideChatsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionFocusedChatIsRenameTargetContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionHasSideChatsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { CLOSE_CHAT_COMMAND_ID, FOCUS_ACTIVE_SESSION_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; @@ -55,9 +62,14 @@ import { logSessionsInteraction, SessionsInteractionSource } from '../../../comm import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; import { groupSessionsForPicker } from './sessionsPicker.js'; import { getSessionConversationActionId, isSessionConversationSideChat, SESSION_CONVERSATION_SIDE_CHATS_GROUP } from '../../../browser/sessionConversationGroups.js'; -import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext } from './views/sessionsList.js'; +import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext, SessionsList, SessionsListFocusedChatItemContext } from './views/sessionsList.js'; +import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './media/newSessionActionViewItem.css'; +export const NEW_SESSION_BUTTON_STYLE_SETTING = 'sessions.newSessionButton.style'; +export const NEW_SESSION_BUTTON_STYLE_TREATMENT = 'agentSessionsNewSessionButtonStyle'; +export type NewSessionButtonStyle = 'default' | 'lightweight' | 'lightweightWithKeybindingBackground'; + // -- Show Sessions Picker -- export const SHOW_SESSIONS_PICKER_COMMAND_ID = 'sessions.showSessionsPicker'; @@ -535,13 +547,101 @@ registerAction2(class CloseAllSessionsAction extends Action2 { } }); -// -- Chat tab navigation, new chat, & close (within the active session's tab strip) -- +// -- Chat tab navigation, rename, new chat, & close (within the active session's tab strip) -- // These chords sit just above the session-level navigation/close commands so // they win while a multi-chat session is focused, falling back to the // session-level commands when the tab strip is not shown. const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; +interface IChatRenameContext { + readonly session: ISession; + readonly chat: IChat; +} + +function getSessionsList(accessor: ServicesAccessor): SessionsList | undefined { + return accessor.get(IViewsService).getViewWithId(SessionsViewId)?.sessionsControl; +} + +function getChatRenameContext(accessor: ServicesAccessor, context?: IChatRenameContext): IChatRenameContext | undefined { + if (context) { + return context; + } + + const sessionsList = getSessionsList(accessor); + const focusedChat = sessionsList?.getFocusedChatItem(); + if (focusedChat) { + return focusedChat; + } + if (sessionsList?.getFocusedSessions() !== undefined) { + return undefined; + } + + const sessionView = accessor.get(ISessionsPartService).getFocusedSessionView(); + const session = sessionView?.getSession(); + const chat = sessionView?.getFocusedChat(); + return session && chat ? { session, chat } : undefined; +} + +async function renameChatWithQuickInput(accessor: ServicesAccessor, context: IChatRenameContext): Promise { + const extUri = accessor.get(IUriIdentityService).extUri; + const quickInputService = accessor.get(IQuickInputService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const { session, chat } = context; + const resource = chat.resource; + const initialChat = session.chats.get().find(candidate => extUri.isEqual(candidate.resource, resource)); + if (!initialChat || extUri.isEqual(resource, session.mainChat.get().resource) || initialChat.status.get() === SessionStatus.Untitled || !getChatCapabilities(initialChat, session, undefined).canRename) { + return; + } + + const initialTitle = initialChat.title.get().trim() || localize('untitledChat', "Untitled Chat"); + const newTitle = await quickInputService.input({ + value: initialTitle, + prompt: localize('renameChat.prompt', "New chat title"), + validateInput: async value => value.trim() ? undefined : localize('renameChat.empty', "Title cannot be empty"), + }); + const trimmedTitle = newTitle?.trim(); + if (!trimmedTitle || trimmedTitle === initialTitle) { + return; + } + + const currentChat = session.chats.get().find(candidate => extUri.isEqual(candidate.resource, resource)); + if (!currentChat || extUri.isEqual(resource, session.mainChat.get().resource) || currentChat.status.get() === SessionStatus.Untitled || !getChatCapabilities(currentChat, session, undefined).canRename) { + return; + } + + await sessionsManagementService.renameChat(session, resource, trimmedTitle); +} + +registerAction2(class RenameChatAction extends Action2 { + constructor() { + super({ + id: RENAME_CHAT_COMMAND_ID, + title: localize2('renameActiveChat', "Rename..."), + f1: false, + category: SessionsCategories.Sessions, + keybinding: { + primary: KeyCode.F2, + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + ContextKeyExpr.or( + ContextKeyExpr.and(ChatContextKeys.inChatSession, SessionFocusedChatIsRenameTargetContext), + ContextKeyExpr.and(FocusedViewContext.isEqualTo(SessionsViewId), WorkbenchListFocusContextKey, SessionsListFocusedChatItemContext), + ), + ), + }, + }); + } + + override async run(accessor: ServicesAccessor, context?: IChatRenameContext): Promise { + const target = getChatRenameContext(accessor, context); + if (target) { + await renameChatWithQuickInput(accessor, target); + } + } +}); + registerAction2(class RenameSessionListChatAction extends Action2 { constructor() { super({ @@ -558,21 +658,10 @@ registerAction2(class RenameSessionListChatAction extends Action2 { } override async run(accessor: ServicesAccessor, context?: ISessionChatItem): Promise { - if (!context || !getChatCapabilities(context.chat, context.session, undefined).canRename || context.chat.status.get() === SessionStatus.Untitled) { + if (!context) { return; } - const quickInputService = accessor.get(IQuickInputService); - const sessionsManagementService = accessor.get(ISessionsManagementService); - const currentTitle = context.chat.title.get().trim() || localize('untitledChat', "Untitled Chat"); - const newTitle = await quickInputService.input({ - value: currentTitle, - prompt: localize('renameChat.prompt', "New chat title"), - validateInput: async value => value.trim() ? undefined : localize('renameChat.empty', "Title cannot be empty"), - }); - const trimmedTitle = newTitle?.trim(); - if (trimmedTitle && trimmedTitle !== currentTitle) { - await sessionsManagementService.renameChat(context.session, context.chat.resource, trimmedTitle); - } + await renameChatWithQuickInput(accessor, context); } }); @@ -1181,6 +1270,8 @@ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { /** Hook invoked right before the action runs (e.g. for telemetry). */ protected onRun(): void { } + protected configureButton(_button: Button): void { } + override render(container: HTMLElement): void { super.render(container); @@ -1198,6 +1289,7 @@ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { supportIcons: true, })); button.element.classList.add('agent-sessions-compact-new-button'); + this.configureButton(button); const onboardingTargetId = this.onboardingTargetId; if (onboardingTargetId) { this._register(markOnboardingTarget(button.element, onboardingTargetId)); @@ -1275,6 +1367,7 @@ class NewSessionActionViewItem extends CompactButtonActionViewItem { constructor( action: IAction, private readonly telemetrySource: SessionsInteractionSource, + private readonly newSessionButtonStyle: IObservable, @IKeybindingService keybindingService: IKeybindingService, @IHoverService hoverService: IHoverService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -1295,6 +1388,14 @@ class NewSessionActionViewItem extends CompactButtonActionViewItem { return 'sessions.newSession.button'; } + protected override configureButton(button: Button): void { + this._register(autorun(reader => { + const style = this.newSessionButtonStyle.read(reader); + button.element.classList.toggle('lightweight', style === 'lightweight' || style === 'lightweightWithKeybindingBackground'); + button.element.classList.toggle('lightweight-keybinding-background', style === 'lightweightWithKeybindingBackground'); + })); + } + protected override getHoverContent(keybindingLabel: string | undefined): string { return keybindingLabel ? localize('newSessionButtonTitle', "New Session ({0})", keybindingLabel) @@ -1324,6 +1425,8 @@ export class NewSessionActionViewItemContribution extends Disposable implements private static readonly NEW_SESSION_TITLEBAR_TREATMENT = 'agentSessionsTitleBarNewSession'; private readonly titleBarEnabledContext: IContextKey; + private readonly newSessionButtonStyle = observableValue(this, 'default'); + private newSessionButtonStyleRequest = 0; constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @@ -1331,10 +1434,19 @@ export class NewSessionActionViewItemContribution extends Disposable implements @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, @IEnvironmentService private readonly environmentService: IEnvironmentService, @IProductService private readonly productService: IProductService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, ) { super(); this.titleBarEnabledContext = SessionsTitleBarNewSessionEnabledContext.bindTo(contextKeyService); + const configuredStyle = observableConfigValue(NEW_SESSION_BUTTON_STYLE_SETTING, 'default', this.configurationService); + const assignmentsChanged = observableSignalFromEvent(this, this.assignmentService.onDidRefetchAssignments); + this._register(autorun(reader => { + configuredStyle.read(reader); + assignmentsChanged.read(reader); + void this.updateNewSessionButtonStyle().catch(onUnexpectedError); + })); const onDidRegister = this._register(new Emitter()); const menus: MenuId[] = [Menus.SidebarSessionsHeader, Menus.TitleBarLeftLayout]; @@ -1344,7 +1456,7 @@ export class NewSessionActionViewItemContribution extends Disposable implements if (!(action instanceof MenuItemAction)) { return undefined; } - return instantiationService.createInstance(NewSessionActionViewItem, action, source); + return instantiationService.createInstance(NewSessionActionViewItem, action, source, this.newSessionButtonStyle); }, onDidRegister.event)); } onDidRegister.fire(); @@ -1354,6 +1466,33 @@ export class NewSessionActionViewItemContribution extends Disposable implements this.updateTitleBarTreatment(); } + private async updateNewSessionButtonStyle(): Promise { + const request = ++this.newSessionButtonStyleRequest; + const inspection = this.configurationService.inspect(NEW_SESSION_BUTTON_STYLE_SETTING); + let value: string | undefined; + if (isConfigured(inspection)) { + value = inspection.value; + } else { + try { + value = await this.assignmentService.getTreatment(NEW_SESSION_BUTTON_STYLE_TREATMENT); + } catch (error) { + this.logService.warn('[NewSessionActionViewItemContribution] Failed to resolve the New Session button style treatment; using default.', error); + } + } + if (request !== this.newSessionButtonStyleRequest) { + return; + } + this.newSessionButtonStyle.set(this.normalizeNewSessionButtonStyle(value), undefined); + } + + private normalizeNewSessionButtonStyle(value: string | undefined): NewSessionButtonStyle { + if (value === undefined || value === 'default' || value === 'lightweight' || value === 'lightweightWithKeybindingBackground') { + return value ?? 'default'; + } + this.logService.warn(`[NewSessionActionViewItemContribution] Unsupported New Session button style treatment '${value}'; using 'default'.`); + return 'default'; + } + private async updateTitleBarTreatment(): Promise { // Always show in dev builds (running from sources) to ease development, regardless of the experiment. if (!this.environmentService.isBuilt) { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 6f4d91d9ec37d1..eaed70b6200117 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -124,6 +124,7 @@ export const SessionItemStatusContext = new RawContextKey('sessio export const SessionChatItemCanRenameContext = new RawContextKey('sessionChatItem.canRename', false); export const SessionChatItemCanDeleteContext = new RawContextKey('sessionChatItem.canDelete', false); export const SessionChatItemIsUntitledContext = new RawContextKey('sessionChatItem.isUntitled', false); +export const SessionsListFocusedChatItemContext = new RawContextKey('sessionsList.focusedChatItem', false); /** Whether the focused session item currently belongs to a user group. */ export const SessionItemInGroupContext = new RawContextKey('sessionItem.inGroup', false); export const SessionSectionTypeContext = new RawContextKey('sessionSection.type', ''); @@ -2695,6 +2696,7 @@ export class SessionsList extends Disposable implements ISessionsList { : 'force-no-twistie', } )); + const focusedChatItemContext = SessionsListFocusedChatItemContext.bindTo(this.tree.contextKeyService); this.tree.updateOptions({ indent: 0, defaultIndent: 0, expandOnDoubleClick: false }); // Hierarchy guides: resolve any row (a session or one of its chats) to @@ -2736,11 +2738,13 @@ export class SessionsList extends Disposable implements ISessionsList { })); const updateFocusedGuideSessionIds = () => { this.focusedGuideSessionIds.set(guideOwnerSessionIds(this.tree.getFocus()), undefined); + focusedChatItemContext.set(DOM.isAncestorOfActiveElement(this.listContainer) && this.tree.getFocus().some(item => !!item && isSessionChatItem(item))); }; this._register(this.tree.onDidChangeFocus(updateFocusedGuideSessionIds)); this._register(this.tree.onDidFocus(updateFocusedGuideSessionIds)); this._register(this.tree.onDidBlur(() => { this.focusedGuideSessionIds.set(EMPTY_GUIDE_SESSION_IDS, undefined); + focusedChatItemContext.reset(); })); this._register(this.tree.onDidOpen(async e => { @@ -3464,6 +3468,15 @@ export class SessionsList extends Disposable implements ISessionsList { return focusedSession ? this.getMultiSelectedSessions(focusedSession) : []; } + /** Returns the focused chat row while this list owns DOM focus. */ + getFocusedChatItem(): ISessionChatItem | undefined { + if (!DOM.isAncestorOfActiveElement(this.listContainer)) { + return undefined; + } + + return this.tree.getFocus().find((item): item is ISessionChatItem => !!item && isSessionChatItem(item)); + } + setVisible(visible: boolean): void { if (this.visible === visible) { return; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 541fc0caec3725..0e61d7171980ab 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -42,7 +42,7 @@ import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } fro import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, ISessionSection, limitSessionsForList, SessionSectionRenderer, SessionsFlatList, SessionsList, SessionsListFocusedChatItemContext, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { getSessionSummaryHoverData } from '../../browser/sessionHoverContent.js'; import { createListHarness, createTestSession } from './sessionsListTestUtils.js'; @@ -1590,6 +1590,67 @@ suite('Sessions - SessionsList', () => { }]); }); + test('reports a focused nested chat only while the Sessions list owns focus', () => { + const main = createChat('Main chat'); + const peer = createChat('Peer chat', ChatOriginKind.User); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats: constObservable([main, peer]), + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const harness = createListHarness(disposables, [session], instantiationService => { + instantiationService.stub(IContextKeyService, disposables.add(new ContextKeyService(new TestConfigurationService()))); + }); + const contextKeyService = harness.instantiationService.get(IContextKeyService); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, + sorting: () => SessionsSorting.Created, + onSessionOpen: () => { }, + onChatOpen: () => { }, + })); + list.layout(300, 400); + list.reveal(session.resource); + list.focus(); + const contextTarget = container.querySelector('.monaco-list'); + assert.ok(contextTarget); + const getFocusedChatItemContext = () => contextKeyService.getContext(contextTarget).getValue(SessionsListFocusedChatItemContext.key); + const sessionRowValue = getFocusedChatItemContext(); + const beforeChatFocus = list.getFocusedChatItem(); + + const peerRow = [...container.querySelectorAll('.session-chat-item')] + .find(element => element.textContent === 'Peer chat'); + assert.ok(peerRow); + peerRow.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + list.focus(); + const focusedChat = list.getFocusedChatItem(); + const chatRowValue = getFocusedChatItemContext(); + + const outside = mainWindow.document.createElement('button'); + mainWindow.document.body.appendChild(outside); + harness.store.add({ dispose: () => outside.remove() }); + outside.focus(); + contextTarget.dispatchEvent(new FocusEvent('blur')); + + assert.deepStrictEqual({ + sessionRowValue, + beforeChatFocus, + focusedChat: focusedChat?.chat.resource.toString(), + chatRowValue, + afterBlur: list.getFocusedChatItem(), + afterBlurValue: getFocusedChatItemContext(), + }, { + sessionRowValue: false, + beforeChatFocus: undefined, + focusedChat: peer.resource.toString(), + chatRowValue: true, + afterBlur: undefined, + afterBlurValue: false, + }); + }); + test('opens a nested chat to the side with the session row modifier gesture', () => { const main = createChat('Main chat'); const peer = createChat('Peer chat', ChatOriginKind.User); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts index fe645926b28fac..2fdb901e39e188 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListTestUtils.ts @@ -49,6 +49,7 @@ export class TestSessionsManagementService extends mock { this.renamedChats.push({ session, chatResource, title }); + if (this.renameChatError) { + throw this.renameChatError; + } } } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts index fc46a989b638f5..a7add286667e7b 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsRename.test.ts @@ -5,18 +5,23 @@ import assert from 'assert'; import { mainWindow } from '../../../../../base/browser/window.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { extUri } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { mock } from '../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IInputOptions, IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { ARCHIVE_SESSION_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { ARCHIVE_SESSION_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { SessionView } from '../../../../browser/parts/sessionView.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { SessionsChatAccessibilityHelp } from '../../../chat/browser/sessionsChatAccessibilityHelp.js'; import { SessionsFlatList, SessionsGrouping, SessionsList, SessionsSorting } from '../../browser/views/sessionsList.js'; import { createListHarness, createTestSession, TestCommandService, TestSessionsManagementService } from './sessionsListTestUtils.js'; @@ -27,10 +32,14 @@ class TestQuickInputService extends mock() { result: string | undefined; options: IInputOptions | undefined; calls = 0; + inputHandler: ((options?: IInputOptions) => Promise) | undefined; override async input(options?: IInputOptions): Promise { this.calls++; this.options = options; + if (this.inputHandler) { + return this.inputHandler(options); + } return this.result; } } @@ -240,6 +249,176 @@ suite('Sessions rename', () => { }); }); + suite('chat action', () => { + function createChatHarness(options: { readonly status?: SessionStatus; readonly canRename?: boolean } = {}) { + const instantiationService = disposables.add(new TestInstantiationService()); + const quickInputService = new TestQuickInputService(); + const managementService = new TestSessionsManagementService([]); + const baseSession = createTestSession('Explore Jitter Issue').session; + const mainChat = baseSession.mainChat.get(); + const peerChat = new class extends mock() { + override readonly resource = URI.parse('test-chat:///grill-and-plan'); + override readonly title = constObservable('Grill and Plan'); + override readonly status = constObservable(options.status ?? SessionStatus.Completed); + override readonly interactivity = constObservable(ChatInteractivity.Full); + override readonly capabilities = constObservable({ canRename: options.canRename ?? true, canDelete: true }); + }(); + const otherPeerChat = new class extends mock() { + override readonly resource = URI.parse('test-chat:///other-peer'); + override readonly title = constObservable('Other Peer'); + override readonly status = constObservable(SessionStatus.Completed); + override readonly interactivity = constObservable(ChatInteractivity.Full); + override readonly capabilities = constObservable({ canRename: true, canDelete: true }); + }(); + const chats = observableValue('renameChats', [mainChat, peerChat, otherPeerChat]); + const session: ISession = { + ...baseSession, + chats, + mainChat: constObservable(mainChat), + }; + const activeChat = observableValue('renameActiveChat', peerChat); + const focusedChat = observableValue('renameFocusedChat', peerChat); + const activeSession = upcastPartial({ + ...session, + activeChat, + }); + instantiationService.stub(IQuickInputService, quickInputService); + instantiationService.stub(ISessionsManagementService, managementService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(activeSession); + }()); + instantiationService.stub(ISessionsPartService, new class extends mock() { + override getFocusedSessionView(): SessionView { + return upcastPartial({ getSession: () => activeSession, getFocusedChat: () => focusedChat.get() }); + } + }()); + instantiationService.stub(IViewsService, new class extends mock() { + override getViewWithId() { return null; } + }()); + instantiationService.stub(IUriIdentityService, new class extends mock() { + override readonly extUri = extUri; + }()); + const handler = CommandsRegistry.getCommand(RENAME_CHAT_COMMAND_ID)?.handler; + assert.ok(handler); + return { handler, instantiationService, quickInputService, managementService, session, activeSession, mainChat, peerChat, otherPeerChat, activeChat, focusedChat, chats }; + } + + test('renames the exact peer chat with the peer title as the prompt value', async () => { + const harness = createChatHarness(); + harness.quickInputService.result = ' Renamed Peer '; + + await harness.handler(harness.instantiationService, { session: harness.session, chat: harness.peerChat }); + + assert.deepStrictEqual({ + inputValue: harness.quickInputService.options?.value, + inputPrompt: harness.quickInputService.options?.prompt, + renamedSessions: harness.managementService.renamed, + renamedChats: harness.managementService.renamedChats, + }, { + inputValue: 'Grill and Plan', + inputPrompt: 'New chat title', + renamedSessions: [], + renamedChats: [{ session: harness.session, chatResource: harness.peerChat.resource, title: 'Renamed Peer' }], + }); + }); + + test('rejects main, unsupported, untitled, cancelled, blank, and unchanged chat renames', async () => { + const main = createChatHarness(); + await main.handler(main.instantiationService, { session: main.session, chat: main.mainChat }); + + const unsupported = createChatHarness({ canRename: false }); + await unsupported.handler(unsupported.instantiationService, { session: unsupported.session, chat: unsupported.peerChat }); + + const untitled = createChatHarness({ status: SessionStatus.Untitled }); + await untitled.handler(untitled.instantiationService, { session: untitled.session, chat: untitled.peerChat }); + + const cancelled = createChatHarness(); + cancelled.quickInputService.result = undefined; + await cancelled.handler(cancelled.instantiationService, { session: cancelled.session, chat: cancelled.peerChat }); + + const blank = createChatHarness(); + blank.quickInputService.result = ' '; + await blank.handler(blank.instantiationService, { session: blank.session, chat: blank.peerChat }); + + const unchanged = createChatHarness(); + unchanged.quickInputService.result = ' Grill and Plan '; + await unchanged.handler(unchanged.instantiationService, { session: unchanged.session, chat: unchanged.peerChat }); + + assert.deepStrictEqual({ + inputCalls: { + main: main.quickInputService.calls, + unsupported: unsupported.quickInputService.calls, + untitled: untitled.quickInputService.calls, + cancelled: cancelled.quickInputService.calls, + blank: blank.quickInputService.calls, + unchanged: unchanged.quickInputService.calls, + }, + renamedChatCounts: [ + main, + unsupported, + untitled, + cancelled, + blank, + unchanged, + ].map(harness => harness.managementService.renamedChats.length), + }, { + inputCalls: { + main: 0, + unsupported: 0, + untitled: 0, + cancelled: 1, + blank: 1, + unchanged: 1, + }, + renamedChatCounts: [0, 0, 0, 0, 0, 0], + }); + }); + + test('captures the peer target and fails closed if it disappears while Quick Input is open', async () => { + const harness = createChatHarness(); + const input = new DeferredPromise(); + harness.quickInputService.inputHandler = async () => input.p; + + const rename = harness.handler(harness.instantiationService, { session: harness.session, chat: harness.peerChat }); + harness.chats.set([harness.mainChat], undefined); + input.complete('Renamed Peer'); + await rename; + + assert.deepStrictEqual(harness.managementService.renamedChats, []); + }); + + test('uses and captures the focused group chat while the session active chat is stale', async () => { + const harness = createChatHarness(); + const input = new DeferredPromise(); + harness.quickInputService.inputHandler = async () => input.p; + harness.activeChat.set(harness.mainChat, undefined); + + const rename = harness.handler(harness.instantiationService); + harness.focusedChat.set(harness.otherPeerChat, undefined); + input.complete('Renamed Peer'); + await rename; + + assert.deepStrictEqual(harness.managementService.renamedChats, [{ + session: harness.activeSession, + chatResource: harness.peerChat.resource, + title: 'Renamed Peer', + }]); + }); + + test('propagates provider errors', async () => { + const harness = createChatHarness(); + harness.quickInputService.result = 'Renamed Peer'; + harness.managementService.renameChatError = new Error('rename chat failed'); + + await assert.rejects( + async () => { + await harness.handler(harness.instantiationService, { session: harness.session, chat: harness.peerChat }); + }, + harness.managementService.renameChatError, + ); + }); + }); + suite('session header action', () => { function createHeaderHarness(inlineRename: boolean | undefined) { const instantiationService = disposables.add(new TestInstantiationService()); @@ -331,8 +510,11 @@ suite('Sessions rename', () => { assert.deepStrictEqual({ hasDoubleClick: content.includes('double-click its title'), hasContextMenu: content.includes('open its context menu'), - hasChatFocus: content.includes('chat transcript or input'), - hasRenameKeybinding: content.includes(``), + hasMainChatFocus: content.includes('main chat transcript or input'), + hasPeerChatFocus: content.includes('non-main chat') && content.includes('nested row'), + scopesChatRenameToAvailability: content.includes('When Rename is available for a non-main chat'), + hasSessionRenameKeybinding: content.includes(``), + hasChatRenameKeybinding: content.includes(``), hasArchiveKeybinding: content.includes(``), hasPermanentDelete: content.includes('open its context menu and choose Delete'), hasDevContainerAvailability: content.includes('Docker is available') && content.includes('selected local folder contains a Dev Container configuration'), @@ -344,8 +526,11 @@ suite('Sessions rename', () => { }, { hasDoubleClick: true, hasContextMenu: true, - hasChatFocus: true, - hasRenameKeybinding: true, + hasMainChatFocus: true, + hasPeerChatFocus: true, + scopesChatRenameToAvailability: true, + hasSessionRenameKeybinding: true, + hasChatRenameKeybinding: true, hasArchiveKeybinding: true, hasPermanentDelete: true, hasDevContainerAvailability: true, diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts index da485c4e86bc4c..bd00f7b04a54c9 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsSessionManagementActions.test.ts @@ -8,7 +8,9 @@ import { decodeKeybinding } from '../../../../../base/common/keybindings.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; -import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyValue, IContext } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -17,17 +19,20 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { RawWorkbenchListFocusContextKey } from '../../../../../platform/list/browser/listService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { FocusedViewContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IView } from '../../../../../workbench/common/views.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; -import { ARCHIVE_SESSION_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; -import { SessionActiveChatIsDeletableContext, SessionSupportsRenameContext, SessionsFocusContext } from '../../../../common/contextkeys.js'; +import { ARCHIVE_SESSION_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { SessionActiveChatIsDeletableContext, SessionFocusedChatIsRenameTargetContext, SessionSupportsRenameContext, SessionsFocusContext } from '../../../../common/contextkeys.js'; +import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; +import { SessionView } from '../../../../browser/parts/sessionView.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ArchiveSessionAction } from '../../browser/views/sessionsViewActions.js'; -import { SessionsList } from '../../browser/views/sessionsList.js'; +import { ISessionChatItem, SessionsList, SessionsListFocusedChatItemContext } from '../../browser/views/sessionsList.js'; import { SessionsView, SessionsViewId } from '../../browser/views/sessionsView.js'; import { createTestSession, TestSessionsManagementService } from './sessionsListTestUtils.js'; import '../../browser/sessionsActions.js'; @@ -49,11 +54,13 @@ suite('Sessions - Session management actions', () => { test('scopes Rename and Archive keybindings to their Agents Window surfaces', () => { const renameRule = getKeybindingRule(RENAME_SESSION_COMMAND_ID, KeyCode.F2); + const renameChatRule = getKeybindingRule(RENAME_CHAT_COMMAND_ID, KeyCode.F2); const archiveSessionRule = getKeybindingRule(ARCHIVE_SESSION_COMMAND_ID, KeyCode.Delete); const archiveSessionMacRule = getKeybindingRule(ARCHIVE_SESSION_COMMAND_ID, KeyMod.CtrlCmd | KeyCode.Backspace, OperatingSystem.Macintosh); const deleteSessionRule = getKeybindingRule('sessionsViewPane.deleteSession', KeyCode.Delete); const deleteChatRule = getKeybindingRule(DELETE_CHAT_COMMAND_ID, KeyCode.Delete); assert.ok(renameRule?.when); + assert.ok(renameChatRule?.when); assert.ok(archiveSessionRule?.when); assert.ok(archiveSessionMacRule?.when); assert.ok(deleteChatRule?.when); @@ -64,22 +71,35 @@ suite('Sessions - Session management actions', () => { ...sessionsWindow, [FocusedViewContext.key]: SessionsViewId, [RawWorkbenchListFocusContextKey.key]: true, + [SessionsListFocusedChatItemContext.key]: false, }; const chatTranscript = { ...sessionsWindow, [ChatContextKeys.inChatSession.key]: true, [SessionSupportsRenameContext.key]: true, [SessionsFocusContext.key]: true, + [SessionFocusedChatIsRenameTargetContext.key]: false, }; + const peerChatTranscript = { ...chatTranscript, [SessionFocusedChatIsRenameTargetContext.key]: true }; + const nestedChat = { ...sessionsList, [SessionsListFocusedChatItemContext.key]: true }; assert.deepStrictEqual({ renameWeight: renameRule.weight1, - renameInList: evaluate(renameRule, sessionsList), + renameChatWeight: renameChatRule.weight1, + renameSessionRow: evaluate(renameRule, sessionsList), + renameChatOnSessionRow: evaluate(renameChatRule, sessionsList), + renameNestedChatAsSession: evaluate(renameRule, nestedChat), + renameNestedChat: evaluate(renameChatRule, nestedChat), renameInListFindInput: evaluate(renameRule, { ...sessionsList, [InputFocusedContext.key]: true }), - renameInTranscript: evaluate(renameRule, chatTranscript), - renameInChatInput: evaluate(renameRule, { ...chatTranscript, [ChatContextKeys.inChatInput.key]: true, [InputFocusedContext.key]: true }), - renameUnsupportedChat: evaluate(renameRule, { ...chatTranscript, [SessionSupportsRenameContext.key]: false }), + renameChatInListFindInput: evaluate(renameChatRule, { ...nestedChat, [InputFocusedContext.key]: true }), + renameMainTranscriptAsSession: evaluate(renameRule, chatTranscript), + renameMainTranscriptAsChat: evaluate(renameChatRule, chatTranscript), + renamePeerTranscriptAsSession: evaluate(renameRule, peerChatTranscript), + renamePeerTranscriptAsChat: evaluate(renameChatRule, peerChatTranscript), + renamePeerChatInput: evaluate(renameChatRule, { ...peerChatTranscript, [ChatContextKeys.inChatInput.key]: true, [InputFocusedContext.key]: true }), + renameUnsupportedPeerAsChat: evaluate(renameChatRule, { ...peerChatTranscript, [SessionSupportsRenameContext.key]: false }), renameOutsideAgentsWindow: evaluate(renameRule, { [ChatContextKeys.inChatSession.key]: true, [SessionSupportsRenameContext.key]: true }), + renameChatOutsideAgentsWindow: evaluate(renameChatRule, { [ChatContextKeys.inChatSession.key]: true, [SessionFocusedChatIsRenameTargetContext.key]: true }), archiveWeight: archiveSessionRule.weight1, archiveMacWeight: archiveSessionMacRule.weight1, archiveInList: evaluate(archiveSessionRule, sessionsList), @@ -91,12 +111,21 @@ suite('Sessions - Session management actions', () => { deleteChatInInput: evaluate(deleteChatRule, { ...chatTranscript, [SessionActiveChatIsDeletableContext.key]: true, [InputFocusedContext.key]: true }), }, { renameWeight: KeybindingWeight.SessionsContrib, - renameInList: true, + renameChatWeight: KeybindingWeight.SessionsContrib + 10, + renameSessionRow: true, + renameChatOnSessionRow: false, + renameNestedChatAsSession: true, + renameNestedChat: true, renameInListFindInput: false, - renameInTranscript: true, - renameInChatInput: true, - renameUnsupportedChat: false, + renameChatInListFindInput: false, + renameMainTranscriptAsSession: true, + renameMainTranscriptAsChat: false, + renamePeerTranscriptAsSession: true, + renamePeerTranscriptAsChat: true, + renamePeerChatInput: true, + renameUnsupportedPeerAsChat: true, renameOutsideAgentsWindow: false, + renameChatOutsideAgentsWindow: false, archiveWeight: KeybindingWeight.SessionsContrib, archiveMacWeight: KeybindingWeight.SessionsContrib, archiveInList: true, @@ -109,10 +138,13 @@ suite('Sessions - Session management actions', () => { }); }); - function createActionHarness(focusedSessions: readonly ISession[] | undefined, activeSession: ISession | undefined) { + function createActionHarness(focusedSessions: readonly ISession[] | undefined, activeSession: IActiveSession | undefined, focusedChat?: ISessionChatItem, focusedGroupChat?: IChat) { const instantiationService = disposables.add(new TestInstantiationService()); const managementService = new TestSessionsManagementService([]); - const sessionsControl = upcastPartial({ getFocusedSessions: () => focusedSessions }); + const sessionsControl = upcastPartial({ + getFocusedSessions: () => focusedSessions, + getFocusedChatItem: () => focusedChat, + }); const sessionsView = upcastPartial({ sessionsControl }); const getViewWithId = (id: string): T | null => id === SessionsViewId ? sessionsView as unknown as T : null; @@ -120,7 +152,15 @@ suite('Sessions - Session management actions', () => { instantiationService.stub(ISessionsService, upcastPartial({ activeSession: constObservable(activeSession ? upcastPartial(activeSession) : undefined), })); + instantiationService.stub(ISessionsPartService, new class extends mock() { + override getFocusedSessionView(): SessionView | undefined { + return focusedGroupChat && activeSession + ? upcastPartial({ getSession: () => activeSession, getFocusedChat: () => focusedGroupChat }) + : undefined; + } + }()); instantiationService.stub(ISessionsManagementService, managementService); + instantiationService.stub(IUriIdentityService, upcastPartial({ extUri })); instantiationService.stub(IQuickInputService, upcastPartial({ input: async () => 'Renamed', })); @@ -128,32 +168,55 @@ suite('Sessions - Session management actions', () => { return { instantiationService, managementService }; } - test('routes keybinding invocations to the focused list session or active chat session', async () => { + test('routes session and chat rename commands to their focused targets', async () => { const listSession = createTestSession('List').session; - const listActiveSession = createTestSession('Other active').session; + const listActiveSession = upcastPartial(createTestSession('Other active').session); const listHarness = createActionHarness([listSession], listActiveSession); - const activeSession = createTestSession('Active chat').session; - const chatHarness = createActionHarness(undefined, activeSession); + const base = createTestSession('Explore Jitter Issue').session; + const mainChat = base.mainChat.get(); + const peerChat = upcastPartial({ + resource: URI.parse('test-chat:///grill-and-plan'), + title: constObservable('Grill and Plan'), + status: constObservable(SessionStatus.Completed), + interactivity: constObservable(ChatInteractivity.Full), + capabilities: constObservable({ canRename: true, canDelete: true }), + }); + const activeSession = upcastPartial({ + ...base, + chats: constObservable([mainChat, peerChat]), + mainChat: constObservable(mainChat), + activeChat: constObservable(peerChat), + }); + const chatHarness = createActionHarness(undefined, activeSession, undefined, peerChat); + const nestedChatHarness = createActionHarness([], listActiveSession, { session: activeSession, chat: peerChat }); const archiveSession = createTestSession('Archive target').session; const archivedSession = createTestSession('Already archived', { isArchived: true }).session; const archiveHarness = createActionHarness([archiveSession, archivedSession], listActiveSession); const inactiveArchiveHarness = createActionHarness(undefined, listActiveSession); - const renameHandler = CommandsRegistry.getCommand(RENAME_SESSION_COMMAND_ID)?.handler; - assert.ok(renameHandler); + const renameSessionHandler = CommandsRegistry.getCommand(RENAME_SESSION_COMMAND_ID)?.handler; + const renameChatHandler = CommandsRegistry.getCommand(RENAME_CHAT_COMMAND_ID)?.handler; + assert.ok(renameSessionHandler); + assert.ok(renameChatHandler); - await renameHandler(listHarness.instantiationService); - await renameHandler(chatHarness.instantiationService); + await renameSessionHandler(listHarness.instantiationService); + await renameSessionHandler(chatHarness.instantiationService); + await renameChatHandler(chatHarness.instantiationService); + await renameChatHandler(nestedChatHarness.instantiationService); await archiveHarness.instantiationService.invokeFunction(accessor => new ArchiveSessionAction().run(accessor)); await inactiveArchiveHarness.instantiationService.invokeFunction(accessor => new ArchiveSessionAction().run(accessor)); assert.deepStrictEqual({ listRename: listHarness.managementService.renamed.map(({ session, title }) => ({ sessionId: session.sessionId, title })), - chatRename: chatHarness.managementService.renamed.map(({ session, title }) => ({ sessionId: session.sessionId, title })), + sessionRenameFromChat: chatHarness.managementService.renamed.map(({ session, title }) => ({ sessionId: session.sessionId, title })), + activeChatRename: chatHarness.managementService.renamedChats.map(({ session, chatResource, title }) => ({ sessionId: session.sessionId, chatResource: chatResource.toString(), title })), + nestedChatRename: nestedChatHarness.managementService.renamedChats.map(({ session, chatResource, title }) => ({ sessionId: session.sessionId, chatResource: chatResource.toString(), title })), archived: archiveHarness.managementService.archived.map(session => session.sessionId), inactiveArchived: inactiveArchiveHarness.managementService.archived, }, { listRename: [{ sessionId: listSession.sessionId, title: 'Renamed' }], - chatRename: [{ sessionId: activeSession.sessionId, title: 'Renamed' }], + sessionRenameFromChat: [{ sessionId: activeSession.sessionId, title: 'Renamed' }], + activeChatRename: [{ sessionId: activeSession.sessionId, chatResource: peerChat.resource.toString(), title: 'Renamed' }], + nestedChatRename: [{ sessionId: activeSession.sessionId, chatResource: peerChat.resource.toString(), title: 'Renamed' }], archived: [archiveSession.sessionId], inactiveArchived: [], }); diff --git a/src/vs/sessions/services/chatView/browser/chatViewFactory.ts b/src/vs/sessions/services/chatView/browser/chatViewFactory.ts index e2b811dcd4f514..35ad1edf299051 100644 --- a/src/vs/sessions/services/chatView/browser/chatViewFactory.ts +++ b/src/vs/sessions/services/chatView/browser/chatViewFactory.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { AbstractChatView, IChatViewOptions } from '../../../browser/parts/chatView.js'; export const IChatViewFactory = createDecorator('chatViewFactory'); @@ -22,10 +22,10 @@ export interface IChatViewFactory { * Creates a "new chat" view that lets the user pick a workspace and * start a new chat. This is the view the grid is seeded with on startup. */ - createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions): AbstractChatView; + createNewChatView(isNewChatInSession: boolean, options: IChatViewOptions, instantiationService?: IInstantiationService): AbstractChatView; /** * Creates a chat view that hosts a chat widget for an active session. */ - createChatView(): AbstractChatView; + createChatView(instantiationService?: IInstantiationService): AbstractChatView; } diff --git a/src/vs/sessions/services/sessions/browser/sessionsPartService.ts b/src/vs/sessions/services/sessions/browser/sessionsPartService.ts index af7dd7d2a31515..f81f1a94b1d38c 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsPartService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsPartService.ts @@ -70,6 +70,11 @@ export interface ISessionsPartService { */ getSessionView(sessionId: string | undefined): SessionView | undefined; + /** + * Returns the session view that currently contains DOM focus. + */ + getFocusedSessionView(): SessionView | undefined; + /** * Returns the progress indicator for the sessions part, which drives the * progress bar shown at the top of the part's content area. diff --git a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts index d70400ef3a5b11..1820760360bbf7 100644 --- a/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts +++ b/src/vs/sessions/services/sessions/test/common/sessionContextKeys.test.ts @@ -247,4 +247,5 @@ suite('setSessionContextKeys - side chat', () => { withToolChatHasSideChats: false, }); }); + }); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 1bdf3ed8f92c68..99046a3919d614 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -14,12 +14,17 @@ import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../base/test/common/timeTravelScheduler.js'; +import { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { DEFAULT_EDITOR_PART_OPTIONS } from '../../../workbench/browser/parts/editor/editor.js'; import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; -import { AbstractChatView, ChatViewKind } from '../../browser/parts/chatView.js'; +import { AbstractChatView, ChatViewKind, IChatViewOptions } from '../../browser/parts/chatView.js'; import { ChatGroupsView } from '../../browser/parts/chatGroupsView.js'; +import { SessionFocusedChatIsRenameTargetContext } from '../../common/contextkeys.js'; import { type IAgentHostAutoConnect, type IAgentHostConnectProgress, IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; @@ -35,8 +40,12 @@ class TestChatView extends AbstractChatView { layoutCount = 0; primary = false; - constructor(readonly kind: ChatViewKind) { + constructor( + readonly kind: ChatViewKind, + @IContextKeyService contextKeyService: IContextKeyService, + ) { super(); + this._register(contextKeyService.createScoped(this.element)); this.element.dataset.kind = kind; this.element.appendChild(this._focusTarget); } @@ -61,16 +70,17 @@ class TestChatView extends AbstractChatView { class TestChatViewFactory extends mock() { readonly views: TestChatView[] = []; - override createNewChatView(isNewChatInSession: boolean): AbstractChatView { - return this._createView(isNewChatInSession ? 'newChatInSession' : 'newSession'); + override createNewChatView(isNewChatInSession: boolean, _options: IChatViewOptions, instantiationService?: IInstantiationService): AbstractChatView { + return this._createView(isNewChatInSession ? 'newChatInSession' : 'newSession', instantiationService); } - override createChatView(): AbstractChatView { - return this._createView('chat'); + override createChatView(instantiationService?: IInstantiationService): AbstractChatView { + return this._createView('chat', instantiationService); } - private _createView(kind: ChatViewKind): TestChatView { - const view = new TestChatView(kind); + private _createView(kind: ChatViewKind, instantiationService?: IInstantiationService): TestChatView { + assert.ok(instantiationService); + const view = instantiationService.createInstance(TestChatView, kind); this.views.push(view); return view; } @@ -222,6 +232,7 @@ function createHarness(disposables: Pick, tabsReplaceHea const instantiationService = workbenchInstantiationService(undefined, store); const sessionsService = new TestSessionsService(); const chatViewFactory = new TestChatViewFactory(); + instantiationService.stub(IContextKeyService, store.add(new ContextKeyService(new TestConfigurationService()))); const sessionsProvidersService = new TestSessionsProvidersService(); instantiationService.stub(IChatViewFactory, chatViewFactory); instantiationService.stub(IEditorGroupsService, new class extends mock() { @@ -351,6 +362,69 @@ suite('Sessions - ChatGroupsView', () => { }); }); + test('focused group publishes its rename target before the session active chat updates', async () => { + const { instantiationService, sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + sessionsService.activeSession.set(session, undefined); + view.setSession(session, options); + view.splitChatToSide(secondary.resource); + view.focusAdjacentGroup('previous'); + + const gate = new DeferredPromise(); + sessionsService.openChatGate = gate.p; + view.focusAdjacentGroup('next'); + + const contextKeyService = instantiationService.get(IContextKeyService); + const focusedContext = contextKeyService.getContext(mainWindow.document.activeElement); + const beforeOpenSettles = { + sessionActiveChat: session.activeChat.get().resource.toString(), + focusedChat: view.getFocusedChat()?.resource.toString(), + focusedChatClaimsRename: focusedContext.getValue(SessionFocusedChatIsRenameTargetContext.key), + }; + gate.complete(); + await gate.p; + await Promise.resolve(); + + assert.deepStrictEqual({ + beforeOpenSettles, + sessionActiveChatAfterOpen: session.activeChat.get().resource.toString(), + }, { + beforeOpenSettles: { + sessionActiveChat: main.resource.toString(), + focusedChat: secondary.resource.toString(), + focusedChatClaimsRename: true, + }, + sessionActiveChatAfterOpen: secondary.resource.toString(), + }); + }); + + test('focused DOM group remains the rename target when session reconciliation promotes another group', async () => { + const { instantiationService, sessionsService, view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + sessionsService.activeSession.set(session, undefined); + view.setSession(session, options); + view.splitChatToSide(secondary.resource); + view.focusAdjacentGroup('previous'); + + await sessionsService.openChat(session, secondary.resource); + + const contextKeyService = instantiationService.get(IContextKeyService); + const focusedContext = contextKeyService.getContext(mainWindow.document.activeElement); + assert.deepStrictEqual({ + sessionActiveChat: session.activeChat.get().resource.toString(), + focusedChat: view.getFocusedChat()?.resource.toString(), + focusedChatClaimsRename: focusedContext.getValue(SessionFocusedChatIsRenameTargetContext.key), + }, { + sessionActiveChat: secondary.resource.toString(), + focusedChat: main.resource.toString(), + focusedChatClaimsRename: false, + }); + }); + test('restoration settles when an already-loaded catalog no longer contains a saved chat', () => { const { view } = createHarness(disposables); const main = createChat('main'); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index ee013c65ef0247..ec527cc8949365 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -22,6 +22,7 @@ import { GroupDirection, GroupOrientation } from '../../../workbench/services/ed import { SESSIONS_LIST_MINIMUM_WIDTH } from '../../browser/parts/sidebarPart.js'; import { Menus } from '../../browser/menus.js'; import { DEFAULT_NOTIFICATION_ROW_HEIGHT, onDidChangeNotificationRowHeight, setNotificationRowHeight } from '../../../workbench/browser/parts/notifications/notificationsViewer.js'; +import { NullTelemetryServiceShape } from '../../../platform/telemetry/common/telemetryUtils.js'; interface IViewSize { width: number; height: number } @@ -31,6 +32,20 @@ class TestDockedEditorInput extends DockedEditorInput { override get resource(): undefined { return undefined; } } +function isTelemetryData(data: unknown): data is Record { + return typeof data === 'object' && data !== null; +} + +class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: Record }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName && isTelemetryData(data)) { + this.events.push({ name: eventName, data }); + } + } +} + suite('Sessions - Workbench', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -77,6 +92,7 @@ suite('Sessions - Workbench', () => { layoutPolicy: { isPhoneLayout: IObservable }; _register(disposable: T): T; }) => void; + const logWindowLayout = Reflect.get(Workbench.prototype, 'logWindowLayout') as (this: ITestWorkbench, telemetryService: TestTelemetryService) => void; // --- Harness ------------------------------------------------------------ @@ -379,6 +395,18 @@ suite('Sessions - Workbench', () => { // --- Notifications ------------------------------------------------------ + test('logs the selected Agents window layout', () => { + const telemetryService = new TestTelemetryService(); + + logWindowLayout.call(createHost(), telemetryService); + logWindowLayout.call(createHost({ single: true }), telemetryService); + + assert.deepStrictEqual(telemetryService.events, [ + { name: 'agents/windowLayout', data: { layout: 'classic' } }, + { name: 'agents/windowLayout', data: { layout: 'sidePane' } }, + ]); + }); + test('uses touch-sized notification rows on phone layouts', () => { setNotificationRowHeight(DEFAULT_NOTIFICATION_ROW_HEIGHT); const registeredDisposables = new DisposableStore(); diff --git a/src/vs/workbench/api/browser/mainThreadDataChannels.ts b/src/vs/workbench/api/browser/mainThreadDataChannels.ts index edb18aaf53a823..ef43b3e7d4e651 100644 --- a/src/vs/workbench/api/browser/mainThreadDataChannels.ts +++ b/src/vs/workbench/api/browser/mainThreadDataChannels.ts @@ -9,6 +9,7 @@ import { autorun, observableValue } from '../../../base/common/observable.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IDataChannelService, ILinkPresentation, ILinkPresentationProvider, ILinkPresentationService, ILinkPresentationWatcher, LinkPresentationKind, parseLinkPresentation } from '../../../platform/dataChannel/common/dataChannel.js'; +import { ILogService } from '../../../platform/log/common/log.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostDataChannelsShape, MainContext, MainThreadDataChannelsShape } from '../common/extHost.protocol.js'; @@ -25,6 +26,7 @@ export class MainThreadDataChannels extends Disposable implements MainThreadData extHostContext: IExtHostContext, @IDataChannelService private readonly _dataChannelService: IDataChannelService, @ILinkPresentationService private readonly _linkPresentationService: ILinkPresentationService, + @ILogService private readonly _logService: ILogService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostDataChannels); @@ -47,6 +49,7 @@ export class MainThreadDataChannels extends Disposable implements MainThreadData $createLinkPresentationWatcher(handle: number, providerId: string, kind: LinkPresentationKind, resource: UriComponents): void { const watcher = this._linkPresentationService.createLinkPresentationWatcher(providerId, URI.revive(resource)); if (!watcher) { + this._logService.warn(`[MainThreadDataChannels] Link presentation provider '${providerId}' does not accept '${URI.revive(resource).toString(true)}'`); this._proxy.$acceptLinkPresentation(handle, { kind, status: { kind: 'error', label: localize('linkPresentation.unavailable', "Not available") }, diff --git a/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts b/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts index db0bd51cd049d4..13a471148829be 100644 --- a/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadDataChannels.test.ts @@ -44,6 +44,7 @@ suite('MainThreadDataChannels', () => { new TestConfigurationService(), store.add(new TestStorageService()), )), + new NullLogService(), )); mainThread.$createLinkPresentationWatcher(1, 'missing', 'pullRequest', URI.parse('https://example.com/pull/1')); @@ -103,6 +104,7 @@ suite('MainThreadDataChannels', () => { SingleProxyRPCProtocol(extHostProxy), store.add(new DataChannelService()), linkPresentationService, + new NullLogService(), )); const extHost = new ExtHostDataChannels(SingleProxyRPCProtocol(mainThread)); extHostHolder.value = extHost; @@ -180,6 +182,7 @@ suite('MainThreadDataChannels', () => { SingleProxyRPCProtocol(extHostProxy), store.add(new DataChannelService()), linkPresentationService, + new NullLogService(), )); const extHost = new ExtHostDataChannels(SingleProxyRPCProtocol(mainThread)); const extension = { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index e48ef83bf1bdf5..7a69461a8f94c2 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -108,7 +108,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { } ); - for (const extension of ['html', 'htm']) { + for (const extension of ['html', 'htm', 'mhtml', 'mht']) { editorResolverService.registerEditor( `${Schemas.file}:/**/*.${extension}`, { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 8c0dfd27b3bf33..471aed5624101c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -340,9 +340,9 @@ class OpenIntegratedBrowserAction extends Action2 { class OpenFileInIntegratedBrowserAction extends Action2 { constructor() { - const IS_LOCAL_HTML_FILE = ContextKeyExpr.and( + const IS_SUPPORTED_LOCAL_BROWSER_FILE = ContextKeyExpr.and( ResourceContextKey.Scheme.isEqualTo(Schemas.file), - ContextKeyExpr.regex(ResourceContextKey.Extension.key, /\.html?$/i), + ContextKeyExpr.regex(ResourceContextKey.Extension.key, /\.(?:html?|mht(?:ml)?)$/i), ); super({ id: BrowserViewCommandId.OpenFile, @@ -350,25 +350,25 @@ class OpenFileInIntegratedBrowserAction extends Action2 { category: BrowserActionCategory, icon: Codicon.globe, f1: true, - precondition: IS_LOCAL_HTML_FILE, + precondition: IS_SUPPORTED_LOCAL_BROWSER_FILE, menu: [ { id: MenuId.ExplorerContext, group: 'navigation', order: 29, - when: IS_LOCAL_HTML_FILE, + when: IS_SUPPORTED_LOCAL_BROWSER_FILE, }, { id: MenuId.EditorTitleContext, group: '1_open', order: 5, - when: IS_LOCAL_HTML_FILE, + when: IS_SUPPORTED_LOCAL_BROWSER_FILE, }, { id: MenuId.EditorTitle, group: 'navigation', order: 99, - when: IS_LOCAL_HTML_FILE, + when: IS_SUPPORTED_LOCAL_BROWSER_FILE, }, ] }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 5028d80a5bbd77..10556e77706ae4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -12,7 +12,7 @@ import { Delayer } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -365,6 +365,9 @@ export class AgentHostChatInputPicker extends Disposable { private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined; private readonly _initialResolveCts = this._registerInitialResolveCts(); private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _pickerDisposables = this._register(new DisposableStore()); + private readonly _sandboxToggleDisabled = derived(this, reader => this._agentHostEnablementService.managedSandboxEnforced.read(reader) + && !this._agentHostEnablementService.managedSandboxAllowsBypass.read(reader)); private readonly _filterDelayer = this._register(new Delayer[]>(200)); private readonly _subRef = this._register(new MutableDisposable; readonly backendSession: URI }>()); @@ -700,7 +703,10 @@ export class AgentHostChatInputPicker extends Disposable { return toActionItems(this._property, await this._getItems(refreshed.schema, query), refreshed.value, isAutoApprovePolicyRestricted(this._configurationService), this._getSandboxStandaloneToggle()); }) : undefined, - onHide: () => trigger.focus(), + onHide: () => { + this._pickerDisposables.clear(); + trigger.focus(); + }, }; this._actionWidgetService.show( @@ -722,6 +728,15 @@ export class AgentHostChatInputPicker extends Disposable { : {}), }), ); + if (actionItems.some(item => item.standaloneToggle)) { + this._pickerDisposables.add(autorun(reader => { + this._agentHostEnablementService.managedSandboxEnforced.read(reader); + this._sandboxToggleDisabled.read(reader); + this._actionWidgetService.updateItems(actionItems.map(item => item.standaloneToggle + ? { ...item, standaloneToggle: this._getSandboxStandaloneToggle() } + : item)); + })); + } } private _getSandboxSettingId(): ReturnType { @@ -743,21 +758,28 @@ export class AgentHostChatInputPicker extends Disposable { return settingId !== undefined && isAgentSandboxEnabledValue(this._configurationService.getValue(settingId)); } + private _isSandboxToggleDisabled(): boolean { + return this._sandboxToggleDisabled.get(); + } + private _getSandboxStandaloneToggle(): IActionListItemInlineToggle | undefined { const settingId = this._getSandboxSettingId(); if (this._property !== SessionConfigKey.AutoApprove || !this._isSandboxToggleSettingEnabled() || !settingId) { return undefined; } const managed = this._agentHostEnablementService.managedSandboxEnforced.get(); + const disabled = this._isSandboxToggleDisabled(); return { label: localize('agentHostChatInputPicker.defaultSandboxToggle', "Sandboxing for terminal"), title: managed - ? localize('agentHostChatInputPicker.managedSandboxToggleTitle', "Sandboxing is managed by your organization") + ? disabled + ? localize('agentHostChatInputPicker.requiredSandboxToggleTitle', "Sandboxing is required by your organization") + : localize('agentHostChatInputPicker.editableManagedSandboxToggleTitle', "Sandboxing is enabled by your organization, but you may disable it") : localize('agentHostChatInputPicker.defaultSandboxToggleTitle', "Run terminal commands inside a sandbox that restricts file system and network access"), checked: this._isSandboxingEnabled(), - disabled: managed, + disabled, onChange: checked => { - if (this._agentHostEnablementService.managedSandboxEnforced.get()) { + if (this._isSandboxToggleDisabled()) { return; } const target = checked ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index ff51c76d74df0f..b971ba54736038 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -48,7 +48,7 @@ import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuth import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readMessageSystemInitiatedLabel, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -315,6 +315,7 @@ function getMcpAuthenticationRequiredServers(sessionResource: URI, state: ISessi interface IStartServerRequestOptions { readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; readonly isHidden?: boolean; readonly isRequestHidden?: boolean; readonly timestamp?: number; @@ -805,6 +806,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { prompt, variableData, isSystemInitiated: options?.isSystemInitiated, + systemInitiatedLabel: options?.systemInitiatedLabel, isHidden: options?.isHidden, isRequestHidden: options?.isRequestHidden, timestamp: options?.timestamp, @@ -2421,6 +2423,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC messageToVariableData(activeTurn.message, this._config.connectionAuthority), { isSystemInitiated: activeTurn.message.origin.kind === MessageKind.SystemNotification, + systemInitiatedLabel: readMessageSystemInitiatedLabel(activeTurn.message), isHidden: isMessageHiddenFromTranscript(activeTurn.message), isRequestHidden: isMessageRequestHiddenFromTranscript(activeTurn.message), timestamp: parseTimestamp(activeTurn.startedAt), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 05476439941e78..ce2c77d8383645 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -310,11 +310,16 @@ export class AgentHostSessionListStore extends Disposable { return; } + const { activity, ...changes } = notification.changes; const updated: IAgentHostSessionListEntry = { provider, rawId, statusKnown: cached.statusKnown || notification.changes.status !== undefined, - summary: { ...cached.summary, ...notification.changes }, + summary: { + ...cached.summary, + ...changes, + ...(Object.prototype.hasOwnProperty.call(notification.changes, 'activity') ? { activity: activity ?? undefined } : {}), + }, }; if (!this._isSessionInWorkspace(updated)) { this._mutationGeneration++; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index d413de68f5e421..f273f9ad66b85c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -14,7 +14,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, getTurnError, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getTurnError, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, readMessageSystemInitiatedLabel, readUsageInfoMeta, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; @@ -77,6 +77,10 @@ function isRenameChatTool(toolCall: ToolCallState): boolean { return toolCall.toolName === SessionServerToolName.RenameChat || toolCall.toolName.endsWith(`__${SessionServerToolName.RenameChat}`); } +function isSetWorkspaceTool(toolCall: ToolCallState): boolean { + return toolCall.toolName === SessionServerToolName.SetWorkspace || toolCall.toolName.endsWith(`__${SessionServerToolName.SetWorkspace}`); +} + function isAutomaticTitleRename(toolCall: ToolCallState): boolean { if (!isRenameChatTool(toolCall) || toolCall.status === ToolCallStatus.Streaming) { return false; @@ -942,6 +946,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part ...(isMessageRequestHiddenFromTranscript(turn.message) ? { isRequestHidden: true } : {}), ...(isSystemInitiated ? { isSystemInitiated: true, + systemInitiatedLabel: readMessageSystemInitiatedLabel(turn.message), } : {}), ...(isTerminalRequest ? { isTerminalRequest: true, @@ -2353,7 +2358,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI }; } else if (getToolKind(tc) === 'terminal' && getInlineToolInput(tc.toolInput)) { toolSpecificData = buildTerminalToolSpecificData(tc, sessionResource); - } else { + } else if (!isSetWorkspaceTool(tc)) { const toolInput = getInlineToolInput(tc.toolInput); if (toolInput) { let rawInput: unknown; diff --git a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts index a5fecc91b84313..f6642d3f8b72a8 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts @@ -13,9 +13,10 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { basename, dirname } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { isLocation, Location } from '../../../../../editor/common/languages.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; @@ -30,17 +31,40 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; -import { ChatContextIconPath, IChatRequestStringVariableEntry, isStringImplicitContextValue, resolveChatContextIcon } from '../../common/attachments/chatVariableEntries.js'; +import { ChatContextIconPath, IChatRequestStringVariableEntry, IChatRequestVariableEntry, isStringImplicitContextValue, isStringVariableEntry, resolveChatContextIcon } from '../../common/attachments/chatVariableEntries.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IChatWidget } from '../chat.js'; import { ChatAttachmentModel } from './chatAttachmentModel.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; import { ChatImplicitContext, ChatImplicitContexts } from './chatImplicitContext.js'; -import { IRange } from '../../../../../editor/common/core/range.js'; import { IBrowserViewWorkbenchService } from '../../../browserView/common/browserView.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +export function isImplicitContextAlreadyAttached(attachments: readonly IChatRequestVariableEntry[], targetUri: URI | undefined, targetRange: IRange | undefined, targetHandle: number | undefined): boolean { + return attachments.some(attachment => { + if (targetHandle !== undefined) { + return isStringVariableEntry(attachment) + && (attachment.handle === targetHandle || (targetUri !== undefined && isEqual(targetUri, attachment.uri))); + } + if (isStringVariableEntry(attachment)) { + return false; + } + const attachmentUri = URI.isUri(attachment.value) + ? attachment.value + : isLocation(attachment.value) + ? attachment.value.uri + : undefined; + const attachmentRange = isLocation(attachment.value) ? attachment.value.range : undefined; + if (targetUri && attachmentUri && isEqual(targetUri, attachmentUri)) { + return targetRange && attachmentRange + ? Range.equalsRange(targetRange, attachmentRange) + : !targetRange && !attachmentRange; + } + return false; + }); +} + export class ImplicitContextAttachmentWidget extends Disposable { private readonly renderDisposables = this._register(new DisposableStore()); 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 2afcd194db8b78..85ef33db1f8429 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -2450,6 +2450,11 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.subagents.useRichRendering', "Controls whether subagents in chat editors use a rich presentation that opens each subagent in its own editor instead of rendering its full activity inline in the parent chat."), default: true, }, + [ChatConfiguration.SubagentsShowCreditUsage]: { + type: 'boolean', + description: nls.localize('chat.subagents.showCreditUsage', "Controls whether AI credit usage is shown next to the duration for subagents."), + default: false, + }, [ChatConfiguration.TerminalAgentHostEnabled]: { type: 'boolean', description: nls.localize('chat.terminal.agentHost.enabled', "Controls whether Terminal Chat is backed by the Agent Host instead of the extension host. Applied on startup."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts index 6da7d942b7b261..ef804ad9d3135a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPlanReviewPart.ts @@ -57,6 +57,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { private readonly _buttonStore = this._register(new DisposableStore()); private _submitButton: Button | undefined; + private _approveButton: IButton | undefined; private _renderedSubmitInlineCount = -1; private readonly _messageContentDisposables = this._register(new MutableDisposable()); private readonly _planChangeListeners = this._register(new DisposableStore()); @@ -392,7 +393,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } // Update the cached Submit button rather than re-rendering the // whole button row on every keystroke. - this.updateSubmitButtonState(); + this.updateFeedbackActionButtonState(); })); // Enter submits feedback; Shift+Enter inserts a newline. Only wired @@ -538,9 +539,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } this.renderCommentsList(); - if (this._isFeedbackMode) { - this.updateSubmitButtonState(); - } + this.updateFeedbackActionButtonState(); this._messageScrollable.scanDomNode(); this._onDidChangeHeight.fire(); } @@ -564,11 +563,12 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { const includeReject = options?.includeReject ?? true; this._buttonStore.clear(); this._submitButton = undefined; + this._approveButton = undefined; this._renderedSubmitInlineCount = -1; dom.clearNode(container); - // In feedback mode, show Submit + Reject. Submit's label includes - // the count of pending inline comments. + // In feedback mode, keep approval available while there is no pending + // feedback. Submit's label includes the count of inline comments. if (this._isFeedbackMode) { const inlineCount = this.getInlineFeedbackItems().length; const submitButton = new Button(container, { ...defaultButtonStyles, supportIcons: true }); @@ -578,18 +578,10 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { this._renderedSubmitInlineCount = inlineCount; this._buttonStore.add(submitButton); this._buttonStore.add(submitButton.onDidClick(() => void this.submitFeedback())); - - if (includeReject) { - const rejectButton = new Button(container, { ...defaultButtonStyles, secondary: true }); - rejectButton.label = localize('chat.planReview.reject', 'Reject'); - this._buttonStore.add(rejectButton); - this._buttonStore.add(rejectButton.onDidClick(() => this.submitRejection())); - } - return; } - // Approve button first (blue). Uses ButtonWithDropdown when there are - // extra actions; otherwise a plain Button. + // Uses ButtonWithDropdown when there are extra approval actions; + // otherwise uses a plain button. const primary = this._selectedAction; const moreActions = this.review.actions.filter(a => a !== primary); @@ -598,6 +590,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { approveButton = new ButtonWithDropdown(container, { ...defaultButtonStyles, supportIcons: true, + secondary: this._isFeedbackMode, contextMenuProvider: this._contextMenuService, addPrimaryActionToDropdown: false, actions: moreActions.map(action => { @@ -616,18 +609,18 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { }) as (Action | Separator)[], }); } else { - approveButton = new Button(container, { ...defaultButtonStyles, supportIcons: true }); + approveButton = new Button(container, { ...defaultButtonStyles, secondary: this._isFeedbackMode, supportIcons: true }); } this._buttonStore.add(approveButton); + this._approveButton = approveButton; approveButton.label = primary.label; if (primary.description) { approveButton.element.title = primary.description; } + approveButton.enabled = !this.review.planUri || !this.canSubmitFeedback(); this._buttonStore.add(approveButton.onDidClick(() => this.submitApproval(primary))); - // Reject button (grey secondary) immediately after the approve button - // so the primary Approve / Reject pair stays grouped together — - // omitted in the collapsed title bar (parity with + // Reject is omitted in the collapsed title bar (parity with // chatToolConfirmationCarouselPart which only surfaces the primary // action when collapsed). if (includeReject) { @@ -653,16 +646,19 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { } /** - * Update the cached Submit button's enabled state and label without + * Update the cached feedback-mode buttons without * destroying the button row. Cheap enough to run on every keystroke. */ - private updateSubmitButtonState(): void { - if (!this._submitButton || !this._isFeedbackMode) { - return; + private updateFeedbackActionButtonState(): void { + const canSubmitFeedback = this.canSubmitFeedback(); + if (this._submitButton) { + this._submitButton.enabled = canSubmitFeedback; + } + if (this._approveButton) { + this._approveButton.enabled = !this.review.planUri || !canSubmitFeedback; } - this._submitButton.enabled = this.canSubmitFeedback(); const inlineCount = this.getInlineFeedbackItems().length; - if (inlineCount !== this._renderedSubmitInlineCount) { + if (this._submitButton && inlineCount !== this._renderedSubmitInlineCount) { this._submitButton.label = this.computeSubmitLabel(inlineCount); this._renderedSubmitInlineCount = inlineCount; } @@ -984,6 +980,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart { this.domNode.classList.add('chat-plan-review-used'); this._buttonStore.clear(); this._submitButton = undefined; + this._approveButton = undefined; this._renderedSubmitInlineCount = -1; // Hide the editor contribution even if the plan file is still open. this._planReviewRegistration.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts index 75a12af6f94818..a47b2c138b6e60 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentOpenChat.ts @@ -21,6 +21,7 @@ import { IAccessibilityService } from '../../../../../../platform/accessibility/ import { IActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { parseChatUri } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -30,7 +31,7 @@ import { ACTIVE_GROUP } from '../../../../../services/editor/common/editorServic import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { formatCopilotCreditsLabel } from '../../../common/chatService/chatService.js'; -import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, ChatConfiguration } from '../../../common/constants.js'; import { AUTO_RAW_MODEL_ID, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatWidgetService } from '../../chat.js'; import { getChatMarkdownRenderOptions } from '../chatContentMarkdownRenderer.js'; @@ -256,6 +257,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { @INotificationService notificationService: INotificationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(context, openInEditor ? createEditorOpenSubagentAction(action, chatWidgetService, notificationService) : createOpenSubagentAction(action), options); this._sourceAction = action; @@ -268,6 +270,12 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { this._finishToolTransition(); } })); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(ChatConfiguration.SubagentsShowCreditUsage)) { + this._updateCredits(); + this.updateTooltip(); + } + })); } override render(container: HTMLElement): void { @@ -423,14 +431,23 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { private _setCredits(credits: number | undefined): void { // Zero-cost subagents report 0 rather than nothing, so normalize both to hidden. - const show = typeof credits === 'number' && credits > 0; - this._reportedCredits = show ? credits : undefined; + this._reportedCredits = typeof credits === 'number' && credits > 0 ? credits : undefined; + this._updateCredits(); + } + + private _updateCredits(): void { + const credits = this._reportedCredits; + const show = credits !== undefined && this._showCreditUsage; if (this._creditsElement) { this._creditsElement.textContent = show ? formatCopilotCreditsLabel(credits) : ''; this._creditsElement.classList.toggle('hidden', !show); } } + private get _showCreditUsage(): boolean { + return this.configurationService.getValue(ChatConfiguration.SubagentsShowCreditUsage) === true; + } + private _setAgentType(agentType: string | undefined): void { this._reportedAgentType = agentType; if (this._agentTypeElement) { @@ -662,7 +679,7 @@ export class OpenSubagentChatActionViewItem extends BaseActionViewItem { if (this._reportedModelName) { details.push(localize('chat.subagent.modelTooltip', "Model: {0}", this._reportedModelName)); } - if (this._reportedCredits !== undefined) { + if (this._reportedCredits !== undefined && this._showCreditUsage) { details.push(formatCopilotCreditsLabel(this._reportedCredits)); } if (this._displayedToolAccessibleLabel && this._displayedActivityIsTool) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css index 05ffffdfe1a7de..58479754549fa4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css @@ -104,6 +104,9 @@ } .icon::before { + --file-icon-mask-position: center; + --file-icon-mask-size: contain; + display: inline-block; line-height: 100%; overflow: hidden; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index f4de2f54a5bea4..0cbd09404ade0f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -515,8 +515,8 @@ export function getChatPetBaseState(hasActiveRequest: boolean, needsInput: boole return 'idle'; } -export function shouldReserveChatPetSpace(enabled: boolean, activeHost: boolean): boolean { - return enabled && activeHost; +export function shouldReserveChatPetSpace(enabled: boolean, visible: boolean): boolean { + return enabled && visible; } export function isChatPetVisible(enabled: boolean, windowActive = true): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index ade34c6c292cce..ffe86aad33d899 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -434,8 +434,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private hasActiveRequest: IContextKey; private agentInInput: IContextKey; - private _visible = false; - get visible() { return this._visible; } + private readonly _visible = observableValue(this, false); + get visible() { return this._visible.get(); } private _inputVisible = true; private _readOnly = false; @@ -1132,7 +1132,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const inputContainer = this.inputPart.inputContainerElement; const petHost = this.inputPart.element; const inputHasContent = observableFromEvent(this, this.inputEditor.onDidChangeModelContent, () => this.inputEditor.getValue().length > 0); - const registration = this._register(this.chatPetWidgetService.register(this, { + this._register(this.chatPetWidgetService.register(this, { parent: petHost, dragBounds: inputContainer ?? petHost, movementBounds: petMovementBounds ?? parent, @@ -1142,7 +1142,7 @@ export class ChatWidget extends Disposable implements IChatWidget { getPlatformTop: petCenterX => this.inputPart.getChatPetPlatformTop(petCenterX), onDidChangePlatform: this.inputPart.onDidChangeChatPetHorizontalPlatforms, }, preferredPetHost)); - const petSpaceReserved = derived(this, reader => shouldReserveChatPetSpace(this.chatPetService.enabled.read(reader), registration.active.read(reader))); + const petSpaceReserved = derived(this, reader => shouldReserveChatPetSpace(this.chatPetService.enabled.read(reader), this._visible.read(reader))); this._register(autorun(reader => this.container.classList.toggle('chat-pet-enabled', petSpaceReserved.read(reader)))); } @@ -1423,7 +1423,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } private onDidChangeItems(skipDynamicLayout?: boolean) { - if (this._visible || !this.viewModel) { + if (this._visible.get() || !this.viewModel) { const items = this.viewModel?.getItems() ?? []; if (items.length > 0) { @@ -2065,8 +2065,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } setVisible(visible: boolean): void { - const wasVisible = this._visible; - this._visible = visible; + const wasVisible = this._visible.get(); + this._visible.set(visible, undefined); this.visibleChangeCount++; this.listWidget.setVisible(visible); this.input.setVisible(visible); @@ -2076,7 +2076,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.visibilityTimeoutDisposable.value = disposableTimeout(() => { // Progressive rendering paused while hidden, so start it up again. // Do it after a timeout because the container is not visible yet (it should be but offsetHeight returns 0 here) - if (this._visible) { + if (this._visible.get()) { this.onDidChangeItems(true); } }, 0); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 471aebdc9428a1..aaac9757e0a8f6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -91,7 +91,7 @@ import { AccessibilityCommandId } from '../../../../accessibility/common/accessi import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../../../codeEditor/browser/simpleEditorOptions.js'; import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { ChatRequestVariableSet, getImageAttachmentLimit, IChatRequestVariableEntry, isPastedTextArtifact, isAgentHostCompletionVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isExplicitFileOrImageVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; +import { ChatRequestVariableSet, getImageAttachmentLimit, IChatRequestVariableEntry, isPastedTextArtifact, isAgentHostCompletionVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isExplicitFileOrImageVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModes, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatPlanReview, IChatQuestionCarousel, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isAgentHostTarget, isIChatSessionFileChange2, localChatSessionType, SessionType } from '../../../common/chatSessionsService.js'; @@ -129,7 +129,7 @@ import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; -import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; +import { ImplicitContextAttachmentWidget, isImplicitContextAlreadyAttached } from '../../attachments/implicitContextAttachment.js'; import { IChatWidget, IChatWidgetService, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; @@ -4101,20 +4101,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge : this._implicitContext?.values.some(v => v.enabled || v.isSelection) ?? false; if (this._implicitContext && hasVisibleImplicitContext) { const isAttachmentAlreadyAttached = (targetUri: URI | undefined, targetRange: IRange | undefined, targetHandle: number | undefined): boolean => { - return this._attachmentModel.attachments.some(a => { - const aUri = URI.isUri(a.value) ? a.value : isLocation(a.value) ? a.value.uri : undefined; - const aRange = isLocation(a.value) ? a.value.range : undefined; - if (targetHandle !== undefined && isStringVariableEntry(a) && a.handle === targetHandle) { - return true; - } - if (targetUri && aUri && isEqual(targetUri, aUri)) { - if (targetRange && aRange) { - return Range.equalsRange(targetRange, aRange); - } - return !targetRange && !aRange; - } - return false; - }); + return isImplicitContextAlreadyAttached(this._attachmentModel.attachments, targetUri, targetRange, targetHandle); }; const implicitContextWidget = this.instantiationService.createInstance( ImplicitContextAttachmentWidget, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index cbbf97b58e92c3..c83d6322a3ce88 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -108,4 +108,25 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown this.renderLabel(this.element); } } + + override setFocusable(_focusable: boolean): void { + // Chat input pickers are distinct Tab stops, not only roving toolbar items. + this._updateTabIndex(); + } + + override blur(): void { + super.blur(); + this._updateTabIndex(); + } + + protected override updateEnabled(): void { + super.updateEnabled(); + this._updateTabIndex(); + } + + private _updateTabIndex(): void { + if (this.element) { + this.element.tabIndex = this.isEnabled() ? 0 : -1; + } + } } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 7cb48c8f42ee5f..99371d79af9ec4 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -94,6 +94,7 @@ export enum ChatConfiguration { SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents', SubagentsDefaultToAuto = 'chat.subagents.defaultToAuto', SubagentsUseRichRendering = 'chat.subagents.useRichRendering', + SubagentsShowCreditUsage = 'chat.subagents.showCreditUsage', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index f490dd533715fd..1fb72e679a3580 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -4,6 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { constObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionListDelegate, IActionListItem, IActionListItemInlineToggle } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { COPILOT_SANDBOX_ALLOW_BYPASS_KEY, IManagedSettingsService } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IChatWidget } from '../../../browser/chat.js'; +import { IChatViewModel } from '../../../common/model/chatViewModel.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import * as dom from '../../../../../../base/browser/dom.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -13,15 +33,154 @@ import { ClaudeSessionConfigKey } from '../../../../../../platform/agentHost/com import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { CodexSessionConfigKey } from '../../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; import type { SessionConfigPropertySchema } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { getAgentHostSandboxSettingId, getConfigPickerAccessibleTriggerLabel, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; -import { AgentHostSdkSandboxEnabledSettingId, AgentHostSdkSandboxWindowsEnabledSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; +import { AgentHostChatInputPicker, getAgentHostSandboxSettingId, getConfigPickerAccessibleTriggerLabel, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { SessionType } from '../../../common/chatSessionsService.js'; import { getAgentHostPickerProperty, OpenAgentHostAutoApprovePickerAction, OpenAgentHostCodexApprovalsPickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { isAutoApproveValuePolicyRestricted, isPermissionLevelVisible, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; -import { ChatPermissionLevel } from '../../../common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; import '../../../browser/agentSessions/agentHost/media/agentHostChatInputPicker.css'; +suite('AgentHostChatInputPicker - sandbox toggle', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('editability follows managed bypass policy', async () => { + const sandboxSettingId = getAgentHostSandboxSettingId(SessionType.AgentHostCopilot, false)!; + const writes: unknown[] = []; + const configurationService = new class extends TestConfigurationService { + override async updateValue(key: string, value: unknown): Promise { + writes.push({ key, value }); + } + }(); + store.add(configurationService.onDidChangeConfigurationEmitter); + await configurationService.setUserConfiguration(ChatConfiguration.PermissionsSandboxToggleEnabled, true); + const managedSandboxEnforced = observableValue('managedSandboxEnforced', false); + let allowBypass: boolean | undefined; + const managedSettingsChanged = store.add(new Emitter()); + const managedSettingsService: IManagedSettingsService = { + _serviceBrand: undefined, + onDidChangeManagedSettings: managedSettingsChanged.event, + getManagedSettingValue: key => key === COPILOT_SANDBOX_ALLOW_BYPASS_KEY ? allowBypass : undefined, + }; + const enablementService: IAgentHostEnablementService = { + _serviceBrand: undefined, + enabled: constObservable(true), + managedSandboxEnforced, + managedSandboxAllowsBypass: observableFromEvent(managedSettingsService, managedSettingsChanged.event, () => allowBypass === true), + }; + const visibleStates: Pick[] = []; + let onHide: (() => void) | undefined; + const recordVisibleState = (items: readonly IActionListItem[]) => { + const toggle = items.find(item => item.standaloneToggle)?.standaloneToggle; + assert.ok(toggle); + visibleStates.push({ disabled: toggle.disabled, title: toggle.title }); + }; + const actionWidgetService = new class extends mock() { + override readonly isVisible = false; + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + onHide = delegate.onHide; + recordVisibleState(items); + } + override updateItems(items: readonly IActionListItem[]): void { + recordVisibleState(items); + } + }(); + const widget = new class extends mock() { + override readonly onDidChangeViewModel = Event.None; + override viewModel: IChatViewModel | undefined; + }(); + const picker = store.add(new AgentHostChatInputPicker( + widget, + SessionConfigKey.AutoApprove, + new class extends mock() { }(), + actionWidgetService, + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { + override readonly onDidChange = Event.None; + }(), + configurationService, + new class extends mock() { }(), + new class extends mock() { }(), + store.add(new TestStorageService()), + enablementService, + )); + widget.viewModel = new class extends mock() { + override readonly sessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/test-session' }); + }(); + + for (const managed of [false, true]) { + managedSandboxEnforced.set(managed, undefined); + for (const bypass of [undefined, false, true]) { + allowBypass = bypass; + for (const configured of [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On]) { + await configurationService.setUserConfiguration(sandboxSettingId, configured); + const toggle = picker['_getSandboxStandaloneToggle']()!; + writes.length = 0; + toggle.onChange(false); + toggle.onChange(true); + const disabled = managed && bypass !== true; + assert.deepStrictEqual({ checked: toggle.checked, disabled: toggle.disabled, title: toggle.title, writes }, { + checked: managed || configured === AgentSandboxEnabledValue.On, + disabled, + title: managed + ? disabled ? 'Sandboxing is required by your organization' : 'Sandboxing is enabled by your organization, but you may disable it' + : 'Run terminal commands inside a sandbox that restricts file system and network access', + writes: disabled ? [] : [ + { key: sandboxSettingId, value: AgentSandboxEnabledValue.Off }, + { key: sandboxSettingId, value: AgentSandboxEnabledValue.On }, + ], + }); + } + } + } + + const toggle = picker['_getSandboxStandaloneToggle']()!; + allowBypass = false; + writes.length = 0; + toggle.onChange(false); + assert.deepStrictEqual({ writes, disabled: picker['_getSandboxStandaloneToggle']()!.disabled }, { writes: [], disabled: true }); + allowBypass = true; + assert.strictEqual(picker['_getSandboxStandaloneToggle']()!.disabled, false); + + picker['_initialResolved'] = { + sessionResource: widget.viewModel.sessionResource, + result: { + values: { [SessionConfigKey.AutoApprove]: 'default' }, + schema: { + type: 'object', + properties: { + [SessionConfigKey.AutoApprove]: { type: 'string', title: 'Permissions', enum: ['default', 'autoApprove'], default: 'default' }, + }, + }, + }, + }; + allowBypass = false; + await picker['_showPicker'](document.createElement('div')); + allowBypass = true; + managedSettingsChanged.fire(); + managedSettingsChanged.fire(); + managedSandboxEnforced.set(false, undefined); + managedSandboxEnforced.set(true, undefined); + allowBypass = false; + managedSettingsChanged.fire(); + assert.ok(onHide); + onHide(); + allowBypass = true; + managedSettingsChanged.fire(); + assert.deepStrictEqual(visibleStates, [ + { disabled: true, title: 'Sandboxing is required by your organization' }, + { disabled: true, title: 'Sandboxing is required by your organization' }, + { disabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it' }, + { disabled: false, title: 'Run terminal commands inside a sandbox that restricts file system and network access' }, + { disabled: false, title: 'Sandboxing is enabled by your organization, but you may disable it' }, + { disabled: true, title: 'Sandboxing is required by your organization' }, + ]); + }); +}); + suite('AgentHostChatInputPicker - compact layout', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index e191bf2d8ec5e9..f7af02ff470c64 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -15,7 +15,7 @@ import { toAgentMessageDelegationMeta } from '../../../../../../platform/agentHo import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { createAgentHostResourceUriMapper, fromAgentHostUri, toAgentHostContentUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, withMessageRequestHiddenFromTranscript, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, withMessageRequestHiddenFromTranscript, withMessageSystemInitiatedLabel, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatTranscriptContextAttachmentDisplayKind, IChatRequestTranscriptContextVariableEntry, toChatTranscriptContextAttachmentMeta } from '../../../common/attachments/chatVariableEntries.js'; import { ChatRequestOriginKind } from '../../../common/chatRequestOrigin.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatToolInputInvocationData, type IChatUsage } from '../../../common/chatService/chatService.js'; @@ -317,15 +317,23 @@ suite('stateToProgressAdapter', () => { test('system-initiated turn preserves compact request label', () => { const turn = createTurn({ - message: message('`sleep 6` completed', MessageKind.SystemNotification), + message: withMessageSystemInitiatedLabel( + message('Continue the original task in the new workspace.', MessageKind.SystemNotification), + 'Workspace Set', + ), }); const history = turnsToHistory(URI.file('/'), [turn], 'participant-1'); - assert.strictEqual(history[0].type, 'request'); - if (history[0].type !== 'request') { return; } - assert.strictEqual(history[0].isSystemInitiated, true); - assert.strictEqual(history[0].prompt, '`sleep 6` completed'); - assert.strictEqual(history[0].systemInitiatedLabel, undefined); + assert.deepStrictEqual(history[0], { + id: turn.id, + type: 'request', + prompt: 'Continue the original task in the new workspace.', + participant: 'participant-1', + modelId: undefined, + variableData: undefined, + isSystemInitiated: true, + systemInitiatedLabel: 'Workspace Set', + }); }); test('hidden turn remains hidden when restored from protocol history', () => { @@ -1306,6 +1314,31 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.source, ToolDataSource.Internal); }); + test('set_workspace confirmation hides implementation input', () => { + const invocation = toolCallStateToInvocation({ + toolCallId: 'tc-set-workspace', + toolName: 'set_workspace', + displayName: 'Set Workspace', + invocationMessage: 'Continue this session in /workspace/app and make changes directly in that folder?', + status: ToolCallStatus.PendingConfirmation, + confirmationTitle: 'Continue in app?', + toolInput: '{"workspaceFolder":"/workspace/app","isolation":false}', + }); + const state = invocation.state.get(); + + assert.deepStrictEqual({ + confirmationMessages: state.type === IChatToolInvocation.StateKind.WaitingForConfirmation ? state.confirmationMessages : undefined, + toolSpecificData: invocation.toolSpecificData, + }, { + confirmationMessages: { + title: 'Continue in app?', + message: 'Continue this session in /workspace/app and make changes directly in that folder?', + approvalReason: undefined, + }, + toolSpecificData: undefined, + }); + }); + test('renders ask-user tools as waiting progress that hides after completion', () => { const toolNames = ['ask_user', 'AskUserQuestion', 'request_user_input']; const live = toolNames.map(toolName => { diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts new file mode 100644 index 00000000000000..de4f33bcdf0046 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/implicitContextAttachment.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { isImplicitContextAlreadyAttached } from '../../../browser/attachments/implicitContextAttachment.js'; +import { IChatRequestStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; + +suite('ImplicitContextAttachment', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('matches refreshed string context by resource URI', () => { + const uri = URI.parse('pr://github/microsoft/vscode/195'); + const attachment: IChatRequestStringVariableEntry = { + kind: 'string', + id: 'vscode.implicit.string', + name: '#195 Support for adding, editing, and deleting comments', + value: 'pull request context', + uri, + handle: 1, + }; + + assert.deepStrictEqual({ + refreshedHandle: isImplicitContextAlreadyAttached([attachment], uri, undefined, 2), + differentResource: isImplicitContextAlreadyAttached([attachment], URI.parse('pr://github/microsoft/vscode/196'), undefined, 2), + activeResource: isImplicitContextAlreadyAttached([attachment], uri, undefined, undefined), + }, { + refreshedHandle: true, + differentResource: false, + activeResource: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts index 57975e53ea1de7..98aee100c27957 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatPlanReviewPart.test.ts @@ -26,6 +26,7 @@ import { ITextFileContent, ITextFileService } from '../../../../../../services/t import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { AgentEditorCommentsBridge, IAgentEditorComment, IAgentEditorCommentsBridge } from '../../../../../../services/agentEditorComments/common/agentEditorComments.js'; import { Emitter, Event as VSCodeEvent } from '../../../../../../../base/common/event.js'; +import { IContextMenuService } from '../../../../../../../platform/contextview/browser/contextView.js'; function createMockReview(overrides?: Partial): IChatPlanReview { return { @@ -87,6 +88,7 @@ suite('ChatPlanReviewPart', () => { let lastTextFileService: ITextFileService | undefined; let lastModelService: IModelService | undefined; let lastCommentsBridge: AgentEditorCommentsBridge | undefined; + let lastContextMenuService: IContextMenuService | undefined; let fileChangesEmitter: Emitter | undefined; function createWidget(review: IChatPlanReview, dialogService?: TestDialogService, onSubmit?: () => void): ChatPlanReviewPart { @@ -101,6 +103,7 @@ suite('ChatPlanReviewPart', () => { lastTextFileService = instantiationService.get(ITextFileService); lastModelService = instantiationService.get(IModelService); lastCommentsBridge = commentsBridge; + lastContextMenuService = instantiationService.get(IContextMenuService); if (fileChangesEmitter) { sinon.stub(instantiationService.get(IFileService), 'createWatcher').returns({ onDidChange: fileChangesEmitter.event, @@ -133,6 +136,7 @@ suite('ChatPlanReviewPart', () => { lastTextFileService = undefined; lastModelService = undefined; lastCommentsBridge = undefined; + lastContextMenuService = undefined; fileChangesEmitter = undefined; sinon.restore(); }); @@ -358,9 +362,15 @@ suite('ChatPlanReviewPart', () => { }); suite('Feedback mode', () => { - test('clicking Review button opens the plan editor and shows Submit Feedback button', async () => { - createWidget(createMockReviewWithPlan()); + test('clicking Review button opens the plan editor and preserves the approval dropdown', async () => { + createWidget(createMockReviewWithPlan({ + actions: [ + { id: 'interactive', label: 'Implement Plan', default: true }, + { id: 'autopilot', label: 'Implement with Autopilot' }, + ], + })); const openEditorSpy = sinon.spy(lastEditorService!, 'openEditor'); + const showContextMenuStub = sinon.stub(lastContextMenuService!, 'showContextMenu'); const reviewButton = getReviewButton(widget)!; reviewButton.click(); @@ -375,11 +385,23 @@ suite('ChatPlanReviewPart', () => { const feedbackSection = getFeedbackSection(widget); assert.notStrictEqual(feedbackSection.style.display, 'none', 'feedback section should be visible'); - // Footer should have Submit Feedback + Reject (no approve, no Provide Feedback). + // Footer should keep approval available when no feedback has been entered. const buttons = getFooterButtons(widget); assert.ok(buttons.some(b => b.textContent?.includes('Submit Feedback')), 'should have Submit Feedback button'); assert.ok(buttons.some(b => b.textContent?.includes('Reject')), 'should still have Reject button'); - assert.ok(!buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should be hidden'); + const dropdown = widget.domNode.querySelector('.chat-plan-review-footer .monaco-button-dropdown'); + assert.ok(dropdown, 'approval dropdown should remain visible'); + assert.ok(!dropdown.classList.contains('disabled'), 'approval dropdown should remain enabled without feedback'); + + (dropdown.querySelector('.monaco-dropdown-button') as HTMLElement).click(); + assert.strictEqual(showContextMenuStub.calledOnce, true, 'approval dropdown should open its menu'); + const menuDelegate = showContextMenuStub.firstCall.args[0]; + assert.ok(menuDelegate.getActions); + const autopilotAction = menuDelegate.getActions().find(action => action.id === 'Implement with Autopilot'); + assert.ok(autopilotAction, 'non-default approval action should be available'); + await autopilotAction.run(); + await tick(); + assert.deepStrictEqual(lastSubmitResult, { action: 'Implement with Autopilot', actionId: 'autopilot', rejected: false }); }); test('reject button remains visible in feedback mode', async () => { @@ -403,11 +425,11 @@ suite('ChatPlanReviewPart', () => { const feedbackSection = getFeedbackSection(widget); assert.notStrictEqual(feedbackSection.style.display, 'none', 'feedback section should be visible'); - // Footer should have Submit Feedback + Reject (no approve, no Provide Feedback). + // Footer should have Submit Feedback + Approve + Reject. const buttons = getFooterButtons(widget); assert.ok(buttons.some(b => b.textContent?.includes('Submit Feedback')), 'should have Submit Feedback button'); assert.ok(buttons.some(b => b.textContent?.includes('Reject')), 'should still have Reject button'); - assert.ok(!buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should be hidden'); + assert.ok(buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should remain visible'); }); test('reject button remains visible in feedback mode', async () => { @@ -431,11 +453,11 @@ suite('ChatPlanReviewPart', () => { const feedbackSection = getFeedbackSection(widget); assert.notStrictEqual(feedbackSection.style.display, 'none', 'feedback section should be visible'); - // Footer should have Submit Feedback + Reject (no approve, no Provide Feedback). + // Footer should have Submit Feedback + Approve + Reject. const buttons = getFooterButtons(widget); assert.ok(buttons.some(b => b.textContent?.includes('Submit Feedback')), 'should have Submit Feedback button'); assert.ok(buttons.some(b => b.textContent?.includes('Reject')), 'should still have Reject button'); - assert.ok(!buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should be hidden'); + assert.ok(buttons.some(b => b.textContent?.includes('Autopilot')), 'approve button should remain visible'); }); test('reject button remains visible in feedback mode', async () => { @@ -515,6 +537,66 @@ suite('ChatPlanReviewPart', () => { assert.ok(submitButton); assert.ok(submitButton!.classList.contains('disabled'), 'Submit Feedback should be disabled when nothing to submit'); }); + + test('approval is disabled while feedback is pending and restored when feedback is cleared', async () => { + createWidget(createMockReviewWithPlan()); + + getReviewButton(widget)!.click(); + await tick(); + + const textarea = widget.domNode.querySelector('.chat-plan-review-feedback-textarea') as HTMLTextAreaElement; + const approveButton = getFooterButtons(widget).find(b => b.textContent?.includes('Autopilot'))!; + const submitButton = getFooterButtons(widget).find(b => b.textContent?.includes('Submit Feedback'))!; + + textarea.value = 'Please change the plan'; + textarea.dispatchEvent(new Event('input')); + + assert.deepStrictEqual({ + approveDisabled: approveButton.classList.contains('disabled'), + submitDisabled: submitButton.classList.contains('disabled'), + }, { + approveDisabled: true, + submitDisabled: false, + }); + + textarea.value = ''; + textarea.dispatchEvent(new Event('input')); + + assert.deepStrictEqual({ + approveDisabled: approveButton.classList.contains('disabled'), + submitDisabled: submitButton.classList.contains('disabled'), + }, { + approveDisabled: false, + submitDisabled: true, + }); + }); + + test('closing feedback mode keeps approval disabled while feedback is pending', async () => { + createWidget(createMockReviewWithPlan()); + + getReviewButton(widget)!.click(); + await tick(); + + const textarea = widget.domNode.querySelector('.chat-plan-review-feedback-textarea') as HTMLTextAreaElement; + textarea.value = 'Please change the plan'; + textarea.dispatchEvent(new Event('input')); + + (widget.domNode.querySelector('.chat-plan-review-feedback-close') as HTMLElement).click(); + await tick(); + + const approveButton = getFooterButtons(widget).find(b => b.textContent?.includes('Autopilot'))!; + approveButton.click(); + await tick(); + assert.deepStrictEqual({ + feedbackHidden: getFeedbackSection(widget).style.display, + approveDisabled: approveButton.classList.contains('disabled'), + submitResult: lastSubmitResult, + }, { + feedbackHidden: 'none', + approveDisabled: true, + submitResult: undefined, + }); + }); }); suite('Inline comments list', () => { @@ -888,7 +970,7 @@ suite('ChatPlanReviewPart', () => { collapseButton.click(); const footerButtons = getFooterButtons(widget); assert.ok(footerButtons.some(b => b.textContent?.includes('Submit Feedback')), 'submit feedback button should remain after expand'); - assert.ok(!footerButtons.some(b => b.textContent?.includes('Autopilot')), 'approve should still be hidden in feedback mode'); + assert.ok(footerButtons.some(b => b.textContent?.includes('Autopilot')), 'approve should remain available in feedback mode'); }); test('a comment added while collapsed is reflected in the inline action', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 5a11d77076abfb..fa887531a5b214 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -30,7 +30,7 @@ import { IRenderedMarkdown, MarkdownRenderOptions } from '../../../../../../../b import { IMarkdownString, isMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; -import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { AccessibilityWorkbenchSettingId } from '../../../../../accessibility/browser/accessibilityConfiguration.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -55,6 +55,12 @@ class TestOpenChatActionViewItem extends ActionViewItem { } } +class TestOpenSubagentChatActionViewItem extends OpenSubagentChatActionViewItem { + get tooltip(): string | undefined { + return this.getTooltip(); + } +} + class TestActionViewItemService implements IActionViewItemService { declare _serviceBrand: undefined; private readonly _onDidChange = new Emitter(); @@ -340,6 +346,7 @@ suite('ChatSubagentContentPart', () => { )); instantiationService.stub(IMenuService, menuService); (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.SubagentsUseRichRendering, true); + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.SubagentsShowCreditUsage, true); // Mock list pool and editor pool mockListPool = {} as CollapsibleListPool; @@ -524,6 +531,79 @@ suite('ChatSubagentContentPart', () => { }); }); + test('should update displayed and accessible credit usage when the setting changes', () => { + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + const setShowCreditUsage = (value: boolean) => { + configService.setUserConfiguration(ChatConfiguration.SubagentsShowCreditUsage, value); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([ChatConfiguration.SubagentsShowCreditUsage]), + change: { keys: [ChatConfiguration.SubagentsShowCreditUsage], overrides: [] }, + affectsConfiguration: key => key === ChatConfiguration.SubagentsShowCreditUsage, + } satisfies IConfigurationChangeEvent); + }; + const action = store.add(new Action('openSubagent', 'Open Subagent')); + const viewItem = store.add(instantiationService.createInstance( + TestOpenSubagentChatActionViewItem, + { + chatResource: 'ahp-chat://subagent/Y29waWxvdGNsaTovc2Vzc2lvbg/tool-call', + parentSessionResource: 'agent-host-copilotcli:/session', + startedAt: 1_000, + duration: 65_000, + credits: 2.5, + }, + action, + {}, + false, + )); + const container = mainWindow.document.createElement('div'); + viewItem.render(container); + const credits = container.querySelector('.chat-subagent-pill-credits'); + const before = { + text: credits?.textContent, + hidden: credits?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + setShowCreditUsage(false); + const hidden = { + text: credits?.textContent, + hidden: credits?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + setShowCreditUsage(true); + const restored = { + text: credits?.textContent, + hidden: credits?.classList.contains('hidden'), + tooltip: viewItem.tooltip, + ariaLabel: container.getAttribute('aria-label'), + }; + + assert.deepStrictEqual({ before, hidden, restored }, { + before: { + text: '2.5 credits', + hidden: false, + tooltip: 'Open Subagent\n2.5 credits', + ariaLabel: 'Open Subagent. Worked for 1m 5s. 2.5 credits', + }, + hidden: { + text: '', + hidden: true, + tooltip: 'Open Subagent', + ariaLabel: 'Open Subagent. Worked for 1m 5s', + }, + restored: { + text: '2.5 credits', + hidden: false, + tooltip: 'Open Subagent\n2.5 credits', + ariaLabel: 'Open Subagent. Worked for 1m 5s. 2.5 credits', + }, + }); + }); + test('should render the specialized subagent type before the title', () => { const action = store.add(new Action('openSubagent', 'Open Subagent')); const viewItem = store.add(instantiationService.createInstance( diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index d4aeffd7a2f7c3..d738a35d560d76 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -616,7 +616,7 @@ suite('ChatPetWidget', () => { assert.strictEqual(CHAT_PET_CONFIRMATION_ATTENTION_DURATION, 2_000); }); - test('shows the window pet only in the active VS Code window and reserves only its active host', () => { + test('shows the window pet only in the active VS Code window and reserves space in every visible chat', () => { assert.deepStrictEqual({ visible: [ isChatPetVisible(false, false), diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts new file mode 100644 index 00000000000000..dd12488cda6c28 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPickerActionItem.test.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IAction } from '../../../../../../../base/common/actions.js'; +import { constObservable } from '../../../../../../../base/common/observable.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { ChatInputPickerActionViewItem } from '../../../../browser/widget/input/chatInputPickerActionItem.js'; + +const action: IAction = { + id: 'test.chatInputPicker', + label: 'Agent', + tooltip: '', + class: undefined, + enabled: true, + run: async () => { }, +}; + +class TestChatInputPickerActionViewItem extends ChatInputPickerActionViewItem { + constructor() { + super( + action, + { actions: [] }, + { compact: constObservable(false) }, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { }, + new class extends mock() { }, + ); + } + + setElement(element: HTMLElement): void { + this.element = element; + } +} + +suite('ChatInputPickerActionViewItem', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps the picker in the Tab order when it is not the leading toolbar item', () => { + const item = disposables.add(new TestChatInputPickerActionViewItem()); + const element = document.createElement('a'); + item.setElement(element); + + item.setFocusable(false); + const afterToolbarUpdate = element.tabIndex; + item.focus(); + const afterFocus = element.tabIndex; + item.blur(); + + assert.deepStrictEqual({ + afterToolbarUpdate, + afterFocus, + afterBlur: element.tabIndex, + }, { + afterToolbarUpdate: 0, + afterFocus: 0, + afterBlur: 0, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts index 781939a566e21e..8244c409f3b63b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts @@ -73,7 +73,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -134,7 +134,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -189,7 +189,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -235,7 +235,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -293,7 +293,7 @@ suite('ChatEditorInput', () => { {} as IStorageService, new NullLogService(), new TestContextService(), - { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false) }, + { _serviceBrand: undefined, enabled: constObservable(false), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }, { ambientConnection: undefined } as unknown as IAgentHostConnectionsService, NullTelemetryService, { withProgress: (_options: unknown, task: (progress: unknown) => unknown) => task({ report() { } }) } as unknown as IProgressService, @@ -329,7 +329,7 @@ suite('ChatEditorInput', () => { }]); const storageService = store.add(new TestStorageService()); const workspaceContextService = new TestContextService(); - const agentHostEnablementService = { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false) } satisfies IAgentHostEnablementService; + const agentHostEnablementService = { _serviceBrand: undefined, enabled: constObservable(true), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) } satisfies IAgentHostEnablementService; instantiationService.stub(IChatService, {}); instantiationService.stub(IDialogService, {}); @@ -384,7 +384,7 @@ suite('ChatEditorInput', () => { instantiationService.set(IStorageService, store.add(new TestStorageService())); instantiationService.set(ILogService, new NullLogService()); instantiationService.set(IWorkspaceContextService, new TestContextService()); - instantiationService.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); + instantiationService.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }); return store.add(instantiationService.createInstance(ChatEditorInput, resource, {})); } diff --git a/src/vs/workbench/contrib/chat/test/common/constants.test.ts b/src/vs/workbench/contrib/chat/test/common/constants.test.ts index 785c7854ff559c..d091d94ce31f9c 100644 --- a/src/vs/workbench/contrib/chat/test/common/constants.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/constants.test.ts @@ -59,7 +59,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IChatSessionsService, chatSessionsService); accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); - accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); + accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }); return { sessionType: getDefaultNewChatSessionTypeAndReason(accessor, options).sessionType }; } @@ -76,7 +76,7 @@ suite('ChatConfiguration defaults', () => { accessor.set(IChatSessionsService, chatSessionsService); accessor.set(IStorageService, storageService); accessor.set(IWorkspaceContextService, new TestContextService(workspace)); - accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false) }); + accessor.set(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: constObservable(agentHostEnabled), managedSandboxEnforced: constObservable(false), managedSandboxAllowsBypass: constObservable(false) }); return getDefaultNewChatSessionTypeAndReason(accessor, options); } diff --git a/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts b/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts index dbec4ea9161f08..044b3552f12c92 100644 --- a/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts +++ b/src/vs/workbench/contrib/github/browser/githubLinkPresentation.contribution.ts @@ -15,6 +15,7 @@ import { FragmentState, PullRequestCheck, PullRequestCore, PullRequestRef, PullR import { GitHubRequestError } from '../../../../platform/github/common/githubTransport.js'; import { GitHubAccountHandle, GitHubRequestErrorKind } from '../../../../platform/github/common/githubTypes.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, NeverShowAgainScope, Severity } from '../../../../platform/notification/common/notification.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; const githubRepositoryProviderId = 'workbench.github.repositoryLinkPresentation'; @@ -31,6 +32,7 @@ export class GitHubLinkPresentationContribution extends Disposable implements IW static readonly ID = 'workbench.contrib.githubLinkPresentations'; private readonly _registrations = this._register(new MutableDisposable()); + private readonly _authenticationNotification = this._register(new MutableDisposable()); private readonly _provider: GitHubLinkPresentationProvider; constructor( @@ -38,13 +40,46 @@ export class GitHubLinkPresentationContribution extends Disposable implements IW @ILinkPresentationService private readonly _linkPresentationService: ILinkPresentationService, @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, @ILogService logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, ) { super(); - this._provider = this._register(new GitHubLinkPresentationProvider(gitHubService, logService)); - this._register(_defaultAccountService.onDidChangeDefaultAccount(() => this._registerProviders())); + this._provider = this._register(new GitHubLinkPresentationProvider(gitHubService, logService, () => this._showAuthenticationRequiredNotification())); + this._register(_defaultAccountService.onDidChangeDefaultAccount(() => { + this._authenticationNotification.clear(); + this._registerProviders(); + })); this._registerProviders(); } + private _showAuthenticationRequiredNotification(): void { + if (this._authenticationNotification.value) { + return; + } + + const handleDisposables = new DisposableStore(); + const handle = this._notificationService.prompt( + Severity.Info, + localize('github.authenticationRequired', "Sign in to GitHub to load pull request status and other GitHub link details."), + [{ + label: localize('github.authenticationRequired.signIn', "Sign In"), + run: async () => { + await this._defaultAccountService.signIn({ additionalScopes: ['repo'] }); + }, + }], + { + sticky: true, + neverShowAgain: { + id: 'github.linkPresentation.authenticationRequired', + isSecondary: true, + scope: NeverShowAgainScope.PROFILE, + }, + }, + ); + handleDisposables.add(handle.onDidClose(() => this._authenticationNotification.clear())); + handleDisposables.add({ dispose: () => handle.close() }); + this._authenticationNotification.value = handleDisposables; + } + private _registerProviders(): void { this._registrations.clear(); const authority = URI.parse(this._defaultAccountService.resolveGitHubUrl('')).authority; @@ -82,6 +117,7 @@ class GitHubLinkPresentationProvider extends Disposable implements ILinkPresenta constructor( private readonly _gitHubService: IGitHubService, private readonly _logService: ILogService, + private readonly _onAuthenticationRequired: () => void, ) { super(); this._hydrator = this._register(new GitHubLinkPresentationHydrator(_gitHubService, _logService)); @@ -92,7 +128,7 @@ class GitHubLinkPresentationProvider extends Disposable implements ILinkPresenta if (!target) { throw new Error(`Unsupported GitHub link presentation resource: ${resource.toString(true)}`); } - return new GitHubLinkPresentationWatcher(target, this._gitHubService, this._hydrator, this._logService); + return new GitHubLinkPresentationWatcher(target, this._gitHubService, this._hydrator, this._logService, this._onAuthenticationRequired); } } @@ -189,6 +225,7 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat private readonly _gitHubService: IGitHubService, private readonly _hydrator: GitHubLinkPresentationHydrator, private readonly _logService: ILogService, + private readonly _onAuthenticationRequired: () => void, ) { super(); this._register(_gitHubService.credentials.onDidInvalidate(() => this._initialize())); @@ -223,19 +260,19 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat owner: target.owner, repo: target.repo, }, { priority: 'visible' })); - store.add(autorun(reader => this._presentation.set( - repositoryPresentation(target, subscription.resource.state.read(reader)), - undefined, - ))); + store.add(autorun(reader => { + const state = subscription.resource.state.read(reader); + this._setPresentation(repositoryPresentation(target, state), state.status === 'error' ? state.error : undefined); + })); break; } case 'issue': { const ref: GitHubIssueRef = { ...account, owner: target.owner, repo: target.repo, number: target.number }; const subscription = store.add(this._gitHubService.query.subscribeIssue(ref, { priority: 'visible' })); - store.add(autorun(reader => this._presentation.set( - issuePresentation(target, subscription.resource.state.read(reader)), - undefined, - ))); + store.add(autorun(reader => { + const state = subscription.resource.state.read(reader); + this._setPresentation(issuePresentation(target, state), state.status === 'error' ? state.error : undefined); + })); break; } case 'pullRequest': { @@ -245,10 +282,10 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat core: true, checks: { includeOptional: true }, })); - store.add(autorun(reader => this._presentation.set( - pullRequestPresentation(target, subscription.resource.snapshot.read(reader)), - undefined, - ))); + store.add(autorun(reader => { + const snapshot = subscription.resource.snapshot.read(reader); + this._setPresentation(pullRequestPresentation(target, snapshot), snapshot.core.status === 'error' ? snapshot.core.error : undefined); + })); break; } } @@ -257,8 +294,22 @@ class GitHubLinkPresentationWatcher extends Disposable implements ILinkPresentat return; } this._logService.trace(`[GitHubLinkPresentation] Failed to resolve ${formatTarget(this._target)}`, error); - this._presentation.set(failurePresentation(this._target.kind, error instanceof GitHubRequestError ? error.kind : undefined), undefined); + const errorKind = error instanceof GitHubRequestError ? error.kind : undefined; + this._setPresentation(failurePresentation(this._target.kind, errorKind), { + kind: errorKind ?? 'unknown', + message: error instanceof Error ? error.message : String(error), + }); + } + } + + private _setPresentation(presentation: ILinkPresentation | undefined, error: { readonly kind: GitHubRequestErrorKind; readonly message: string } | undefined): void { + if (error) { + this._logService.warn(`[GitHubLinkPresentation] ${formatTarget(this._target)} failed with '${error.kind}': ${error.message}`); + } + if (error?.kind === 'authentication') { + this._onAuthenticationRequired(); } + this._presentation.set(presentation, undefined); } } diff --git a/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts b/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts index b28f6e4631d4f6..5e42587cd183b0 100644 --- a/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts +++ b/src/vs/workbench/contrib/github/test/browser/githubLinkPresentation.test.ts @@ -15,8 +15,10 @@ import { ILinkPresentationProvider, ILinkPresentationProviderRegistration, ILink import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IGitHubService } from '../../../../../platform/github/common/githubService.js'; import { GitHubIssue, GitHubRepository } from '../../../../../platform/github/common/githubQueryService.js'; -import { FragmentState, PullRequestSnapshot } from '../../../../../platform/github/common/githubPullRequestService.js'; +import { FragmentState, PullRequestCore, PullRequestSnapshot } from '../../../../../platform/github/common/githubPullRequestService.js'; +import { GitHubRequestError } from '../../../../../platform/github/common/githubTransport.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService, NeverShowAgainScope, NoOpNotification, Severity } from '../../../../../platform/notification/common/notification.js'; import { GitHubLinkPresentationContribution } from '../../browser/githubLinkPresentation.contribution.js'; suite('GitHub link presentations', () => { @@ -35,6 +37,7 @@ suite('GitHub link presentations', () => { } }(), new NullLogService(), + new TestNotificationService(), )); const resources = [ @@ -92,6 +95,7 @@ suite('GitHub link presentations', () => { } }(), new NullLogService(), + new TestNotificationService(), )); const before = linkPresentationService.hasProvider(URI.parse('https://github.com/microsoft/vscode/issues/1')); @@ -108,8 +112,105 @@ suite('GitHub link presentations', () => { newAuthority: true, }); }); + + test('prompts once to sign in when authentication is required', async () => { + const linkPresentationService = new TestLinkPresentationService(); + const notificationService = new TestNotificationService(); + let signInOptions: Parameters[0]; + store.add(new GitHubLinkPresentationContribution( + createGitHubService( + () => { }, + async () => { throw new GitHubRequestError('GitHub authentication is required', 'authentication'); }, + ), + linkPresentationService, + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override resolveGitHubUrl(path: string): string { + return `https://github.com/${path}`; + } + override async signIn(options?: Parameters[0]): Promise { + signInOptions = options; + return null; + } + }(), + new NullLogService(), + notificationService, + )); + + store.add(linkPresentationService.createWatcher(URI.parse('https://github.com/microsoft/vscode/issues/7'))); + store.add(linkPresentationService.createWatcher(URI.parse('https://github.com/microsoft/vscode/pull/8'))); + await Promise.resolve(); + await notificationService.prompts[0].choices[0].run(); + + assert.deepStrictEqual({ + prompts: notificationService.prompts.map(prompt => ({ + severity: prompt.severity, + message: prompt.message, + labels: prompt.choices.map(choice => choice.label), + sticky: prompt.options?.sticky, + neverShowAgain: prompt.options?.neverShowAgain, + })), + signInOptions, + }, { + prompts: [{ + severity: Severity.Info, + message: 'Sign in to GitHub to load pull request status and other GitHub link details.', + labels: ['Sign In'], + sticky: true, + neverShowAgain: { + id: 'github.linkPresentation.authenticationRequired', + isSecondary: true, + scope: NeverShowAgainScope.PROFILE, + }, + }], + signInOptions: { additionalScopes: ['repo'] }, + }); + }); + + test('prompts to sign in when a pull request subscription reports an authentication error', async () => { + const linkPresentationService = new TestLinkPresentationService(); + const notificationService = new TestNotificationService(); + store.add(new GitHubLinkPresentationContribution( + createGitHubService( + () => { }, + undefined, + { status: 'error', complete: false, error: { message: 'GitHub authentication is required', kind: 'authentication' } }, + ), + linkPresentationService, + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override resolveGitHubUrl(path: string): string { + return `https://github.com/${path}`; + } + }(), + new NullLogService(), + notificationService, + )); + + store.add(linkPresentationService.createWatcher(URI.parse('https://github.com/microsoft/vscode/pull/8'))); + await Promise.resolve(); + + assert.deepStrictEqual(notificationService.prompts.map(prompt => prompt.message), [ + 'Sign in to GitHub to load pull request status and other GitHub link details.', + ]); + }); }); +class TestNotificationService extends mock() { + + readonly prompts: { + readonly severity: Severity; + readonly message: string; + readonly choices: Parameters[2]; + readonly options: Parameters[3]; + }[] = []; + + override prompt(...[severity, message, choices, options]: Parameters): NoOpNotification { + this.prompts.push({ severity, message, choices, options }); + return new NoOpNotification(); + } +} + class TestLinkPresentationService extends mock() { private readonly _providers: { readonly registration: ILinkPresentationProviderRegistration; readonly provider: ILinkPresentationProvider }[] = []; @@ -141,14 +242,23 @@ class TestLinkPresentationService extends mock() { } } -function createGitHubService(onHydrate: (resources: Parameters[0]) => void): IGitHubService { +function createGitHubService( + onHydrate: (resources: Parameters[0]) => void, + getCredential: IGitHubService['credentials']['getCredential'] = async () => ({ + account: { host: 'api.github.com', accountId: '1' }, + token: 'token', + generation: 1, + signal: new AbortController().signal, + }), + pullRequestCore?: FragmentState, +): IGitHubService { const ready = (value: T): FragmentState => ({ value, status: 'ready', complete: true }); const missing: FragmentState = { status: 'missing', complete: false }; const pullRequestSnapshot: PullRequestSnapshot = { ref: { host: 'api.github.com', accountId: '1', owner: 'microsoft', repo: 'vscode', number: 8 }, generation: 1, headGeneration: 1, - core: ready({ + core: pullRequestCore ?? ready({ repositoryNameWithOwner: 'microsoft/vscode', number: 8, title: 'Pull request title', @@ -189,12 +299,7 @@ function createGitHubService(onHydrate: (resources: Parameters() { override readonly credentials = { onDidInvalidate: Event.None, - getCredential: async () => ({ - account: { host: 'api.github.com', accountId: '1' }, - token: 'token', - generation: 1, - signal: new AbortController().signal, - }), + getCredential, resolveCredential: async () => { throw new Error('Not implemented'); }, handleRequestError: () => { }, }; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 9f02970edf3015..e014242645634b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -558,7 +558,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { }; workspaceContextService.setWorkspaceFolders([URI.file('/workspace-one')]); - // Setup default configuration + // Use an explicitly network-restricted sandbox for the restriction tests. configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowNetwork, false); configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []); 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 index 1b12a94d03ac8f..72443ea5b1cd50 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/sandboxSettingsReader.test.ts @@ -11,6 +11,7 @@ import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkF import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { AgentHostSandboxKey } from '../../../../../../platform/agentHost/common/sandboxConfigSchema.js'; import { readAgentHostSandboxValues, readSandboxSetting } from '../../common/sandboxSettingsReader.js'; +import { terminalChatAgentToolsConfiguration } from '../../common/terminalChatAgentToolsConfiguration.js'; suite('sandboxSettingsReader', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -25,6 +26,19 @@ suite('sandboxSettingsReader', () => { ); }); + test('forwards the network default and explicit network restrictions to the agent host', async () => { + const settingId = AgentSandboxSettingId.AgentSandboxAllowNetwork; + const cfg = new TestConfigurationService({ [settingId]: terminalChatAgentToolsConfiguration[settingId].default }); + const logService = new NullLogService(); + const values = [readAgentHostSandboxValues(cfg, logService)]; + await cfg.setUserConfiguration(settingId, false); + values.push(readAgentHostSandboxValues(cfg, logService)); + assert.deepStrictEqual(values, [ + { [AgentHostSandboxKey.AllowNetwork]: true }, + { [AgentHostSandboxKey.AllowNetwork]: false }, + ]); + }); + test('returns undefined when nothing is configured', () => { const cfg = new TestConfigurationService(); assert.strictEqual( diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts index 3bd6ca111cdef8..28ec3958766d09 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/common/terminalChatAgentToolsConfiguration.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ConfigurationModelParser } from '../../../../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration } from '../../../../../../platform/configuration/common/configurations.js'; +import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; @@ -13,7 +15,7 @@ import { WorkspaceConfigurationModelParser } from '../../../../../services/confi import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; suite('Terminal chat agent tools configuration', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const configurationRegistry = Registry.as(Extensions.Configuration); const configurationNode: IConfigurationNode = { id: 'terminalChatAgentToolsConfigurationTest', @@ -41,6 +43,19 @@ suite('Terminal chat agent tools configuration', () => { suiteTeardown(() => configurationRegistry.deregisterConfigurations([configurationNode])); + test('allows sandbox network access by default and preserves explicit overrides', async () => { + const logService = new NullLogService(); + const defaults = await store.add(new DefaultConfiguration(logService)).initialize(); + const settingId = AgentSandboxSettingId.AgentSandboxAllowNetwork; + const values = [defaults.getValue(settingId)]; + for (const value of [false, true]) { + const parser = new ConfigurationModelParser('sandboxNetworkSettings', logService); + parser.parse(JSON.stringify({ [settingId]: value })); + values.push(defaults.merge(parser.configurationModel).getValue(settingId)); + } + assert.deepStrictEqual(values, [true, false, true]); + }); + test('registers terminal safety settings as restricted', () => { assert.deepStrictEqual( restrictedSettingIds.map(id => terminalChatAgentToolsConfiguration[id].restricted), diff --git a/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts b/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts index ca11193240f024..be8a6ea7b9f8d8 100644 --- a/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts +++ b/src/vs/workbench/services/agentHost/test/electron-browser/agentHostService.test.ts @@ -69,6 +69,7 @@ class TestAgentHostEnablementService extends Disposable implements IAgentHostEna private readonly _enabled; readonly enabled; readonly managedSandboxEnforced = constObservable(false); + readonly managedSandboxAllowsBypass = constObservable(false); constructor(enabled: boolean) { super(); diff --git a/src/vs/workbench/services/assignment/common/assignmentService.ts b/src/vs/workbench/services/assignment/common/assignmentService.ts index 0cd95c3b6938a7..cc9ac957964dfa 100644 --- a/src/vs/workbench/services/assignment/common/assignmentService.ts +++ b/src/vs/workbench/services/assignment/common/assignmentService.ts @@ -8,6 +8,7 @@ import { createDecorator, IInstantiationService } from '../../../../platform/ins import type { IKeyValueStorage, IExperimentationTelemetry, IExperimentationFilterProvider, ExperimentationService as TASClient } from 'tas-client'; import { Memento } from '../../../common/memento.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { TelemetryTrustedValue } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryData } from '../../../../base/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -63,6 +64,20 @@ export function resolveScopedTreatment(read return scoped !== undefined ? scoped : read(name); } +/** + * Builds the telemetry payload for a tas-client feature query. The queried-feature name is marked + * trusted so the telemetry cleaner does not redact a `/vscode/`-scoped name as a `user-file-path`. + * + * Exported for testing. + */ +export function toExperimentTelemetryData(props: Map): ITelemetryData { + const data: ITelemetryData = {}; + for (const [key, value] of props.entries()) { + data[key] = key === 'ABExp.queriedFeature' ? new TelemetryTrustedValue(value) : value; + } + return data; +} + export interface IWorkbenchAssignmentService extends IAssignmentService { getCurrentExperiments(): Promise; addTelemetryAssignmentFilter(filter: IAssignmentFilter): void; @@ -135,10 +150,7 @@ class WorkbenchAssignmentServiceTelemetry extends Disposable implements IExperim } postEvent(eventName: string, props: Map): void { - const data: ITelemetryData = {}; - for (const [key, value] of props.entries()) { - data[key] = value; - } + const data = toExperimentTelemetryData(props); /* __GDPR__ "query-expfeature" : { diff --git a/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts b/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts index 18d92c7817d9dd..1e8b48b8eb34a7 100644 --- a/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts +++ b/src/vs/workbench/services/assignment/test/common/assignmentService.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { resolveScopedTreatment } from '../../common/assignmentService.js'; +import { cleanData } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { resolveScopedTreatment, toExperimentTelemetryData } from '../../common/assignmentService.js'; suite('resolveScopedTreatment', () => { @@ -43,3 +44,18 @@ suite('resolveScopedTreatment', () => { assert.strictEqual(resolveScopedTreatment(read, BARE), false); }); }); + +suite('toExperimentTelemetryData', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks the queried feature name trusted so a /vscode/-scoped key survives telemetry cleaning', () => { + const scoped = '/vscode/config.chat.agentHost.copilot.multiTurnContextRouting.enabled'; + const data = toExperimentTelemetryData(new Map([['ABExp.queriedFeature', scoped]])); + + // The trusted feature name survives cleaning, whereas the same value left unmarked would be + // redacted by the file-path heuristic - guarding against a regression back to that behavior. + assert.strictEqual(cleanData(data, [])['ABExp.queriedFeature'], scoped); + assert.strictEqual(cleanData({ 'ABExp.queriedFeature': scoped }, [])['ABExp.queriedFeature'], ''); + }); +}); diff --git a/src/vs/workbench/services/github/browser/githubService.ts b/src/vs/workbench/services/github/browser/githubService.ts index 67bef5bbb7131c..bb73495acf536b 100644 --- a/src/vs/workbench/services/github/browser/githubService.ts +++ b/src/vs/workbench/services/github/browser/githubService.ts @@ -35,13 +35,14 @@ class WorkbenchGitHubEndpointProvider implements IGitHubEndpointProvider { } } -class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { +export class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { readonly onDidChangeToken: Event; constructor( private readonly _authenticationService: IAuthenticationService, private readonly _defaultAccountService: IDefaultAccountService, + private readonly _logService: ILogService, ) { this.onDidChangeToken = Event.any( Event.map(Event.filter( @@ -60,19 +61,33 @@ class WorkbenchGitHubTokenProvider implements IGitHubTokenProvider { ? sessions.find(session => session.id === defaultAccount.sessionId) : undefined; if (defaultAccount && !defaultSession) { + this._logService.warn(`[WorkbenchGitHubTokenProvider] Default account session was not found for provider '${provider.id}' among ${sessions.length} session(s)`); return undefined; } - if (defaultSession?.scopes.includes('repo')) { - return defaultSession.accessToken; + const repositorySession = sessions.find(session => + session.scopes.includes('repo') + && (!defaultSession || session.account.id === defaultSession.account.id) + ); + if (repositorySession) { + this._logService.trace(`[WorkbenchGitHubTokenProvider] Reusing a repository-capable session for provider '${provider.id}' with scopes [${repositorySession.scopes.join(', ')}]`); + return repositorySession.accessToken; } const repositorySessions = await this._authenticationService.getSessions(provider.id, ['repo'], { createIfNone: true, ...(defaultSession ? { account: defaultSession.account } : {}), }, true); - return repositorySessions.find(session => !defaultSession || session.account.id === defaultSession.account.id)?.accessToken; + const resolvedSession = repositorySessions.find(session => !defaultSession || session.account.id === defaultSession.account.id); + if (!resolvedSession) { + this._logService.warn(`[WorkbenchGitHubTokenProvider] No repository-capable session resolved for provider '${provider.id}'; initial session scopes: ${formatSessionScopes(sessions)}; repository query scopes: ${formatSessionScopes(repositorySessions)}`); + } + return resolvedSession?.accessToken; } } +function formatSessionScopes(sessions: readonly { readonly scopes: readonly string[] }[]): string { + return sessions.length ? sessions.map(session => `[${session.scopes.join(', ')}]`).join(', ') : 'none'; +} + export class WorkbenchGitHubService extends GitHubService { constructor( @@ -82,7 +97,7 @@ export class WorkbenchGitHubService extends GitHubService { ) { super({ endpoint: new WorkbenchGitHubEndpointProvider(defaultAccountService), - tokenProvider: new WorkbenchGitHubTokenProvider(authenticationService, defaultAccountService), + tokenProvider: new WorkbenchGitHubTokenProvider(authenticationService, defaultAccountService, logService), }, logService); } } diff --git a/src/vs/workbench/services/github/test/browser/githubService.test.ts b/src/vs/workbench/services/github/test/browser/githubService.test.ts new file mode 100644 index 00000000000000..eef2eeb8def6bb --- /dev/null +++ b/src/vs/workbench/services/github/test/browser/githubService.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AuthenticationSession, IAuthenticationService } from '../../../authentication/common/authentication.js'; +import { WorkbenchGitHubTokenProvider } from '../../browser/githubService.js'; + +suite('Workbench GitHub service', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reuses a repo-capable session with additional scopes', async () => { + const sessions: AuthenticationSession[] = [{ + id: 'session', + accessToken: 'token', + account: { id: 'account', label: 'Account' }, + scopes: ['repo', 'user:email'], + }]; + const requestedScopes: (readonly string[] | undefined)[] = []; + const tokenProvider = new WorkbenchGitHubTokenProvider( + new class extends mock() { + override readonly onDidChangeSessions = Event.None; + override async getSessions(_id: string, scopes?: readonly string[]): Promise { + requestedScopes.push(scopes); + return sessions; + } + }(), + new class extends mock() { + override readonly onDidChangeDefaultAccount = Event.None; + override readonly currentDefaultAccount = null; + override getDefaultAccountAuthenticationProvider() { + return { id: 'github', name: 'GitHub', enterprise: false }; + } + override async getDefaultAccount() { + return null; + } + }(), + new NullLogService(), + ); + + assert.deepStrictEqual({ + token: await tokenProvider.getToken(), + requestedScopes, + }, { + token: 'token', + requestedScopes: [[]], + }); + }); +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts index b6e02d641eb3a7..a47526872711a0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsList.fixture.ts @@ -7,8 +7,10 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import * as DOM from '../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { OS } from '../../../../../base/common/platform.js'; import { ExtUri } from '../../../../../base/common/resources.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -17,8 +19,11 @@ import { IActionViewItemFactory, IActionViewItemService } from '../../../../../p import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { createUSLayoutResolvedKeybinding } from '../../../../../platform/keybinding/test/common/keybindingsTestUtils.js'; +import { MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -52,7 +57,7 @@ import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } fr // eslint-disable-next-line local/code-import-patterns import { renderSessionsHeader } from '../../../../../sessions/contrib/sessions/browser/views/sessionsView.js'; // eslint-disable-next-line local/code-import-patterns -import { NewSessionActionViewItemContribution } from '../../../../../sessions/contrib/sessions/browser/sessionsActions.js'; +import { NEW_SESSION_BUTTON_STYLE_SETTING, NEW_SESSION_BUTTON_STYLE_TREATMENT, NewSessionActionViewItemContribution, type NewSessionButtonStyle } from '../../../../../sessions/contrib/sessions/browser/sessionsActions.js'; // eslint-disable-next-line local/code-import-patterns import { NEW_SESSION_ACTION_ID } from '../../../../../sessions/contrib/chat/common/constants.js'; // eslint-disable-next-line local/code-import-patterns @@ -219,17 +224,27 @@ interface IRenderOptions { readonly showAutomations?: boolean; readonly automationRunStatus?: IAutomationRun['status']; readonly automationBadgeStyle?: AutomationsNewBadgeStyle; + readonly newSessionButtonStyle?: NewSessionButtonStyle; + readonly newSessionButtonTreatment?: NewSessionButtonStyle; readonly showFocusedToolbar?: boolean; } async function renderSessionsList(ctx: ComponentFixtureContext, options: IRenderOptions): Promise { const { container, disposableStore } = ctx; + const expectedNewSessionButtonStyle = options.newSessionButtonStyle ?? options.newSessionButtonTreatment; + const showHeader = options.showAutomations || expectedNewSessionButtonStyle !== undefined; const approvals = new Map(); const sessions = options.sessions.map(spec => createSession(spec, approvals)); const approvalModel = createApprovalModel(approvals); const groups = options.groups ?? []; const automationRuns = observableValue(disposableStore, []); const actionViewItemService = disposableStore.add(new FixtureActionViewItemService()); + const newSessionKeybinding = expectedNewSessionButtonStyle + ? createUSLayoutResolvedKeybinding(KeyMod.CtrlCmd | KeyCode.KeyN, OS) + : undefined; + if (expectedNewSessionButtonStyle && !newSessionKeybinding) { + throw new Error('Expected the New Session keybinding to resolve.'); + } const membership = new Map(); for (const spec of options.sessions) { if (spec.group) { @@ -262,6 +277,17 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); } reg.define(IListService, ListService); + if (newSessionKeybinding) { + reg.defineInstance(IKeybindingService, new class extends MockKeybindingService { + override lookupKeybinding(commandId: string) { + return commandId === NEW_SESSION_ACTION_ID ? newSessionKeybinding : undefined; + } + + override lookupKeybindings(commandId: string) { + return commandId === NEW_SESSION_ACTION_ID ? [newSessionKeybinding] : []; + } + }()); + } reg.define(IMarkdownRendererService, MarkdownRendererService); reg.defineInstance(IAgentHostConnectionsService, new class extends mock() { }()); reg.defineInstance(IChatService, new class extends mock() { @@ -334,7 +360,9 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override readonly onDidRefetchAssignments = Event.None; - override async getTreatment(): Promise { return undefined; } + override async getTreatment(name: string): Promise { + return name === NEW_SESSION_BUTTON_STYLE_TREATMENT ? options.newSessionButtonTreatment as T | undefined : undefined; + } }()); reg.defineInstance(IUriIdentityService, new class extends mock() { override readonly extUri = new ExtUri(() => true); @@ -344,7 +372,7 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }()); }, }); - if (options.showAutomations) { + if (showHeader) { const contextKeyService = instantiationService.get(IContextKeyService); const newSessionAction = new MenuItemAction( { id: NEW_SESSION_ACTION_ID, title: 'New Session' }, @@ -394,21 +422,21 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender } let listParent = container; - if (options.showAutomations) { + if (showHeader) { container.classList.add('agent-sessions-viewpane', 'agent-sessions-section'); const content = DOM.append(container, DOM.$('.agent-sessions-content')); disposableStore.add(instantiationService.createInstance(NewSessionActionViewItemContribution)); renderSessionsHeader(content, false, instantiationService, instantiationService.get(IContextKeyService), disposableStore).toolbar?.refresh(); listParent = content; } - const listHost = DOM.append(listParent, DOM.$(options.showAutomations ? '.agent-sessions-control-container' : 'div')); + const listHost = DOM.append(listParent, DOM.$(showHeader ? '.agent-sessions-control-container' : 'div')); const list = disposableStore.add(instantiationService.createInstance(SessionsList, listHost, { grouping: () => options.grouping ?? SessionsGrouping.Workspace, sorting: () => SessionsSorting.Created, onSessionOpen: () => { }, approvalModel, })); - list.layout(options.phone ? 260 : options.showAutomations ? 180 : 220, width); + list.layout(options.phone ? 260 : showHeader ? 180 : 220, width); if (options.automationRunStatus) { automationRuns.set([{ @@ -421,13 +449,29 @@ async function renderSessionsList(ctx: ComponentFixtureContext, options: IRender }], undefined); } await Promise.resolve(); - if (options.showAutomations && !container.querySelector('.agent-sessions-compact-new-button')) { + if (options.newSessionButtonStyle) { + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configurationService.setUserConfiguration(NEW_SESSION_BUTTON_STYLE_SETTING, options.newSessionButtonStyle); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([NEW_SESSION_BUTTON_STYLE_SETTING]), + change: { keys: [NEW_SESSION_BUTTON_STYLE_SETTING], overrides: [] }, + affectsConfiguration: configuration => configuration === NEW_SESSION_BUTTON_STYLE_SETTING, + }); + } + if (showHeader && !container.querySelector('.agent-sessions-compact-new-button')) { const menu = instantiationService.get(IMenuService).createMenu(Menus.SidebarSessionsHeader, instantiationService.get(IContextKeyService)); const actionCount = menu.getActions().flatMap(([, actions]) => actions).length; menu.dispose(); const hasProvider = !!instantiationService.get(IActionViewItemService).lookUp(Menus.SidebarSessionsHeader, NEW_SESSION_ACTION_ID); throw new Error(`Expected the production New Session action; found ${actionCount} menu action(s), provider=${hasProvider}.`); } + if (expectedNewSessionButtonStyle === 'lightweight' && !container.querySelector('.agent-sessions-compact-new-button.lightweight:not(.lightweight-keybinding-background)')) { + throw new Error('Expected the rendered New Session action to react to the lightweight style setting.'); + } + if (expectedNewSessionButtonStyle === 'lightweightWithKeybindingBackground' && !container.querySelector('.agent-sessions-compact-new-button.lightweight.lightweight-keybinding-background')) { + throw new Error('Expected the rendered New Session action to react to the lightweight keybinding-background style setting.'); + } if (options.showFocusedToolbar) { return Promise.resolve().then(() => { @@ -529,6 +573,24 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { showAutomations: true, }), }), + SessionsList_LightweightNewButton: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Sessions header has an outlined New button whose keyboard shortcut is plain inline text without a nested keycap or chip background. The shortcut uses a quieter type role than New and compact platform-native chord notation.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + newSessionButtonStyle: 'lightweight', + }), + }), + SessionsList_LightweightNewButtonWithKeybindingBackground: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: ['The Sessions header has an outlined New button whose keyboard shortcut uses a quieter type role than New and sits on a subtle grouped keybinding background.'], + render: ctx => renderSessionsList(ctx, { + sessions: [], + newSessionButtonTreatment: 'lightweightWithKeybindingBackground', + }), + }), SessionsList_AutomationsNewBadge_Accent: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: true }, additionalThemes: ['darkHighContrast'], diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 356ed316dc3026..8c1a3a07c6605a 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -297,6 +297,24 @@ #### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/0154b8cd8302a62959c0a067e02aadc24d27a54d85891dd19fb9e9b4d99362f2) +#### sessions/sessionsList/SessionsList_LightweightNewButton/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8f12a4790516fe09f20ccff0012b2422bac95e48c7f3360af36c254b31b57dc) + +#### sessions/sessionsList/SessionsList_LightweightNewButton/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/50c9d63b886aa15e5aab690fca127c675b135ac76c12a36afa65cc40200e985b) + +#### sessions/sessionsList/SessionsList_LightweightNewButton/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/27750194694cd6b4941e11c7752732c04f934269141b93ad9bbd6131217ace45) + +#### sessions/sessionsList/SessionsList_LightweightNewButtonWithKeybindingBackground/Dark +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9d8a57cfaff4101c600dd6bb8464d48973ecd0751b4470664ec7fb2d6553b008) + +#### sessions/sessionsList/SessionsList_LightweightNewButtonWithKeybindingBackground/DarkHighContrast +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4ada330a003107db5221c84f4662aff80f1f4b541877e80420c0cf22918bf5b2) + +#### sessions/sessionsList/SessionsList_LightweightNewButtonWithKeybindingBackground/Light +![screenshot](https://hediet-screenshots.azurewebsites.net/images/54f5cf2d72ea720ff9c6f07388c686f91f7541abff62ef516b321588260da824) + #### sessions/sessionsList/SessionsList_NarrowHoverToolbar/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8af0c707c9c8e321ac7c8fd792b3c242a0d394cdaf68c3fe1c61804095395030) diff --git a/test/smoke/src/areas/chat/chatSandbox.test.ts b/test/smoke/src/areas/chat/chatSandbox.test.ts index ba89fd95d8885d..054b345e56f0cc 100644 --- a/test/smoke/src/areas/chat/chatSandbox.test.ts +++ b/test/smoke/src/areas/chat/chatSandbox.test.ts @@ -168,8 +168,7 @@ export function setup(logger: Logger): void { // focusable for the additional settings written by nested suites. ['editor.wordWrap', '"on"'], ['chat.agent.sandbox.enabled', '"on"'], - // Leave allowNetwork at its default (false), and prevent a failed probe - // from being retried with relaxed network access or outside the sandbox. + // Exercise the default network policy, without retrying failed probes outside its restrictions. ['chat.agent.sandbox.retryWithAllowNetworkRequests', 'false'], ['chat.agent.sandbox.allowUnsandboxedCommands', 'false'], ]); @@ -273,59 +272,50 @@ export function setup(logger: Logger): void { } }); - /* - * Input: Ask chat to run an HTTP request to the local mock server with allowNetwork disabled. - * Expected result: The sandbox blocks the request and its output contains a network error such as - * `ECONNREFUSED`, `EPERM`, `EACCES`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETDOWN`, or `EAI_AGAIN`. - */ - it('blocks terminal network access by default', async function () { + it('allows terminal network access by default', async function () { const app = this.app as Application; try { const requestsBefore = mockServer.requestCount(); - await app.workbench.chat.sendMessage(`Run the terminal network sandbox probe [scenario:${NETWORK_SCENARIO_ID}]`); + await app.workbench.chat.sendMessage(`Run the allowed terminal network sandbox probe [scenario:${NETWORK_ALLOWED_SCENARIO_ID}]`); - const responseText = await app.workbench.chat.waitForResponseText(NETWORK_BLOCKED_PATTERN, CHAT_RESPONSE_TIMEOUT); - logger.log(`[Chat Sandbox/network] response: ${responseText}`); - assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the network sandbox scenario'); - assert.match( - responseText, - NETWORK_BLOCKED_PATTERN, - 'expected the sandbox to block the terminal command from reaching the local mock server' - ); + const responseText = await app.workbench.chat.waitForResponseText(networkAllowedReply, CHAT_RESPONSE_TIMEOUT); + logger.log(`[Chat Sandbox/network allowed] response: ${responseText}`); + assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the allowed network sandbox scenario'); + assert.ok(responseText.includes(networkAllowedReply), 'expected the default network policy to permit the sandboxed terminal command to reach the local mock server'); } catch (error) { - logger.log(`[Chat Sandbox/network] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); - await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network`); + logger.log(`[Chat Sandbox/network allowed] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network allowed`); throw error; } }); - /* - * Input: Enable allowNetwork and ask chat to run an HTTP request to the local mock server. - * Expected result: The sandbox permits the request and its output contains `${networkAllowedReply}`. - */ - describe('with terminal network access enabled', function () { + describe('with terminal network access disabled', function () { before(async function () { const app = this.app as Application; await updateUserSettingsWhileChatIsOpen(app, [ - ['chat.agent.sandbox.allowNetwork', 'true'], + ['chat.agent.sandbox.allowNetwork', 'false'], ]); }); - it('allows terminal network access', async function () { + it('blocks terminal network access', async function () { const app = this.app as Application; try { const requestsBefore = mockServer.requestCount(); - await app.workbench.chat.sendMessage(`Run the allowed terminal network sandbox probe [scenario:${NETWORK_ALLOWED_SCENARIO_ID}]`); - - const responseText = await app.workbench.chat.waitForResponseText(networkAllowedReply, CHAT_RESPONSE_TIMEOUT); - logger.log(`[Chat Sandbox/network allowed] response: ${responseText}`); - assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the allowed network sandbox scenario'); - assert.ok(responseText.includes(networkAllowedReply), 'expected allowNetwork to permit the sandboxed terminal command to reach the local mock server'); + await app.workbench.chat.sendMessage(`Run the terminal network sandbox probe [scenario:${NETWORK_SCENARIO_ID}]`); + + const responseText = await app.workbench.chat.waitForResponseText(NETWORK_BLOCKED_PATTERN, CHAT_RESPONSE_TIMEOUT); + logger.log(`[Chat Sandbox/network] response: ${responseText}`); + assert.ok(mockServer.requestCount() > requestsBefore, 'expected the mock LLM server to receive the network sandbox scenario'); + assert.match( + responseText, + NETWORK_BLOCKED_PATTERN, + 'expected the sandbox to block the terminal command from reaching the local mock server' + ); } catch (error) { - logger.log(`[Chat Sandbox/network allowed] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); - await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network allowed`); + logger.log(`[Chat Sandbox/network] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + await dumpFailureDiagnostics(app, logger, `Chat Sandbox (${process.platform}) network`); throw error; } });