From fef193b6cf82d2a9ca81930d320d952827a42bd9 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Wed, 2 Sep 2026 16:33:27 +0200 Subject: [PATCH 01/15] automations: fix: preserve Autopilot mode and policy Complete the legacy Automation mapping by preserving Autopilot on the Agent Host mode axis while applying Assisted approvals. Ignore generic chat modes at the AHP boundary and revalidate elevated Copilot approvals against current policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../agentHost/node/copilot/copilotAgent.ts | 7 +++- .../agentHost/test/node/copilotAgent.test.ts | 26 +++++++++++++++ .../browser/agentHostAutomationStore.ts | 17 ++++++++-- .../browser/agentHostAutomationStore.test.ts | 33 ++++++++++++++++++- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 0f8144b6b3bf52..73478985c0f09f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4044,7 +4044,12 @@ export class CopilotAgent extends Disposable implements IAgent { // Isolation / branch are contributed by the host (see // AgentService._withHostSessionConfigContributions); this agent only owns its platform // session config (auto-approve / mode / permissions). - const values = platformSessionSchema.validateOrDefault(migrateLegacyAutopilotConfig(params.config), { + const migratedConfig = migrateLegacyAutopilotConfig(params.config); + const policyRestricted = this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true; + const config = policyRestricted && migratedConfig?.[SessionConfigKey.AutoApprove] !== undefined && migratedConfig[SessionConfigKey.AutoApprove] !== 'default' + ? { ...migratedConfig, [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel } + : migratedConfig; + const values = platformSessionSchema.validateOrDefault(config, { [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, [SessionConfigKey.Mode]: 'interactive' satisfies SessionMode, // Permissions intentionally omitted — leave unset so auto-approval diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 76ac7faf33b797..23f19c07156d29 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1234,6 +1234,32 @@ suite('CopilotAgent', () => { } }); + test('revalidates elevated session config against current policy', async () => { + const { agent, configurationService } = createTestAgentContext(disposables); + try { + const config = { + [SessionConfigKey.Mode]: 'autopilot', + [SessionConfigKey.AutoApprove]: 'assisted', + }; + const selected = await agent.resolveChatConfig({ config }); + configurationService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); + const restricted = await agent.resolveChatConfig({ config }); + + assert.deepStrictEqual({ + selected: selected.values, + restricted: restricted.values, + }, { + selected: config, + restricted: { + [SessionConfigKey.Mode]: 'autopilot', + [SessionConfigKey.AutoApprove]: 'default', + }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('installs the GitHub telemetry callback in CopilotClientOptions', async () => { const client = new TestCopilotClient([]); const agent = createTestAgent(disposables, { copilotClient: client }) as TestableCopilotAgent; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 9358174e3acbc6..e97ce310e2f0b6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -14,7 +14,7 @@ import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../platform/agentHost/common/automationMigration.js'; import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; -import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationDefinition, type AutomationEntry, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -833,8 +833,9 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean): AutomationDefinition { const config = { ...existing?.session.config }; const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(descriptor.modelId); - setOptional(config, SessionConfigKey.Mode, descriptor.mode); - setOptional(config, SessionConfigKey.AutoApprove, descriptor.permissionLevel === ChatPermissionLevel.Autopilot ? ChatPermissionLevel.Assisted : descriptor.permissionLevel); + const migratedConfig = migrateLegacyAutomationSessionConfig(descriptor.mode, descriptor.permissionLevel); + setOptional(config, SessionConfigKey.Mode, migratedConfig.mode); + setOptional(config, SessionConfigKey.AutoApprove, migratedConfig.autoApprove); if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); @@ -1226,6 +1227,16 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +function migrateLegacyAutomationSessionConfig(mode: string | undefined, permissionLevel: string | undefined): { readonly mode: string | undefined; readonly autoApprove: string | undefined } { + const agentMode = mode && KNOWN_MODE_VALUES.has(mode) ? mode : undefined; + return permissionLevel === ChatPermissionLevel.Autopilot + ? { + mode: agentMode === 'plan' ? agentMode : ChatPermissionLevel.Autopilot, + autoApprove: ChatPermissionLevel.Assisted, + } + : { mode: agentMode, autoApprove: permissionLevel }; +} + function setOptional(target: Record, key: string, value: unknown): void { if (value === undefined) { delete target[key]; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index ef15dc40a8eb5d..b9a97ddc6126b5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -405,6 +405,7 @@ suite('AgentHostAutomationStore', () => { prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + mode: 'agent', permissionLevel: 'autopilot', }); const create = connection.dispatched[0].action; @@ -427,7 +428,7 @@ suite('AgentHostAutomationStore', () => { subscribedChannel: URI.parse(AUTOMATION_CATALOG_URI).toString(), dispatchChannel: AUTOMATION_CATALOG_URI, definitionMeta: undefined, - sessionConfig: { autoApprove: 'assisted' }, + sessionConfig: { mode: 'autopilot', autoApprove: 'assisted' }, triggerExpression: '30 9 * * *', automation: { name: 'Review changes', @@ -439,6 +440,36 @@ suite('AgentHostAutomationStore', () => { }); }); + test('does not forward generic chat modes to Agent Host session config', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + mode: 'agent', + permissionLevel: 'default', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { autoApprove: 'default' }, + ); + }); + test('switches authority only after host migration completion is verified', async () => { const connection = new TestAutomationConnection(false); disposables.add(connection); From 04ab7a23a823126b7f5f17d2c8ec9fae90a9a488 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Wed, 2 Sep 2026 23:45:24 +0200 Subject: [PATCH 02/15] automations: fix: harden legacy Autopilot migration Preserve provider-owned configuration across compatibility edits, repair existing and provider-less Copilot Automation definitions, and reset incompatible state on retargeting. Enforce managed auto-approval policy at both SDK and host decision points while keeping saved preferences intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../agentHost/common/automationMigration.ts | 31 +++ .../node/agentHostAutomationService.ts | 19 +- .../agentHost/node/copilot/copilotAgent.ts | 7 +- .../node/copilot/copilotAgentSession.ts | 8 +- .../agentHost/node/sessionPermissions.ts | 5 +- .../node/agentHostAutomationService.test.ts | 52 +++++ .../agentHost/test/node/copilotAgent.test.ts | 26 --- .../test/node/copilotAgentSession.test.ts | 23 +- .../test/node/sessionPermissions.test.ts | 14 +- .../browser/automationDialogService.ts | 7 +- .../automations/browser/automationTools.ts | 9 +- .../test/browser/automationTools.test.ts | 26 +++ .../browser/agentHostAutomationStore.ts | 39 ++-- .../browser/agentHostAutomationStore.test.ts | 202 +++++++++++++++++- .../chat/common/automations/automation.ts | 4 +- 15 files changed, 407 insertions(+), 65 deletions(-) diff --git a/src/vs/platform/agentHost/common/automationMigration.ts b/src/vs/platform/agentHost/common/automationMigration.ts index 16b4683b825852..f0d563b2c80083 100644 --- a/src/vs/platform/agentHost/common/automationMigration.ts +++ b/src/vs/platform/agentHost/common/automationMigration.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { migrateLegacyAutopilotConfig } from './agentHostSchema.js'; +import { KNOWN_MODE_VALUES, SessionConfigKey } from './sessionConfigKeys.js'; + export const AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY = 'vscode.automationMigration'; export const AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY = 'automationsEnabled'; export const AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY = 'automationRunTimeoutMinutes'; @@ -10,6 +13,8 @@ export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY = 'vscode.legacyAutoma export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY = 'vscode.legacyAutomationImportPending'; export const AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY = 'vscode.migrationCompleted'; +const LEGACY_AUTOPILOT_PROVIDER = 'copilotcli'; + export interface IAgentHostAutomationMigrationCompletion { readonly version: 1; readonly status: 'complete'; @@ -27,3 +32,29 @@ export function isAgentHostAutomationMigrationCompletion(value: unknown): value const resources = candidate['resources']; return resources.every(resource => typeof resource === 'string') && new Set(resources).size === resources.length; } + +/** Whether the provider used the legacy flattened Automation mode and permission fields. */ +export function supportsLegacyAutomationSessionConfig(provider: string | undefined): boolean { + return provider === undefined || provider === LEGACY_AUTOPILOT_PROVIDER; +} + +/** Migrates the legacy combined Autopilot value into the Copilot Automation's current two-axis configuration. */ +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: undefined): undefined; +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record): Record; +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record | undefined): Record | undefined; +export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record | undefined): Record | undefined { + if (!supportsLegacyAutomationSessionConfig(provider) || !config) { + return config; + } + if (config[SessionConfigKey.AutoApprove] === 'assisted' + && typeof config[SessionConfigKey.Mode] === 'string' + && !KNOWN_MODE_VALUES.has(config[SessionConfigKey.Mode])) { + return { ...config, [SessionConfigKey.Mode]: 'autopilot' }; + } + if (config[SessionConfigKey.AutoApprove] !== 'autopilot') { + return config; + } + const migrated = migrateLegacyAutopilotConfig(config); + migrated[SessionConfigKey.AutoApprove] = 'assisted'; + return migrated; +} diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts index 6e98e27f32ebff..083d989915f111 100644 --- a/src/vs/platform/agentHost/node/agentHostAutomationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -23,7 +23,7 @@ import { MessageKind } from '../common/state/protocol/channels-chat/state.js'; import { IAgentHostStateManager, type AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; import { nextAutomationCronOccurrence, validateAutomationCron } from './automationCron.js'; -import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY } from '../common/automationMigration.js'; +import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY, migrateLegacyAutomationSessionConfig } from '../common/automationMigration.js'; import { isAgentHostLegacyAutomationImportPending } from '../common/meta/automationMeta.js'; const STORAGE_KEY = 'automations'; @@ -107,7 +107,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost this._migrationCompletedAt = stored?.migration?.completedAt; this._runs = new Map(stored?.runs?.map(run => [run.resource, run])); this._catalog = stored?.catalog ? { - entries: stored.catalog.automations.map(automation => withRunWindow(automation, this._runs, RUN_HISTORY_PAGE_SIZE)), + entries: stored.catalog.automations.map(automation => withRunWindow(migrateStoredAutomation(automation), this._runs, RUN_HISTORY_PAGE_SIZE)), ...(stored.catalog._meta || this._migrationCompletedAt ? { _meta: { ...stored.catalog._meta, @@ -960,6 +960,21 @@ function isStoredAutomationCatalog(value: unknown): value is IStoredAutomationCa && (meta === undefined || !!meta && typeof meta === 'object' && !Array.isArray(meta)); } +function migrateStoredAutomation(automation: AutomationEntry): AutomationEntry { + const session = automation.definition.session; + const config = migrateLegacyAutomationSessionConfig(session.provider, session.config); + if (config === session.config) { + return automation; + } + return { + ...automation, + definition: { + ...automation.definition, + session: { ...session, config }, + }, + }; +} + function isStoredAutomations(value: unknown): value is IStoredAutomations { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 73478985c0f09f..0f8144b6b3bf52 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4044,12 +4044,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Isolation / branch are contributed by the host (see // AgentService._withHostSessionConfigContributions); this agent only owns its platform // session config (auto-approve / mode / permissions). - const migratedConfig = migrateLegacyAutopilotConfig(params.config); - const policyRestricted = this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true; - const config = policyRestricted && migratedConfig?.[SessionConfigKey.AutoApprove] !== undefined && migratedConfig[SessionConfigKey.AutoApprove] !== 'default' - ? { ...migratedConfig, [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel } - : migratedConfig; - const values = platformSessionSchema.validateOrDefault(config, { + const values = platformSessionSchema.validateOrDefault(migrateLegacyAutopilotConfig(params.config), { [SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel, [SessionConfigKey.Mode]: 'interactive' satisfies SessionMode, // Permissions intentionally omitted — leave unset so auto-approval diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f0ee8e65822f62..ef78d252aa6003 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -38,7 +38,7 @@ import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from import { ChatInputRequestPurpose, withChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; 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 { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.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'; @@ -3973,6 +3973,9 @@ export class CopilotAgentSession extends Disposable { * level. Agent mode is an orthogonal axis and does not affect approvals. */ private _isBypassApprovals(): boolean { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true) { + return false; + } if (this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) === true) { return true; } @@ -3989,6 +3992,9 @@ export class CopilotAgentSession extends Disposable { } private _getConfiguredApprovalLevel(): string { + if (this._configurationService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true) { + return 'default'; + } return this._configurationService.getEffectiveValue(this._ownerSessionUri.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default'; } diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 5fa2c94ff88d45..f673188efc6f1b 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -20,7 +20,7 @@ import { localize } from '../../../nls.js'; import { ALWAYS_CHECKED_EDIT_PATTERNS, DEFAULT_EDIT_AUTO_APPROVE_PATTERNS } from '../../chat/common/chatSettings.js'; import { ILogService } from '../../log/common/log.js'; import { containsCmdDelayedExpansion } from '../../terminal/common/autoApprove/cmdDelayedExpansion.js'; -import { AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformRootSchema, platformSessionSchema } from '../common/agentHostSchema.js'; import type { IAgentToolPendingConfirmationSignal } from '../common/agent.js'; import { ISessionDataService, isSessionAttachmentPath } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -424,6 +424,9 @@ export class SessionPermissionManager extends Disposable { } getEffectiveApprovalLevel(sessionKey: ProtocolURI): string { + if (this._configService.getRootValue(platformRootSchema, AgentHostAutoApprovePolicyRestrictedConfigKey) === true) { + return 'default'; + } return this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default'; } diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts index 49033b78af4cea..d9848b658fa4ef 100644 --- a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -13,6 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY, AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_RUN_TIMEOUT_MINUTES_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../common/automationMigration.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { AutomationMisfirePolicy, AutomationOperation, AutomationTriggerKind, type AutomationDefinition } from '../../common/state/protocol/channels-automation/state.js'; import { AutomationRunOriginKind, AutomationRunStatus, type AutomationRunState } from '../../common/state/protocol/channels-automation-run/state.js'; @@ -164,6 +165,57 @@ suite('AgentHostAutomationService', () => { }); }); + test('migrates stored Copilot Autopilot configurations into the current Automation shape', async () => { + storageService.set('automations', { + version: 1, + catalog: { + automations: [ + { + resource: 'ahp-automation:/legacy-autopilot', + definition: { + ...definition(), + session: { + provider: 'copilotcli', + config: { [SessionConfigKey.AutoApprove]: 'autopilot' }, + }, + }, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove], + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + }, + { + resource: 'ahp-automation:/hotfix-window', + definition: { + ...definition(), + session: { + config: { + [SessionConfigKey.Mode]: 'agent', + [SessionConfigKey.AutoApprove]: 'assisted', + }, + }, + }, + runs: [], + operations: [AutomationOperation.Update, AutomationOperation.Remove], + createdAt: '2026-01-01T00:00:00.000Z', + modifiedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }); + await storageService.whenIdle(); + + createService(); + + assert.deepStrictEqual( + stateManager.getAutomationCatalogState()?.entries.map(automation => automation.definition.session.config), + [ + { mode: 'autopilot', autoApprove: 'assisted' }, + { mode: 'autopilot', autoApprove: 'assisted' }, + ], + ); + }); + test('failed catalogue persistence publishes nothing and a retry creates one entry', async () => { const service = createService(); await service.completeMigration(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 23f19c07156d29..76ac7faf33b797 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1234,32 +1234,6 @@ suite('CopilotAgent', () => { } }); - test('revalidates elevated session config against current policy', async () => { - const { agent, configurationService } = createTestAgentContext(disposables); - try { - const config = { - [SessionConfigKey.Mode]: 'autopilot', - [SessionConfigKey.AutoApprove]: 'assisted', - }; - const selected = await agent.resolveChatConfig({ config }); - configurationService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); - const restricted = await agent.resolveChatConfig({ config }); - - assert.deepStrictEqual({ - selected: selected.values, - restricted: restricted.values, - }, { - selected: config, - restricted: { - [SessionConfigKey.Mode]: 'autopilot', - [SessionConfigKey.AutoApprove]: 'default', - }, - }); - } finally { - await disposeAgent(agent); - } - }); - test('installs the GitHub telemetry callback in CopilotClientOptions', async () => { const client = new TestCopilotClient([]); const agent = createTestAgent(disposables, { copilotClient: client }) as TestableCopilotAgent; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 5b46ad04d277bc..70ba3ce80d4bd7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -61,7 +61,7 @@ import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js' import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystemNotification.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; import { SEMANTIC_SEARCH_TOOL_NAME } from '../../common/semanticSearchConstants.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; @@ -5657,6 +5657,27 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['manual', 'allow-all']); }); + test('revokes elevated permission modes when policy changes', async () => { + const results: PermissionMode[][] = []; + for (const autoApprove of ['assisted', 'autoApprove']) { + const { session, mockSession, setRootValue, fireRootConfigChange } = await createAgentSession(disposables, { + configValues: { [SessionConfigKey.AutoApprove]: autoApprove }, + }); + await session.syncPermissionMode('turn-start'); + session.resetTurnState('active-turn'); + setRootValue(AgentHostAutoApprovePolicyRestrictedConfigKey, true); + + fireRootConfigChange(); + await timeout(0); + results.push([...mockSession.permissionModeSetCalls]); + } + + assert.deepStrictEqual(results, [ + ['assisted', 'manual'], + ['allow-all', 'manual'], + ]); + }); + test('aborts when a live permission mode update fails', async () => { const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables, { configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index b7dc09d9fea123..15aa9228094d40 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -14,7 +14,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; -import { AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; import { DEFAULT_EDIT_AUTO_APPROVE_PATTERNS, mergeChatEditAutoApprovePatterns } from '../../../chat/common/chatSettings.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -586,6 +586,18 @@ suite('SessionPermissionManager', () => { assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); }); + test('managed policy disables a persisted session auto-approve level', () => { + manager.setSessionConfig(sessionUri, { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); + assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), true); + + configService.updateRootConfig({ [AgentHostAutoApprovePolicyRestrictedConfigKey]: true }); + + assert.strictEqual(permissions.isSessionAutoApproveEnabled(sessionUri), false); + }); + // ---- Multi-root auto-approval ------------------------------------------ // A session with multiple working directories auto-approves a read/write/ // shell destination when it is contained by *any* root (index 0 = primary). diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 38e8f2c7f16ba0..bbbef88adbe365 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -22,6 +22,7 @@ import { createWorkbenchDialogOptions } from '../../../../workbench/browser/part import { AutomationTarget, IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { isAutoApprovePolicyRestricted, isAutoApproveValuePolicyRestricted } from '../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -222,7 +223,11 @@ export class AutomationDialogService implements IAutomationDialogService { const prompt = getPrompt(); const mode = getMode(); - const permissionLevel = getPermissionLevel(); + const selectedPermissionLevel = getPermissionLevel(); + const permissionLevel = initial?.permissionLevel !== undefined + && isAutoApproveValuePolicyRestricted(initial.permissionLevel, isAutoApprovePolicyRestricted(this.configurationService)) + ? initial.permissionLevel + : selectedPermissionLevel; const modelId = getModelId(); const branch = getBranch(); const target = createAutomationTarget(state, branch); diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 5719db5c811129..056d0bc0551190 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -20,7 +20,7 @@ import { IAutomationRunDispatch, IAutomationRunner } from '../../../../workbench import { type AutomationMutationGuard, ConfigureAutomationToolReferenceName, IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, serializeAutomationEditableState } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IChatAutomationConfiguredData } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { IProviderSessionType, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -35,7 +35,6 @@ const deleteAutomationConfirmationId = 'delete'; const manualRunLeaderWindowId = 0; const automationIntervals: readonly AutomationInterval[] = ['manual', 'hourly', 'daily', 'weekly']; const automationIsolationKinds: readonly AutomationWorkspaceIsolation['kind'][] = ['default', 'folder', 'worktree']; -const chatModes: readonly ChatModeKind[] = [ChatModeKind.Agent, ChatModeKind.Ask, ChatModeKind.Edit]; const chatPermissionLevels: readonly ChatPermissionLevel[] = [ChatPermissionLevel.Default, ChatPermissionLevel.Assisted, ChatPermissionLevel.AutoApprove, ChatPermissionLevel.Autopilot]; interface IAutomationToolOutput { @@ -463,8 +462,8 @@ The change uses the current tool-approval policy. When approval is required, the description: 'Language model ID, or null to use the provider default.', }, mode: { - enum: [...chatModes, null], - description: 'Chat mode, or null to use the provider default.', + type: ['string', 'null'], + description: 'Provider mode identifier, or null to use the provider default.', }, permissionLevel: { enum: [...chatPermissionLevels, null], @@ -666,7 +665,7 @@ The change uses the current tool-approval policy. When approval is required, the const currentTarget = this.getCurrentSessionTarget(sessionResource); const target = parseTarget(input, existing, currentTarget); const modelId = readOptionalNullableNonEmptyString(input, 'modelId'); - const mode = readOptionalNullableEnum(input, 'mode', chatModes); + const mode = readOptionalNullableNonEmptyString(input, 'mode'); const permissionLevel = readOptionalNullableEnum(input, 'permissionLevel', chatPermissionLevels); const enabled = readOptionalBoolean(input, 'enabled'); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index faff57245539c6..2d3067a7885680 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -830,6 +830,32 @@ suite('AutomationTools', () => { }); }); + test('configureAutomation accepts a provider mode returned by listAutomations', async () => { + const existing = createAutomation({ mode: 'autopilot' }); + const automationService = new FakeAutomationService([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + const parameters = { + automationId: existing.id, + mode: 'autopilot', + }; + const prepared = await tool.prepareToolInvocation!({ + parameters, + toolCallId: 'update-call', + chatSessionResource: SESSION_RESOURCE, + }, CancellationToken.None); + + await invoke(tool, parameters, SESSION_RESOURCE, CancellationToken.None, undefined, prepared.toolSpecificData); + + assert.deepStrictEqual(automationService.updated, [{ + id: existing.id, + patch: { mode: 'autopilot' }, + }]); + }); + test('configureAutomation rejects editable changes made while awaiting approval', async () => { const existing = createAutomation(); const automationService = new FakeAutomationService([existing]); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index e97ce310e2f0b6..1ac0a906a0c3bf 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; -import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../platform/agentHost/common/automationMigration.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, migrateLegacyAutomationSessionConfig, supportsLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -25,7 +25,6 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; -import { ChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAutomationStorageService } from '../../../automations/common/automationStorageService.js'; @@ -831,11 +830,14 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean): AutomationDefinition { - const config = { ...existing?.session.config }; const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(descriptor.modelId); - const migratedConfig = migrateLegacyAutomationSessionConfig(descriptor.mode, descriptor.permissionLevel); - setOptional(config, SessionConfigKey.Mode, migratedConfig.mode); - setOptional(config, SessionConfigKey.AutoApprove, migratedConfig.autoApprove); + const existingSession = existing && existing.session.provider === provider ? existing.session : undefined; + const config = applyLegacyAutomationDescriptorConfig( + { ...existingSession?.config }, + provider, + descriptor.mode, + descriptor.permissionLevel, + ); if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); @@ -858,6 +860,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro session: { provider, model: descriptor.modelId ? { id: this._toHostModelId(descriptor.modelId, provider) } : undefined, + agent: existingSession?.agent, workingDirectories: descriptor.target.kind === 'workspace' ? [(this._boundaryMapper?.toHost(descriptor.target.folderUri) ?? descriptor.target.folderUri).toString()] : undefined, @@ -917,8 +920,8 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro modelId: patch.modelId === null ? undefined : patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId), - mode: patch.mode === null ? undefined : patch.mode ?? current.mode, - permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? current.permissionLevel, + mode: patch.mode === null ? undefined : patch.mode ?? (targetAuthorityChanged ? undefined : current.mode), + permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? (targetAuthorityChanged ? undefined : current.permissionLevel), enabled, updatedAt: now.toISOString(), }; @@ -1227,14 +1230,20 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } -function migrateLegacyAutomationSessionConfig(mode: string | undefined, permissionLevel: string | undefined): { readonly mode: string | undefined; readonly autoApprove: string | undefined } { - const agentMode = mode && KNOWN_MODE_VALUES.has(mode) ? mode : undefined; - return permissionLevel === ChatPermissionLevel.Autopilot - ? { - mode: agentMode === 'plan' ? agentMode : ChatPermissionLevel.Autopilot, - autoApprove: ChatPermissionLevel.Assisted, +function applyLegacyAutomationDescriptorConfig(config: Record, provider: string | undefined, mode: string | undefined, permissionLevel: string | undefined): Record { + if (!supportsLegacyAutomationSessionConfig(provider)) { + if (permissionLevel === undefined || permissionLevel === 'default') { + delete config[SessionConfigKey.AutoApprove]; } - : { mode: agentMode, autoApprove: permissionLevel }; + return config; + } + if (mode === undefined) { + delete config[SessionConfigKey.Mode]; + } else if (KNOWN_MODE_VALUES.has(mode)) { + config[SessionConfigKey.Mode] = mode; + } + setOptional(config, SessionConfigKey.AutoApprove, permissionLevel); + return migrateLegacyAutomationSessionConfig(provider, config); } function setOptional(target: Record, key: string, value: unknown): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index b9a97ddc6126b5..69fea34f77261a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -15,6 +15,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import type { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; +import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationEntry, type AutomationState, type RootState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -82,6 +83,23 @@ class TestAutomationConnection { }); } + setFirstAutomationSessionConfig(config: Record): void { + const [automation, ...rest] = this._catalog.entries; + if (!automation) { + throw new Error('No Automation is available.'); + } + this._catalog = { + entries: [{ + ...automation, + definition: { + ...automation.definition, + session: { ...automation.definition.session, config }, + }, + }, ...rest], + }; + this._onDidCatalogChange.fire(this._catalog); + } + getSubscription( kind: StateComponents.AutomationCatalog, resource: URI, @@ -374,7 +392,7 @@ suite('AgentHostAutomationStore', () => { name: id, prompt: 'Review history.', schedule: { interval: 'manual', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, enabled: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -404,7 +422,7 @@ suite('AgentHostAutomationStore', () => { name: 'Review changes', prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, mode: 'agent', permissionLevel: 'autopilot', }); @@ -434,7 +452,7 @@ suite('AgentHostAutomationStore', () => { name: 'Review changes', prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, enabled: true, }, }); @@ -458,7 +476,7 @@ suite('AgentHostAutomationStore', () => { name: 'Review changes', prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, - target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'mock' }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, mode: 'agent', permissionLevel: 'default', }); @@ -470,6 +488,182 @@ suite('AgentHostAutomationStore', () => { ); }); + test('applies legacy Autopilot configuration to the default provider', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { + kind: 'workspace', + folderUri: URI.file('/workspace'), + providerId: undefined, + sessionTypeId: undefined, + isolation: { kind: 'default' }, + }, + mode: 'agent', + permissionLevel: 'autopilot', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { mode: 'autopilot', autoApprove: 'assisted' }, + ); + }); + + test('preserves Agent Host config when a generic dialog mode has no representation', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + await store.updateAutomation(automation.id, { + name: 'Review renamed changes', + mode: 'agent', + permissionLevel: 'assisted', + }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, + { mode: 'autopilot', autoApprove: 'assisted' }, + ); + }); + + test('does not apply legacy Copilot configuration to another session type', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + mode: 'agent', + permissionLevel: 'autopilot', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session : undefined, + { + provider: 'claude', + model: undefined, + agent: undefined, + workingDirectories: undefined, + config: undefined, + }, + ); + }); + + test('clears stale platform approval config for another session type', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + }); + connection.setFirstAutomationSessionConfig({ [SessionConfigKey.AutoApprove]: 'autoApprove' }); + + await store.updateAutomation(automation.id, { permissionLevel: 'default' }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, + undefined, + ); + }); + + test('drops provider configuration when retargeting to another session type', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + await store.updateAutomation(automation.id, { + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, + { + provider: 'claude', + model: undefined, + agent: undefined, + workingDirectories: undefined, + config: undefined, + }, + ); + }); + test('switches authority only after host migration completion is verified', async () => { const connection = new TestAutomationConnection(false); disposables.add(connection); diff --git a/src/vs/workbench/contrib/chat/common/automations/automation.ts b/src/vs/workbench/contrib/chat/common/automations/automation.ts index e042db5a32cb82..c054b68dc44805 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automation.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automation.ts @@ -68,10 +68,10 @@ export interface IAutomationDescriptor { /** Optional language model identifier to seed the new session with. */ readonly modelId?: string; - /** Optional chat mode (`agent`/`ask`/`edit`). Defaults to provider's default; custom modes unsupported. */ + /** Optional provider mode identifier. Defaults to the provider's mode. */ readonly mode?: string; - /** Optional permission level (`default`/`autoApprove`/`autopilot`). Overrides only for scheduled runs; defaults to provider's default. */ + /** Optional permission level (`default`/`assisted`/`autoApprove`/`autopilot`). Defaults to the provider's level. */ readonly permissionLevel?: string; readonly enabled: boolean; From 0f313591e11d5f575b4ca7160e385539d52bb34c Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 00:33:04 +0200 Subject: [PATCH 03/15] automations: refactor: preserve provider session templates Add a versioned provider-neutral session template projection for model, agent, and opaque configuration. Keep legacy flat writers functional during migration, preserve unknown AHP state on edits and transfers, and clear incompatible configuration on retarget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../agentHost/common/automationMigration.ts | 22 ++++ .../automations/browser/automationService.ts | 93 ++++++++++++++-- .../browser/providerAutomationService.ts | 1 + .../test/browser/automationService.test.ts | 104 +++++++++++++++++- .../test/browser/automationTools.test.ts | 3 +- .../browser/providerAutomationService.test.ts | 2 +- .../browser/agentHostAutomationStore.ts | 87 ++++++++++----- .../browser/agentHostAutomationStore.test.ts | 96 ++++++++++++++++ .../chat/common/automations/automation.ts | 13 +++ .../common/automations/automationService.ts | 8 +- 10 files changed, 379 insertions(+), 50 deletions(-) diff --git a/src/vs/platform/agentHost/common/automationMigration.ts b/src/vs/platform/agentHost/common/automationMigration.ts index f0d563b2c80083..5b01894608776e 100644 --- a/src/vs/platform/agentHost/common/automationMigration.ts +++ b/src/vs/platform/agentHost/common/automationMigration.ts @@ -58,3 +58,25 @@ export function migrateLegacyAutomationSessionConfig(provider: string | undefine migrated[SessionConfigKey.AutoApprove] = 'assisted'; return migrated; } + +/** Applies the legacy flattened Automation values to provider configuration. */ +export function applyLegacyAutomationSessionConfig(provider: string | undefined, config: Readonly> | undefined, mode: string | undefined, permissionLevel: string | undefined): Record { + const result = { ...config }; + if (!supportsLegacyAutomationSessionConfig(provider)) { + if (permissionLevel === undefined || permissionLevel === 'default') { + delete result[SessionConfigKey.AutoApprove]; + } + return result; + } + if (mode === undefined) { + delete result[SessionConfigKey.Mode]; + } else if (KNOWN_MODE_VALUES.has(mode)) { + result[SessionConfigKey.Mode] = mode; + } + if (permissionLevel === undefined) { + delete result[SessionConfigKey.AutoApprove]; + } else { + result[SessionConfigKey.AutoApprove] = permissionLevel; + } + return migrateLegacyAutomationSessionConfig(provider, result); +} diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index e0242910477256..71b945c588a6e0 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -8,6 +8,7 @@ import { derived, IObservable, ISettableObservable, observableValue, transaction import { URI, UriComponents } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { applyLegacyAutomationSessionConfig } from '../../../../platform/agentHost/common/automationMigration.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult } from '../../../services/sessions/common/sessionsProvider.js'; @@ -17,6 +18,7 @@ import { AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, + IAutomationSessionTemplate, } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { type AutomationMutationGuard, @@ -34,8 +36,9 @@ import { computeNextRunAt } from '../../../../workbench/contrib/chat/common/auto import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { AUTOMATION_STORAGE_KEY, IAutomationStorageService } from '../common/automationStorageService.js'; -const LEGACY_SCHEMA_VERSIONS = new Set([1, 2]); -const CURRENT_SCHEMA_VERSION = 3; +const LEGACY_TARGET_SCHEMA_VERSIONS = new Set([1, 2]); +const CURRENT_TARGET_SCHEMA_VERSIONS = new Set([3, 4]); +const CURRENT_SCHEMA_VERSION = 4; const MAX_RUNS_PER_AUTOMATION = 50; @@ -44,6 +47,7 @@ interface ISerializedAutomationBase { readonly name: string; readonly prompt: string; readonly schedule: IAutomationDescriptor['schedule']; + readonly sessionTemplate?: IAutomationSessionTemplate; readonly modelId?: string; readonly mode?: string; readonly permissionLevel?: string; @@ -82,7 +86,7 @@ interface ILegacySerializedAutomation extends ISerializedAutomationBase { } interface ISerializedLedger { - readonly schemaVersion: 3; + readonly schemaVersion: 4; // Optimistic-concurrency counter. 0 for legacy blobs without this field. readonly revision?: number; readonly automations: readonly ISerializedAutomation[]; @@ -184,6 +188,7 @@ export class AutomationStore extends Disposable implements IAutomationStore { prompt: options.prompt, schedule: options.schedule, target: normalizeAutomationTarget(options.target), + ...(options.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : {}), modelId: options.modelId, mode: options.mode, permissionLevel: isChatPermissionLevel(options.permissionLevel) ? options.permissionLevel : undefined, @@ -533,19 +538,19 @@ export class AutomationStore extends Disposable implements IAutomationStore { return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; } try { - const parsed = JSON.parse(raw) as ISerializedLedger | ILegacySerializedLedger; + const parsed = JSON.parse(raw) as ISerializedLedger | (Omit & { readonly schemaVersion: 3 }) | ILegacySerializedLedger; if (typeof parsed?.schemaVersion === 'number' && parsed.schemaVersion > CURRENT_SCHEMA_VERSION) { this.logService.warn(`[AutomationService] Ledger has schema v${parsed.schemaVersion}; this build only supports v${CURRENT_SCHEMA_VERSION}. Entering read-only mode.`); return { kind: 'unsupportedSchema' }; } - if (parsed?.schemaVersion !== CURRENT_SCHEMA_VERSION && !LEGACY_SCHEMA_VERSIONS.has(parsed?.schemaVersion)) { + if (!CURRENT_TARGET_SCHEMA_VERSIONS.has(parsed?.schemaVersion) && !LEGACY_TARGET_SCHEMA_VERSIONS.has(parsed?.schemaVersion)) { this.logService.warn(`[AutomationService] Unsupported ledger schema version ${parsed?.schemaVersion}; ignoring.`); return { kind: 'invalid', ledger: EMPTY_LEDGER, revision: 0 }; } const automations: IAutomationDescriptor[] = []; // Malformed rows are dropped individually; only structurally invalid ledgers remain read-only. const invalid = !Array.isArray(parsed.automations) || !Array.isArray(parsed.runs); - if (parsed.schemaVersion === CURRENT_SCHEMA_VERSION) { + if (CURRENT_TARGET_SCHEMA_VERSIONS.has(parsed.schemaVersion)) { const entries = Array.isArray(parsed.automations) ? parsed.automations : []; for (const entry of entries) { try { @@ -617,6 +622,7 @@ function serializeAutomation(a: IAutomationDescriptor): ISerializedAutomation { prompt: a.prompt, schedule: a.schedule, target: serializeAutomationTarget(a.target), + sessionTemplate: a.sessionTemplate, modelId: a.modelId, mode: a.mode, permissionLevel: a.permissionLevel, @@ -683,6 +689,7 @@ function createAutomationFromSerialized(s: ISerializedAutomationBase, target: Au const permissionLevel = isChatPermissionLevel(s.permissionLevel) ? s.permissionLevel : ChatPermissionLevel.Default; + const sessionTemplate = deserializeAutomationSessionTemplate(s.sessionTemplate); return Object.freeze({ id: s.id, @@ -690,6 +697,7 @@ function createAutomationFromSerialized(s: ISerializedAutomationBase, target: Au prompt: s.prompt, schedule: s.schedule, target, + ...(sessionTemplate ? { sessionTemplate } : {}), modelId: s.modelId, mode: s.mode, permissionLevel, @@ -715,15 +723,31 @@ function updateAutomation(current: IAutomationDescriptor, patch: IUpdateAutomati } function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): IAutomationDescriptor { + const target = patch.target ? normalizeAutomationTarget(patch.target) : current.target; + const targetAuthorityChanged = patch.target !== undefined + && (target.providerId !== current.target.providerId || target.sessionTypeId !== current.target.sessionTypeId); + const modelId = patch.modelId === null ? undefined : (patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId)); + const mode = patch.mode === null ? undefined : (patch.mode ?? (targetAuthorityChanged ? undefined : current.mode)); + const permissionLevel = patch.permissionLevel === null + ? undefined + : patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) + ? patch.permissionLevel + : targetAuthorityChanged ? ChatPermissionLevel.Default : current.permissionLevel; + const sessionTemplate = patch.sessionTemplate === null + ? undefined + : patch.sessionTemplate ?? (targetAuthorityChanged + ? undefined + : synchronizeAutomationSessionTemplate(current.sessionTemplate, target.sessionTypeId, modelId, mode, permissionLevel)); return { ...current, name: patch.name ?? current.name, prompt: patch.prompt ?? current.prompt, schedule: patch.schedule ?? current.schedule, - target: patch.target ? normalizeAutomationTarget(patch.target) : current.target, - modelId: patch.modelId === null ? undefined : (patch.modelId ?? current.modelId), - mode: patch.mode === null ? undefined : (patch.mode ?? current.mode), - permissionLevel: patch.permissionLevel === null ? undefined : (patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) ? patch.permissionLevel : current.permissionLevel), + target, + sessionTemplate, + modelId, + mode, + permissionLevel, enabled: patch.enabled ?? current.enabled, }; } @@ -746,6 +770,55 @@ function normalizeAutomationTarget(target: AutomationTarget): AutomationTarget { ); } +function deserializeAutomationSessionTemplate(value: unknown): IAutomationSessionTemplate | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new Error('Automation session template must be an object.'); + } + const modelId = value['modelId']; + if (modelId !== undefined && typeof modelId !== 'string') { + throw new Error('Automation session template model must be a string.'); + } + const rawAgent = value['agent']; + let agent: IAutomationSessionTemplate['agent']; + if (rawAgent !== undefined) { + if (!isRecord(rawAgent) || typeof rawAgent['uri'] !== 'string') { + throw new Error('Automation session template agent must contain a URI.'); + } + agent = { uri: rawAgent['uri'] }; + } + const config = value['config']; + if (config !== undefined && !isRecord(config)) { + throw new Error('Automation session template config must be an object.'); + } + return { + ...(modelId !== undefined ? { modelId } : {}), + ...(agent ? { agent } : {}), + ...(config !== undefined ? { config: { ...config } } : {}), + }; +} + +function synchronizeAutomationSessionTemplate(template: IAutomationSessionTemplate | undefined, provider: string | undefined, modelId: string | undefined, mode: string | undefined, permissionLevel: string | undefined): IAutomationSessionTemplate | undefined { + if (!template) { + return undefined; + } + const config = applyLegacyAutomationSessionConfig(provider, template.config, mode, permissionLevel); + if (!modelId && !template.agent && Object.keys(config).length === 0) { + return undefined; + } + return { + ...(modelId ? { modelId } : {}), + ...(template.agent ? { agent: template.agent } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + function serializeAutomationTarget(target: AutomationTarget): ISerializedAutomationTarget { return target.kind === 'quickChat' ? { kind: 'quickChat', providerId: target.providerId, sessionTypeId: target.sessionTypeId } diff --git a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index 9a83474304b422..0bbd54c7e0c1ef 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -298,6 +298,7 @@ export class ProviderAutomationService extends Disposable implements IAutomation prompt: previous.prompt, schedule: previous.schedule, target: previous.target, + sessionTemplate: previous.sessionTemplate ?? null, modelId: previous.modelId ?? null, mode: previous.mode ?? null, permissionLevel: previous.permissionLevel ?? null, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index 34aae1239c3bc2..96daaf90e2a133 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -118,6 +118,66 @@ suite('AutomationService', () => { assert.strictEqual(a.enabled, true); }); + test('round-trips the provider session template through persistence', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); + const sessionTemplate = { + modelId: 'agent-host-copilotcli:auto', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }; + + await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate, + }); + const restored = teardown.add(createAutomationService(storage, new NullLogService(), NullTelemetryService)); + const persisted = JSON.parse(storage.get('chat.automations.ledger', StorageScope.APPLICATION)!); + + assert.deepStrictEqual({ + schemaVersion: persisted.schemaVersion, + template: restored.automations.get()[0].sessionTemplate, + }, { + schemaVersion: 4, + template: sessionTemplate, + }); + }); + + test('folds legacy field updates into an existing session template', async () => { + const { service } = createService(); + const automation = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate: { + modelId: 'old-model', + config: { mode: 'autopilot', autoApprove: 'assisted', providerOption: true }, + }, + modelId: 'old-model', + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + const updated = await service.updateAutomation(automation.id, { + modelId: 'new-model', + mode: 'agent', + permissionLevel: 'autoApprove', + }); + + assert.deepStrictEqual(updated.sessionTemplate, { + modelId: 'new-model', + config: { mode: 'autopilot', autoApprove: 'autoApprove', providerOption: true }, + }); + }); + test('createAutomation with manual schedule leaves nextRunAt undefined', async () => { const { service } = createService(); const a = await service.createAutomation({ @@ -208,16 +268,18 @@ suite('AutomationService', () => { assert.strictEqual(b.name, 'B'); }); - test('updateAutomation can clear modelId/mode/permissionLevel by passing null but keeps folderUri', async () => { + test('updateAutomation can clear the session template and legacy fields by passing null but keeps folderUri', async () => { const { service } = createService(); const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget(), + sessionTemplate: { config: { mode: 'autopilot' } }, modelId: 'gpt-4', mode: 'agent', permissionLevel: 'autopilot', }); - const b = await service.updateAutomation(a.id, { modelId: null, mode: null, permissionLevel: null }); + const b = await service.updateAutomation(a.id, { sessionTemplate: null, modelId: null, mode: null, permissionLevel: null }); + assert.strictEqual(b.sessionTemplate, undefined); assert.strictEqual(b.modelId, undefined); assert.strictEqual(b.mode, undefined); assert.strictEqual(b.permissionLevel, undefined); @@ -232,6 +294,36 @@ suite('AutomationService', () => { assert.strictEqual(b.target.kind === 'workspace' ? b.target.folderUri.toString() : undefined, other.toString()); }); + test('updateAutomation clears provider configuration when the target authority changes', async () => { + const { service } = createService(); + const a = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: dailySchedule(), + target: { ...workspaceTarget(), providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + sessionTemplate: { config: { mode: 'autopilot', autoApprove: 'assisted' } }, + modelId: 'gpt-4', + mode: 'autopilot', + permissionLevel: 'assisted', + }); + + const b = await service.updateAutomation(a.id, { + target: { ...workspaceTarget(), providerId: 'local-agent-host', sessionTypeId: 'claude' }, + }); + + assert.deepStrictEqual({ + sessionTemplate: b.sessionTemplate, + modelId: b.modelId, + mode: b.mode, + permissionLevel: b.permissionLevel, + }, { + sessionTemplate: undefined, + modelId: undefined, + mode: undefined, + permissionLevel: 'default', + }); + }); + test('updateAutomation rejects incomplete workspace-less targets', async () => { const { service } = createService(); const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), target: workspaceTarget() }); @@ -668,7 +760,7 @@ suite('AutomationService', () => { test('successful CAS accepts a restored lower revision without accepting stale notifications', async () => { const storage = teardown.add(new InMemoryStorageService()); storage.store('chat.automations.ledger', JSON.stringify({ - schemaVersion: 3, + schemaVersion: 4, revision: 40, automations: [serializeLedgerAutomation('newer', 'Before restore')], runs: [], @@ -796,7 +888,7 @@ suite('AutomationService', () => { runIds: persisted.runs.map((run: { id: string }) => run.id), canCompleteMigration: service.canCompleteMigration(), }, { - schemaVersion: 3, + schemaVersion: 4, automationIds: ['keep', 'quick'], keepName: 'Updated', runIds: ['r-keep', 'r-quick'], @@ -804,7 +896,7 @@ suite('AutomationService', () => { }); }); - test('migrates schema v2 flat targets to schema v3 target unions', async () => { + test('migrates schema v2 flat targets to the current target union', async () => { const storage = teardown.add(new InMemoryStorageService()); const common = { prompt: 'p', @@ -832,7 +924,7 @@ suite('AutomationService', () => { await service.updateAutomation('workspace', { name: 'Updated' }); const migrated = JSON.parse(storage.get('chat.automations.ledger', -1)!); - assert.strictEqual(migrated.schemaVersion, 3); + assert.strictEqual(migrated.schemaVersion, 4); }); test('round-trips a folderUri through persistence', async () => { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index 2d3067a7885680..db3573e3f6a538 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -230,6 +230,7 @@ function editableAutomationKey(automation: IAutomationDescriptor): string { target: automation.target.kind === 'workspace' ? { ...automation.target, folderUri: automation.target.folderUri.toString() } : automation.target, + sessionTemplate: automation.sessionTemplate, modelId: automation.modelId, mode: automation.mode, permissionLevel: automation.permissionLevel, @@ -239,7 +240,7 @@ function editableAutomationKey(automation: IAutomationDescriptor): string { function serializeAutomationLedger(automations: readonly IAutomationDescriptor[], revision = 1): string { return JSON.stringify({ - schemaVersion: 3, + schemaVersion: 4, revision, automations: automations.map(automation => ({ ...automation, diff --git a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts index 7d39b2550ff979..2d0ec7065aa580 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -560,7 +560,7 @@ suite('ProviderAutomationService', () => { }, acknowledgedAutomationIds: ['automation-1'], runIds: ['run-1'], - legacy: { schemaVersion: 3, revision: 2, automations: [], runs: [] }, + legacy: { schemaVersion: 4, revision: 2, automations: [], runs: [] }, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 1ac0a906a0c3bf..70f730bfba0483 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -12,9 +12,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; -import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, migrateLegacyAutomationSessionConfig, supportsLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; -import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationDefinition, type AutomationEntry, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -22,7 +22,7 @@ import { AUTOMATION_CATALOG_URI, isAhpAutomationCatalogChannel, ROOT_STATE_URI, import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule, IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { AutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -204,6 +204,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro prompt: options.prompt, schedule: options.schedule, target: options.target, + sessionTemplate: options.sessionTemplate, modelId: options.modelId, mode: options.mode, permissionLevel: options.permissionLevel, @@ -664,6 +665,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return undefined; } const config = state.definition.session.config; + const modelId = this._projectModelId(state.definition.session.model?.id, state.definition.session.provider); const newestRun = state.runs[0]; return { id: automationId(state.resource), @@ -671,7 +673,8 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro prompt: state.definition.message.text, schedule: projectSchedule(state.definition.triggers), target, - modelId: this._projectModelId(state.definition.session.model?.id, state.definition.session.provider), + sessionTemplate: projectAutomationSessionTemplate(state.definition, modelId), + modelId, mode: readString(config?.[SessionConfigKey.Mode]), permissionLevel: readString(config?.[SessionConfigKey.AutoApprove]), enabled: state.definition.enabled, @@ -830,14 +833,18 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean): AutomationDefinition { - const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(descriptor.modelId); + const sessionTemplate = descriptor.sessionTemplate; + const modelId = sessionTemplate ? sessionTemplate.modelId : descriptor.modelId; + const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(modelId); const existingSession = existing && existing.session.provider === provider ? existing.session : undefined; - const config = applyLegacyAutomationDescriptorConfig( - { ...existingSession?.config }, - provider, - descriptor.mode, - descriptor.permissionLevel, - ); + const config = sessionTemplate + ? { ...sessionTemplate.config } + : applyLegacyAutomationSessionConfig( + provider, + existingSession?.config, + descriptor.mode, + descriptor.permissionLevel, + ); if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); @@ -859,8 +866,8 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro message: { text: descriptor.prompt, origin: { kind: MessageKind.Automation } }, session: { provider, - model: descriptor.modelId ? { id: this._toHostModelId(descriptor.modelId, provider) } : undefined, - agent: existingSession?.agent, + model: modelId ? { id: this._toHostModelId(modelId, provider) } : undefined, + agent: sessionTemplate ? sessionTemplate.agent : existingSession?.agent, workingDirectories: descriptor.target.kind === 'workspace' ? [(this._boundaryMapper?.toHost(descriptor.target.folderUri) ?? descriptor.target.folderUri).toString()] : undefined, @@ -911,17 +918,27 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro const target = patch.target ?? current.target; const targetAuthorityChanged = patch.target !== undefined && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); + const modelId = patch.modelId === null + ? undefined + : patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId); + const mode = patch.mode === null ? undefined : patch.mode ?? (targetAuthorityChanged ? undefined : current.mode); + const permissionLevel = patch.permissionLevel === null ? undefined : patch.permissionLevel ?? (targetAuthorityChanged ? undefined : current.permissionLevel); + const provider = target.sessionTypeId ?? this._providerFromModelId(modelId); + const sessionTemplate = patch.sessionTemplate === null + ? undefined + : patch.sessionTemplate ?? (targetAuthorityChanged + ? undefined + : synchronizeAutomationSessionTemplate(current.sessionTemplate, provider, modelId, mode, permissionLevel)); return { ...current, ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.prompt !== undefined ? { prompt: patch.prompt } : {}), schedule, target, - modelId: patch.modelId === null - ? undefined - : patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId), - mode: patch.mode === null ? undefined : patch.mode ?? (targetAuthorityChanged ? undefined : current.mode), - permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? (targetAuthorityChanged ? undefined : current.permissionLevel), + sessionTemplate, + modelId, + mode, + permissionLevel, enabled, updatedAt: now.toISOString(), }; @@ -1230,20 +1247,30 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } -function applyLegacyAutomationDescriptorConfig(config: Record, provider: string | undefined, mode: string | undefined, permissionLevel: string | undefined): Record { - if (!supportsLegacyAutomationSessionConfig(provider)) { - if (permissionLevel === undefined || permissionLevel === 'default') { - delete config[SessionConfigKey.AutoApprove]; - } - return config; +function projectAutomationSessionTemplate(definition: AutomationDefinition, modelId: string | undefined): IAutomationSessionTemplate | undefined { + const config = { ...definition.session.config }; + delete config[SessionConfigKey.Isolation]; + delete config[SessionConfigKey.Branch]; + return createAutomationSessionTemplate(modelId, definition.session.agent, config); +} + +function synchronizeAutomationSessionTemplate(template: IAutomationSessionTemplate | undefined, provider: string | undefined, modelId: string | undefined, mode: string | undefined, permissionLevel: string | undefined): IAutomationSessionTemplate | undefined { + if (!template) { + return undefined; } - if (mode === undefined) { - delete config[SessionConfigKey.Mode]; - } else if (KNOWN_MODE_VALUES.has(mode)) { - config[SessionConfigKey.Mode] = mode; + const config = applyLegacyAutomationSessionConfig(provider, template.config, mode, permissionLevel); + return createAutomationSessionTemplate(modelId, template.agent, config); +} + +function createAutomationSessionTemplate(modelId: string | undefined, agent: IAutomationSessionTemplate['agent'], config: Readonly>): IAutomationSessionTemplate | undefined { + if (!modelId && !agent && Object.keys(config).length === 0) { + return undefined; } - setOptional(config, SessionConfigKey.AutoApprove, permissionLevel); - return migrateLegacyAutomationSessionConfig(provider, config); + return { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; } function setOptional(target: Record, key: string, value: unknown): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 69fea34f77261a..113721e9fb967d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -664,6 +664,102 @@ suite('AgentHostAutomationStore', () => { ); }); + test('round-trips the complete Agent Host session template', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, undefined, { + toHost: resource => resource, + fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + }, new NullLogService(), storage, NullTelemetryService, new TestAutomationStorageService(storage))); + const sessionTemplate = { + modelId: 'agent-host-copilotcli:auto', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }; + + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { + kind: 'workspace', + folderUri: URI.file('/workspace'), + providerId: 'local-agent-host', + sessionTypeId: 'copilotcli', + isolation: { kind: 'folder' }, + }, + sessionTemplate, + }); + await store.updateAutomation(automation.id, { + name: 'Review renamed changes', + modelId: 'agent-host-copilotcli:gpt-5', + mode: 'agent', + permissionLevel: 'autoApprove', + }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual({ + projected: store.getAutomation(automation.id)?.sessionTemplate, + updatedSession: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, + }, { + projected: { + modelId: 'agent-host-copilotcli:gpt-5', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'autoApprove', + providerOption: { enabled: true }, + }, + }, + updatedSession: { + provider: 'copilotcli', + model: { id: 'gpt-5' }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + workingDirectories: ['file:///workspace'], + config: { + mode: 'plan', + autoApprove: 'autoApprove', + providerOption: { enabled: true }, + isolation: 'folder', + }, + }, + }); + }); + + test('applies the first flat permission update when the projected session template is empty', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + const automation = await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + + await store.updateAutomation(automation.id, { permissionLevel: 'autoApprove' }); + + const update = connection.dispatched.at(-1)?.action; + assert.deepStrictEqual( + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, + { autoApprove: 'autoApprove' }, + ); + }); + test('switches authority only after host migration completion is verified', async () => { const connection = new TestAutomationConnection(false); disposables.add(connection); diff --git a/src/vs/workbench/contrib/chat/common/automations/automation.ts b/src/vs/workbench/contrib/chat/common/automations/automation.ts index c054b68dc44805..9ac0b7ac6e123e 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automation.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automation.ts @@ -52,6 +52,16 @@ export type AutomationTarget = readonly sessionTypeId: string; }; +/** Provider-owned values used to create each Automation run session. */ +export interface IAutomationSessionTemplate { + /** Optional language model identifier. */ + readonly modelId?: string; + /** Optional custom agent selection. */ + readonly agent?: { readonly uri: string }; + /** Provider-owned session configuration values. */ + readonly config?: Readonly>; +} + /** * A single scheduled automation. Identity is the immutable `id`; everything * else may be edited by the user. @@ -65,6 +75,9 @@ export interface IAutomationDescriptor { /** Explicit workspace-backed or workspace-less execution target. */ readonly target: AutomationTarget; + /** Complete provider-owned session template. */ + readonly sessionTemplate?: IAutomationSessionTemplate; + /** Optional language model identifier to seed the new session with. */ readonly modelId?: string; diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index f92f21f536e57c..c6e8c301c140b6 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { IObservable } from '../../../../../base/common/observable.js'; +import { stableStringify } from '../../../../../base/common/objects.js'; import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatPermissionLevel } from '../constants.js'; -import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger, IAutomationSchedule, AutomationTarget } from './automation.js'; +import { IAutomationDescriptor, IAutomationRun, AutomationRunTrigger, IAutomationSchedule, IAutomationSessionTemplate, AutomationTarget } from './automation.js'; export const IAutomationService = createDecorator('automationService'); export const ConfigureAutomationToolReferenceName = 'configureAutomation'; @@ -39,6 +40,7 @@ export interface ICreateAutomationOptions { readonly prompt: string; readonly schedule: IAutomationSchedule; readonly target: AutomationTarget; + readonly sessionTemplate?: IAutomationSessionTemplate; readonly modelId?: string; readonly mode?: string; readonly permissionLevel?: string; @@ -54,6 +56,7 @@ export interface IUpdateAutomationOptions { readonly prompt?: string; readonly schedule?: IAutomationSchedule; readonly target?: AutomationTarget; + readonly sessionTemplate?: IAutomationSessionTemplate | null; readonly modelId?: string | null; readonly mode?: string | null; readonly permissionLevel?: string | null; @@ -89,7 +92,7 @@ export function serializeAutomationEditableState(automation: IAutomationDescript ? { kind: automation.target.isolation.kind, branch: automation.target.isolation.branch } : { kind: automation.target.isolation.kind }, }; - return JSON.stringify({ + return stableStringify({ name: automation.name, prompt: automation.prompt, schedule: { @@ -99,6 +102,7 @@ export function serializeAutomationEditableState(automation: IAutomationDescript scheduleDay: automation.schedule.scheduleDay, }, target, + sessionTemplate: automation.sessionTemplate, modelId: automation.modelId, mode: automation.mode, permissionLevel: automation.permissionLevel ?? ChatPermissionLevel.Default, From 595aae22b1a4e18d5a18f1ca9768da39eca49fee Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 00:57:37 +0200 Subject: [PATCH 04/15] automations: refactor: restore provider draft configuration Let Automation drafts restore and capture provider-owned model, agent, and resolved configuration through the Sessions provider contract. Keep normal New Session defaults isolated, reject replaced draft snapshots, and exclude transient or target-owned values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../automations/browser/automationDialog.ts | 53 +++++++++++++++--- .../browser/automationDialogService.ts | 2 +- .../test/browser/automationDialog.test.ts | 47 ++++++++++++++-- .../browser/baseAgentHostSessionsProvider.ts | 54 +++++++++++++++---- .../localAgentHostSessionsProvider.test.ts | 39 ++++++++++++++ .../browser/copilotChatSessionsProvider.ts | 4 +- .../browser/sessionsManagementService.ts | 16 +++--- .../sessions/common/sessionsManagement.ts | 6 +++ .../sessions/common/sessionsProvider.ts | 9 +++- .../test/browser/sessionNavigation.test.ts | 1 + .../browser/sessionsManagementService.test.ts | 34 ++++++++++-- 11 files changed, 227 insertions(+), 38 deletions(-) diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 18d8feeec04428..2cea54a46fce0d 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -44,7 +44,7 @@ import { MobileSessionTypePicker } from '../../chat/browser/mobile/mobileSession import { isMobilePickerSheetTarget } from '../../../browser/parts/mobile/mobilePickerSheet.js'; import { ISession, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js'; import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { AutomationInterval } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationInterval, AutomationTarget, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { DAYS_OF_WEEK } from '../../../../workbench/contrib/chat/common/automations/schedule.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; @@ -208,6 +208,7 @@ interface IRenderFormHandle { readonly getMode: () => string | undefined; readonly getPermissionLevel: () => string | undefined; readonly getModelId: () => string | undefined; + readonly getSessionTemplate: () => Promise; readonly getBranch: () => string | undefined; readonly waitForAutomationSessionSync: () => Promise; readonly getFocusableElements: () => readonly HTMLElement[]; @@ -215,12 +216,12 @@ interface IRenderFormHandle { } export type AutomationSessionDraftTarget = - | { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string } - | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string }; + | { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string; readonly sessionTemplate?: IAutomationSessionTemplate } + | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string; readonly sessionTemplate?: IAutomationSessionTemplate }; type AutomationSessionDraftService = Pick< ISessionsManagementService, - 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' + 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionTemplate' >; export class AutomationSessionDraftSynchronizer extends Disposable { @@ -254,6 +255,11 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } while (pendingSync !== this.syncPromise); } + async getSessionTemplate(): Promise { + await this.waitForSync(); + return this.session ? this.sessionsManagementService.getAutomationSessionTemplate(this.session) : undefined; + } + private scheduleSync(): void { if (this.syncScheduled) { return; @@ -291,10 +297,12 @@ export class AutomationSessionDraftSynchronizer extends Disposable { ? this.sessionsManagementService.createAutomationQuickChat({ providerId: target.providerId, sessionTypeId: target.sessionTypeId, + sessionTemplate: target.sessionTemplate, }) : this.sessionsManagementService.createAutomationSession(target.folderUri, { providerId: target.providerId, sessionTypeId: target.sessionTypeId, + sessionTemplate: target.sessionTemplate, }); this.appliedTarget = target; } catch (error) { @@ -311,7 +319,8 @@ export class AutomationSessionDraftSynchronizer extends Disposable { || this.sessionsManagementService.automationSession.get()?.sessionId !== this.session.sessionId || this.appliedTarget.kind !== target.kind || this.appliedTarget.providerId !== target.providerId - || this.appliedTarget.sessionTypeId !== target.sessionTypeId) { + || this.appliedTarget.sessionTypeId !== target.sessionTypeId + || this.appliedTarget.sessionTemplate !== target.sessionTemplate) { return false; } return target.kind === 'quickChat' @@ -829,6 +838,8 @@ export function renderForm( sessionsManagementService: ISessionsManagementService, workspaceTrustRequestService: IWorkspaceTrustRequestService, initialPrompt: string, + initialTarget: AutomationTarget | undefined, + initialSessionTemplate: IAutomationSessionTemplate | undefined, initialMode: string | undefined, initialPermissionLevel: string | undefined, initialModelId: string | undefined, @@ -962,6 +973,22 @@ export function renderForm( (folderUri, preferredProviderId) => canSelectAutomationWorkspace(folderUri, preferredProviderId, sessionsManagementService, workspaceTrustRequestService), error => logService.error('[AutomationDialog] Failed to synchronize the automation session draft.', error), )); + let resolvedInitialProviderId = initialTarget?.providerId; + let resolvedInitialSessionTypeId = initialTarget?.sessionTypeId; + const getInitialSessionTemplate = (folderUri: URI | undefined, providerId: string | undefined, sessionTypeId: string, isQuickChat: boolean) => { + if (!initialTarget || !initialSessionTemplate || initialTarget.kind !== (isQuickChat ? 'quickChat' : 'workspace')) { + return undefined; + } + if (initialTarget.kind === 'workspace' && (!folderUri || !isEqual(initialTarget.folderUri, folderUri))) { + return undefined; + } + resolvedInitialProviderId ??= providerId; + resolvedInitialSessionTypeId ??= sessionTypeId; + if (resolvedInitialProviderId !== providerId || resolvedInitialSessionTypeId !== sessionTypeId) { + return undefined; + } + return initialSessionTemplate; + }; const updateAutomationSessionTarget = () => { const folderUri = isolationModel.folderUriObs.get(); const pick = sessionTypePicker.selectedPick; @@ -973,10 +1000,21 @@ export function renderForm( if (isQuickChat) { const providerId = pick.providerId; if (providerId) { - automationSessionDraftSynchronizer.update({ kind: 'quickChat', providerId, sessionTypeId: pick.sessionTypeId }); + automationSessionDraftSynchronizer.update({ + kind: 'quickChat', + providerId, + sessionTypeId: pick.sessionTypeId, + sessionTemplate: getInitialSessionTemplate(undefined, providerId, pick.sessionTypeId, true), + }); } } else if (folderUri) { - automationSessionDraftSynchronizer.update({ kind: 'workspace', folderUri, providerId: pick.providerId, sessionTypeId: pick.sessionTypeId }); + automationSessionDraftSynchronizer.update({ + kind: 'workspace', + folderUri, + providerId: pick.providerId, + sessionTypeId: pick.sessionTypeId, + sessionTemplate: getInitialSessionTemplate(folderUri, pick.providerId, pick.sessionTypeId, false), + }); } }; disposables.add(sessionTypePicker.onDidChangeSelectedPick(() => { @@ -1251,6 +1289,7 @@ export function renderForm( getMode: () => chatInput.currentModeObs.get().id, getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(), getModelId: () => chatInput.selectedLanguageModel.get()?.identifier, + getSessionTemplate: () => automationSessionDraftSynchronizer.getSessionTemplate(), getBranch: () => isolationModel.persistedBranch, waitForAutomationSessionSync: () => { updateAutomationSessionTarget(); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index bbbef88adbe365..5e7c755621e4e9 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -168,7 +168,7 @@ export class AutomationDialogService implements IAutomationDialogService { const formPane = DOM.append(container, $('.automation-form-pane')); const form = DOM.append(formPane, $('.automation-form')); - const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.languageModelsService, this.layoutService, this.logService, this.productService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initial?.mode, initial?.permissionLevel, initial?.modelId); + const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.languageModelsService, this.layoutService, this.logService, this.productService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initialTarget, initial?.sessionTemplate, initial?.mode, initial?.permissionLevel, initial?.modelId); getPrompt = handle.getPrompt; getMode = handle.getMode; getPermissionLevel = handle.getPermissionLevel; diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index f074e1abadddae..d336c0e2912f4c 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -31,6 +31,7 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { createWorkbenchDialogOptions } from '../../../../../workbench/browser/parts/dialogs/dialog.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { GitRefType, IGitRepository, IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; @@ -148,10 +149,11 @@ function createWorkspace(requiresWorkspaceTrust: boolean): ISessionWorkspace { function createAutomationDraftService() { const automationSession = observableValue('automationSession', undefined); - const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string }> = []; + const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string; sessionTemplate?: IAutomationSessionTemplate }> = []; const discarded: string[] = []; + const sessionTemplates = new Map(); let nextId = 1; - const createDraft = (kind: 'workspace' | 'quickChat', providerId: string | undefined, sessionTypeId: string, folderUri?: URI): ISession => { + const createDraft = (kind: 'workspace' | 'quickChat', providerId: string | undefined, sessionTypeId: string, folderUri?: URI, sessionTemplate?: IAutomationSessionTemplate): ISession => { const previous = automationSession.get(); if (previous) { discarded.push(previous.sessionId); @@ -161,14 +163,16 @@ function createAutomationDraftService() { providerId: providerId ?? 'resolved-provider', sessionType: sessionTypeId, }); - created.push({ kind, providerId, sessionTypeId, folderUri: folderUri?.toString() }); + created.push({ kind, providerId, sessionTypeId, folderUri: folderUri?.toString(), ...(sessionTemplate ? { sessionTemplate } : {}) }); + sessionTemplates.set(session.sessionId, sessionTemplate); automationSession.set(session, undefined); return session; }; const service = upcastPartial({ automationSession, - createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri), - createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default'), + createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri, options?.sessionTemplate), + createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default', undefined, options?.sessionTemplate), + getAutomationSessionTemplate: async session => sessionTemplates.get(session.sessionId), discardAutomationSession: session => { const current = automationSession.get(); if (!current || (session && session.sessionId !== current.sessionId)) { @@ -221,6 +225,39 @@ suite('Automation session draft synchronization', () => { }); }); + test('restores and captures the target session template', async () => { + const { service, created } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => { })); + const sessionTemplate = { + modelId: 'model', + agent: { uri: 'file:///agent.md' }, + config: { mode: 'plan' }, + }; + + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionTemplate, + }); + const captured = await synchronizer.getSessionTemplate(); + + assert.deepStrictEqual({ + created, + captured, + }, { + created: [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///workspace', + sessionTemplate, + }], + captured: sessionTemplate, + }); + }); + test('ignores stale workspace validation', async () => { const { service, created } = createAutomationDraftService(); const firstWorkspaceValidation = new DeferredPromise(); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 1944b6b622f501..cb343066c893de 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,7 +29,7 @@ import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/an import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; -import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { KNOWN_MODE_VALUES, omitTransientSessionConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata, type IAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -50,6 +50,7 @@ import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browse import { ChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatSendRequestOptions, IChatService, type IChatModelReference } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionFileChange2, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, getChatPermissionLevelFromDefaultConfiguration, isChatPermissionLevel, type IChatDefaultConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; import { isAutoApprovePolicyRestricted, normalizeSessionConfigValue } from '../../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; @@ -1958,6 +1959,8 @@ interface INewSessionConstructionContext { * present from the very first `resolveConfig`/`createSession`. */ readonly initialConfigValues?: Record; + /** Provider-owned Automation values restored before the first configuration resolution. */ + readonly initialSessionTemplate?: IAutomationSessionTemplate; /** * Optional property schemas to seed into the new session's config before its * first {@link NewSession.resolveConfig} round-trip. Carried over from the @@ -2145,11 +2148,11 @@ class NewSession extends Disposable { this._workspace = observableValue(this, ctx.workspace); const changes = observableValueOpts({ owner: this, equalsFn: sessionFileChangesEqual }, []); const checkpoints = observableValue(this, undefined); - this._selectedModelId = undefined; - this._selectedAgent = undefined; + this._selectedModelId = ctx.initialSessionTemplate?.modelId; + this._selectedAgent = ctx.initialSessionTemplate?.agent ? { uri: ctx.initialSessionTemplate.agent.uri, name: '' } : undefined; this._modelId = observableValue(this, this._selectedModelId); - this._modelSource = observableValue(this, undefined); - const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + this._modelSource = observableValue(this, this._selectedModelId ? ChatModelSource.Chosen : undefined); + const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, this._selectedAgent ? { id: this._selectedAgent.uri, kind: AGENT_MODE_KIND } : undefined); this._mode = mode; const isArchived = observableValue(this, false); const isRead = observableValue(this, true); @@ -3516,7 +3519,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } - return this._createDraftSession(sessionType, workspace, false, options?.metadata); + return this._createDraftSession(sessionType, workspace, false, options?.metadata, options?.sessionTemplate); } startNewSessionRequest(sessionId: string, activity?: string): IDisposable { @@ -3527,7 +3530,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return newSession.startRequest(activity); } - createQuickChat(sessionTypeId: string): ISession { + createQuickChat(sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId); if (!sessionType) { throw new Error(this._noAgentsErrorMessage()); @@ -3539,7 +3542,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // workspace-less: no `resolveWorkspace`, no `workingDirectory`. The // agent host runs it in a throwaway scratch cwd and tags it via the // `quickChat` create flag. - return this._createDraftSession(sessionType, undefined, true); + return this._createDraftSession(sessionType, undefined, true, options?.metadata, options?.sessionTemplate); } /** @@ -3547,7 +3550,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * given session type. Shared by {@link createNewSession} (workspace-bound) * and {@link createQuickChat} (workspace-less, `quickChat === true`). */ - private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record): ISession { + private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record, initialSessionTemplate?: IAutomationSessionTemplate): ISession { // Tear-down of superseded drafts is handled by the management layer // (it calls `deleteNewSession` on the previous pending session). Each // new session is tracked independently in `_newSessions` so several can @@ -3568,7 +3571,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement backendSessionScheme: this._backendSessionScheme(sessionType.id), authenticationPending: this.authenticationPending, logService: this._logService, - initialConfigValues: this._initialNewSessionConfig(workspace), + initialConfigValues: initialSessionTemplate ? { ...initialSessionTemplate.config } : this._initialNewSessionConfig(workspace), + initialSessionTemplate, initialConfigSchema: this._seededConfigSchema(), initialMetadata, instantiationService: this._instantiationService, @@ -3833,6 +3837,36 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Dynamic session config ---------------------------------------------- + async getAutomationSessionTemplate(sessionId: string): Promise { + const newSession = this._getNewSession(sessionId); + if (!newSession) { + return undefined; + } + await newSession.waitForConfigResolution(); + if (this._getNewSession(sessionId) !== newSession) { + return undefined; + } + const config = omitTransientSessionConfigValues({ ...newSession.getConfigValues() }); + delete config[SessionConfigKey.Isolation]; + delete config[SessionConfigKey.Branch]; + delete config[SessionConfigKey.WorktreeBranchPrefix]; + delete config[SessionConfigKey.WorktreeIncludeFiles]; + delete config[SessionConfigKey.WorktreeBranchTrack]; + delete config[SessionConfigKey.WorktreeCreateNewBranch]; + delete config[SessionConfigKey.AgentMerge]; + delete config[SessionConfigKey.AgentMergeController]; + const modelId = newSession.getSelectedModelId(); + const agent = newSession.getSelectedAgent(); + if (!modelId && !agent && Object.keys(config).length === 0) { + return undefined; + } + return { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + } + getSessionConfig(sessionId: string): ResolveSessionConfigResult | undefined { // New-session config wins (during pre-creation flow). Otherwise lazily // subscribe to the session's state so the running picker can seed its 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 046178e6b0006f..c6e98cfe9b4745 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 @@ -4466,6 +4466,45 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('createNewSession restores and captures an Automation session template', async () => { + const sessionTemplate = { + modelId: 'agent-host-copilotcli:auto', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + }, + }; + agentHost.resolveSessionConfigResult = { + schema: { type: 'object', properties: {} }, + values: { + ...sessionTemplate.config, + [SessionConfigKey.WorktreeBranchPrefix]: 'stale-prefix/', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + }, + }; + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { sessionTemplate }, + ); + + const captured = await provider.getAutomationSessionTemplate(session.sessionId); + + assert.deepStrictEqual({ + captured, + initialConfig: agentHost.resolveSessionConfigRequests.at(-1)?.config, + modelId: session.modelId.get(), + agentUri: session.mode.get()?.id, + }, { + captured: sessionTemplate, + initialConfig: sessionTemplate.config, + modelId: sessionTemplate.modelId, + agentUri: sessionTemplate.agent.uri, + }); + }); + test('createNewSession drops an invalid remembered mode instead of forwarding it', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify({ diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 4357c9a561d250..92ff799e942920 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -27,7 +27,7 @@ import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGrou import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { ChatMode, IChatMode, IChatModeService, isBuiltinChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; @@ -1677,7 +1677,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSession(session); } - createQuickChat(_sessionTypeId: string): ISession { + createQuickChat(_sessionTypeId: string, _options?: ISessionsProviderCreateSessionOptions): ISession { // This provider is workspace-bound and does not advertise // `supportsQuickChats`; callers must gate on that capability. throw new Error('CopilotChatSessionsProvider does not support quick chats'); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 1c0de89b12b0bb..d100c3e59dec02 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -495,7 +495,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousNewSession = this._newSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: options?.metadata }); + const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); // Providers no longer dispose the previous new session implicitly, so // dispose the one this composer just replaced. Use its own provider @@ -514,7 +514,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId); + const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -582,7 +582,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousNewSession = this._newSession.get(); - const session = provider.createQuickChat(sessionTypeId); + const session = provider.createQuickChat(sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); this._newSession.set(session, undefined); this.storageService.store(LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY, sessionTypeId, StorageScope.PROFILE, StorageTarget.USER); @@ -598,7 +598,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationQuickChat(options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createQuickChat(sessionTypeId); + const session = provider.createQuickChat(sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -606,6 +606,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } + async getAutomationSessionTemplate(session: ISession) { + return this._getProvider(session)?.getAutomationSessionTemplate?.(session.sessionId); + } + async createNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise { const provider = this._getProvider(session); if (!provider) { @@ -850,7 +854,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa throw new WorkspaceNotTrustedError(); } } - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: createOptions?.metadata }); + const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: createOptions?.metadata, sessionTemplate: createOptions?.sessionTemplate }); this._unlistedNewSessions.set(session.resource, session); const requestActivity = new MutableDisposable(); try { @@ -874,7 +878,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async createAndSendQuickChatRequest(options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(createOptions); - const session = provider.createQuickChat(sessionTypeId); + const session = provider.createQuickChat(sessionTypeId, { metadata: createOptions?.metadata, sessionTemplate: createOptions?.sessionTemplate }); return this._configureAndSendNewSession(provider, session, options, createOptions, false, token); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 3488b625c13ea8..3cbf28eeafc097 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -8,6 +8,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; import { IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; @@ -93,6 +94,8 @@ export interface ICreateNewSessionOptions { * does not implement the setter. */ readonly permissionLevel?: string; + /** Provider-owned session values restored into an Automation draft. */ + readonly sessionTemplate?: IAutomationSessionTemplate; /** * Optional worktree isolation mode (`worktree` or `workspace`) to apply * via {@link ISessionsProvider.setIsolationMode}. Skipped if the @@ -392,6 +395,9 @@ export interface ISessionsManagementService { */ discardAutomationSession(session?: ISession): void; + /** Capture the provider-owned values currently selected on an Automation draft. */ + getAutomationSessionTemplate(session: ISession): Promise; + /** * Create a new session for the given folder. * diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 401ec2844879d2..035cbd9de4b1c5 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -11,7 +11,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { ModelIdentifierResolution } from '../../../../workbench/contrib/chat/common/modelSelection.js'; -import { IAutomationDescriptor, IAutomationRun } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationDescriptor, IAutomationRun, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationStore } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatModelSource, IChat, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection } from './session.js'; @@ -51,6 +51,8 @@ export interface ISendRequestOptions { export interface ISessionsProviderCreateSessionOptions { /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; + /** Provider-owned values restored into the draft before its first configuration resolution. */ + readonly sessionTemplate?: IAutomationSessionTemplate; } /** Programmatic worktree settings applied together before a new session starts. */ @@ -296,7 +298,7 @@ export interface ISessionsProvider { * support quick chats must throw. * @param sessionTypeId The ID of the session type to create. */ - createQuickChat(sessionTypeId: string): ISession; + createQuickChat(sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession; /** * Delete a new (untitled, not-yet-sent) session previously created via @@ -307,6 +309,9 @@ export interface ISessionsProvider { */ deleteNewSession(sessionId: string): void; + /** Capture the provider-owned values currently selected on an Automation draft. */ + getAutomationSessionTemplate?(sessionId: string): Promise; + /** * Get the session types supported for a given workspace URI. * @param workspaceUri The URI of the workspace to get session types for. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 8e7c0ea22be2eb..492176ccd7ac3a 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -216,6 +216,7 @@ class MockSessionStore implements ISessionsManagementService { createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } + getAutomationSessionTemplate(): Promise { return Promise.resolve(undefined); } createQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } forkChatInSession(_session: ISession, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 1ee026a2fe4dba..6b509a7277afc7 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -36,6 +36,7 @@ import { PreferredGroup } from '../../../../../workbench/services/editor/common/ import { nullExtensionDescription } from '../../../../../workbench/services/extensions/common/extensions.js'; import { SessionTypeAuthRequirement, ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection, SessionStatus } from '../../common/session.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ISessionChangeEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { ISessionsManagementService, ICreateNewSessionOptions, inheritableSessionTarget, ISendRequestSentEvent, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; @@ -2925,7 +2926,7 @@ suite('SessionsManagementService', () => { }); }); - test('automation draft lifecycle is isolated from the new-session draft', () => { + test('automation draft lifecycle and session template are isolated from the new-session draft', async () => { const drafts = [ stubSession({ sessionId: 'automation-workspace', providerId: 'test' }), stubSession({ sessionId: 'new-session', providerId: 'test' }), @@ -2933,6 +2934,12 @@ suite('SessionsManagementService', () => { stubSession({ sessionId: 'automation-replacement', providerId: 'test' }), ]; const deleted: string[] = []; + const createOptions: Array = []; + const sessionTemplate: IAutomationSessionTemplate = { + modelId: 'model', + agent: { uri: 'file:///agent.md' }, + config: { mode: 'plan' }, + }; let createIndex = 0; const provider = new class extends TestSessionsProvider { override readonly supportsQuickChats = true; @@ -2946,16 +2953,24 @@ suite('SessionsManagementService', () => { isVirtualWorkspace: false, }; } - override createNewSession(): ISession { return drafts[createIndex++]; } - override createQuickChat(): ISession { return drafts[createIndex++]; } + override createNewSession(_folderUri: URI, _sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { + createOptions.push(options); + return drafts[createIndex++]; + } + override createQuickChat(_sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { + createOptions.push(options); + return drafts[createIndex++]; + } + override async getAutomationSessionTemplate(): Promise { return sessionTemplate; } override deleteNewSession(sessionId: string): void { deleted.push(sessionId); } }(drafts[0]); const { service } = createSessionsManagementService(drafts[0], disposables, provider); const folderUri = URI.parse('test:///folder'); - const firstAutomationSession = service.createAutomationSession(folderUri); + const firstAutomationSession = service.createAutomationSession(folderUri, { sessionTemplate }); + const capturedTemplate = await service.getAutomationSessionTemplate(firstAutomationSession); service.createNewSession(folderUri); - service.createAutomationQuickChat(); + service.createAutomationQuickChat({ sessionTemplate }); service.discardAutomationSession(firstAutomationSession); service.createAutomationSession(folderUri); service.discardAutomationSession(); @@ -2963,10 +2978,19 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual({ newSession: service.newSession.get()?.sessionId, automationSession: service.automationSession.get()?.sessionId, + capturedTemplate, + createOptions, deleted, }, { newSession: 'new-session', automationSession: undefined, + capturedTemplate: sessionTemplate, + createOptions: [ + { metadata: undefined, sessionTemplate }, + { metadata: undefined, sessionTemplate: undefined }, + { metadata: undefined, sessionTemplate }, + { metadata: undefined, sessionTemplate: undefined }, + ], deleted: ['automation-workspace', 'automation-quick-chat', 'automation-replacement'], }); }); From 992bffeaa6cd47ecd5788fafd4881f3da38908b3 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 02:58:03 +0200 Subject: [PATCH 05/15] automations: refactor: reuse provider session controls Drive Automation configuration through a scoped session draft and the same provider-owned pickers as New Session. Capture complete provider state for Agent Host and legacy Copilot paths while preserving unavailable, opaque, removed, and policy-clamped preferences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- src/vs/platform/actions/common/actions.ts | 1 + .../automations/browser/automationDialog.ts | 276 +++++++++--------- .../browser/automationDialogService.ts | 44 +-- .../browser/media/automationDialog.css | 80 +++-- .../test/browser/automationDialog.test.ts | 97 +++--- .../contrib/chat/browser/newChatInput.ts | 17 +- .../chat/browser/newSessionConfigToolbars.ts | 31 ++ .../agentHost/browser/agentHostAgentPicker.ts | 12 +- .../browser/baseAgentHostSessionsProvider.ts | 96 +++++- .../mobile/mobileChatInputConfigPicker.ts | 5 +- .../localAgentHostSessionsProvider.test.ts | 74 ++++- .../browser/copilotChatSessionsProvider.ts | 151 +++++++--- .../copilotChatSessionsProvider.test.ts | 81 ++++- .../test/browser/sandboxPicker.test.ts | 1 + .../browser/sessionsManagementService.ts | 35 ++- .../sessions/common/sessionsManagement.ts | 14 +- .../sessions/common/sessionsProvider.ts | 15 +- .../test/browser/sessionNavigation.test.ts | 3 +- .../browser/sessionsManagementService.test.ts | 8 +- .../browser/actions/chatExecuteActions.ts | 1 + .../browser/widget/input/chatInputPart.ts | 47 +-- 21 files changed, 723 insertions(+), 366 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 68080fa1ac4ef3..ae974f4b58aa7e 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -273,6 +273,7 @@ export class MenuId { static readonly ChatInputStatus = new MenuId('ChatInputStatus'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput'); + static readonly AutomationsDialogInputToolbar = new MenuId('AutomationsDialogInputToolbar'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 2cea54a46fce0d..05ac7d016e807a 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -16,7 +16,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, IObservable } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, disposableObservableValue, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -34,7 +34,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { hasNativeContextMenu } from '../../../../platform/window/common/window.js'; @@ -44,20 +44,27 @@ import { MobileSessionTypePicker } from '../../chat/browser/mobile/mobileSession import { isMobilePickerSheetTarget } from '../../../browser/parts/mobile/mobilePickerSheet.js'; import { ISession, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js'; import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { AutomationInterval, AutomationTarget, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationInterval, AutomationTarget } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { DAYS_OF_WEEK } from '../../../../workbench/contrib/chat/common/automations/schedule.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { ChatAgentLocation, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatWidget, ISessionTypePickerDelegate } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPart.js'; -import { isModeConsideredBuiltIn } from '../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js'; +import { ChatInputPickerResponsiveLayout, IChatInputPickerResponsiveLayoutItem } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerResponsiveLayout.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { AutomationIsolationModel, normalizeAutomationBranchNames } from '../common/isolationGroupModel.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from '../../chat/browser/mobile/mobileWorkspacePickerSheet.js'; import { AutomationInputCompletions } from './automationInputCompletions.js'; +import { NewChatModelPickerService, INewChatModelPickerService } from '../../chat/browser/newChatModelPicker.js'; +import { createNewSessionConfigToolbar, createNewSessionControlToolbar } from '../../chat/browser/newSessionConfigToolbars.js'; +import { ISessionModelSelection, SessionModelSelection } from '../../chat/browser/sessionModelSelection.js'; +import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { VisibleSession } from '../../../services/sessions/browser/visibleSessions.js'; +import { setActiveSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; +import { SessionUsesCombinedConfigPickerContext } from '../../../common/contextkeys.js'; const $ = DOM.$; @@ -205,10 +212,7 @@ export interface IValidationState { interface IRenderFormHandle { readonly getPrompt: () => string; - readonly getMode: () => string | undefined; - readonly getPermissionLevel: () => string | undefined; - readonly getModelId: () => string | undefined; - readonly getSessionTemplate: () => Promise; + readonly getSessionConfiguration: () => Promise; readonly getBranch: () => string | undefined; readonly waitForAutomationSessionSync: () => Promise; readonly getFocusableElements: () => readonly HTMLElement[]; @@ -216,15 +220,16 @@ interface IRenderFormHandle { } export type AutomationSessionDraftTarget = - | { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string; readonly sessionTemplate?: IAutomationSessionTemplate } - | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string; readonly sessionTemplate?: IAutomationSessionTemplate }; + | { readonly kind: 'workspace'; readonly folderUri: URI; readonly providerId: string | undefined; readonly sessionTypeId: string; readonly sessionConfiguration?: IAutomationSessionConfiguration } + | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string; readonly sessionConfiguration?: IAutomationSessionConfiguration }; type AutomationSessionDraftService = Pick< ISessionsManagementService, - 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionTemplate' + 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' >; export class AutomationSessionDraftSynchronizer extends Disposable { + readonly availability = observableValue<'idle' | 'pending' | 'available' | 'unavailable'>(this, 'idle'); private requestedTarget: AutomationSessionDraftTarget | undefined; private appliedTarget: AutomationSessionDraftTarget | undefined; private session: ISession | undefined; @@ -244,6 +249,7 @@ export class AutomationSessionDraftSynchronizer extends Disposable { update(target: AutomationSessionDraftTarget | undefined): void { this.requestedTarget = target; this.generation++; + this.availability.set(target ? 'pending' : 'idle', undefined); this.scheduleSync(); } @@ -255,9 +261,22 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } while (pendingSync !== this.syncPromise); } - async getSessionTemplate(): Promise { - await this.waitForSync(); - return this.session ? this.sessionsManagementService.getAutomationSessionTemplate(this.session) : undefined; + async getSessionConfiguration(): Promise { + while (!this.disposed) { + await this.waitForSync(); + const generation = this.generation; + const session = this.session; + const target = this.requestedTarget; + if (!session) { + return target?.sessionConfiguration; + } + const captured = await this.sessionsManagementService.getAutomationSessionConfiguration(session); + if (generation !== this.generation || session !== this.session) { + continue; + } + return captured === null ? target?.sessionConfiguration : captured; + } + return undefined; } private scheduleSync(): void { @@ -278,15 +297,18 @@ export class AutomationSessionDraftSynchronizer extends Disposable { const target = this.requestedTarget; if (!target) { this.discardSession(); + this.availability.set('idle', undefined); return; } if (this.matchesAppliedTarget(target)) { + this.availability.set('available', undefined); return; } try { if (target.kind === 'workspace' && !await this.canSelectWorkspace(target.folderUri, target.providerId)) { if (generation === this.generation) { this.discardSession(); + this.availability.set('unavailable', undefined); } return; } @@ -297,17 +319,21 @@ export class AutomationSessionDraftSynchronizer extends Disposable { ? this.sessionsManagementService.createAutomationQuickChat({ providerId: target.providerId, sessionTypeId: target.sessionTypeId, - sessionTemplate: target.sessionTemplate, + sessionTemplate: target.sessionConfiguration?.sessionTemplate, + automationConfiguration: target.sessionConfiguration, }) : this.sessionsManagementService.createAutomationSession(target.folderUri, { providerId: target.providerId, sessionTypeId: target.sessionTypeId, - sessionTemplate: target.sessionTemplate, + sessionTemplate: target.sessionConfiguration?.sessionTemplate, + automationConfiguration: target.sessionConfiguration, }); this.appliedTarget = target; + this.availability.set('available', undefined); } catch (error) { if (!this.disposed && generation === this.generation) { this.discardSession(); + this.availability.set('unavailable', undefined); this.onError(error); } } @@ -320,7 +346,7 @@ export class AutomationSessionDraftSynchronizer extends Disposable { || this.appliedTarget.kind !== target.kind || this.appliedTarget.providerId !== target.providerId || this.appliedTarget.sessionTypeId !== target.sessionTypeId - || this.appliedTarget.sessionTemplate !== target.sessionTemplate) { + || this.appliedTarget.sessionConfiguration !== target.sessionConfiguration) { return false; } return target.kind === 'quickChat' @@ -343,25 +369,6 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } } -export function resolveAutomationModelIdentifier( - languageModelsService: Pick, - identifier: string, - logicalSessionType: string | undefined, - modelTarget: string | undefined, -): string { - if (!logicalSessionType || !modelTarget) { - return identifier; - } - const sourceModel = languageModelsService.lookupLanguageModel(identifier); - if (sourceModel?.targetChatSessionType !== logicalSessionType) { - return identifier; - } - return languageModelsService.getLanguageModelIds().find(candidateIdentifier => { - const candidate = languageModelsService.lookupLanguageModel(candidateIdentifier); - return candidate?.targetChatSessionType === modelTarget && candidate.id === sourceModel.id; - }) ?? identifier; -} - const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomationsHarnessChip'; const AUTOMATIONS_WORKSPACE_PICKER_ACTION_ID = 'workbench.action.chat.renderAutomationsWorkspacePicker'; const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup'; @@ -377,6 +384,29 @@ function setAutomationControlVisible(container: HTMLElement, visible: boolean): } } +function getAutomationSessionToolbarResponsiveItems(toolbar: MenuWorkbenchToolBar, compactModelPicker?: ISettableObservable): IChatInputPickerResponsiveLayoutItem[] { + const items: IChatInputPickerResponsiveLayoutItem[] = []; + for (let index = 0; index < toolbar.getItemsLength(); index++) { + const element = toolbar.getItemElement(index); + const action = toolbar.getItemAction(index); + if (!element || !action) { + continue; + } + items.push({ + element, + canShrink: true, + isCompact: () => element.classList.contains('compact-picker'), + setCompact: compact => { + element.classList.toggle('compact-picker', compact); + if (action.id === 'sessions.modelPicker') { + compactModelPicker?.set(compact, undefined); + } + }, + }); + } + return items; +} + export class AutomationIsolationGroupActionViewItem extends BaseActionViewItem { private readonly renderDisposables = this._register(new DisposableStore()); private readonly branchRepoDisposable = this._register(new MutableDisposable()); @@ -831,18 +861,13 @@ export function renderForm( contextKeyService: IContextKeyService, contextViewService: IContextViewService, configurationService: IConfigurationService, - languageModelsService: ILanguageModelsService, layoutService: IWorkbenchLayoutService, logService: ILogService, - productService: IProductService, sessionsManagementService: ISessionsManagementService, workspaceTrustRequestService: IWorkspaceTrustRequestService, initialPrompt: string, initialTarget: AutomationTarget | undefined, - initialSessionTemplate: IAutomationSessionTemplate | undefined, - initialMode: string | undefined, - initialPermissionLevel: string | undefined, - initialModelId: string | undefined, + initialSessionConfiguration: IAutomationSessionConfiguration | undefined, ): IRenderFormHandle { const nameRow = DOM.append(form, $('.automation-form-row')); DOM.append(nameRow, $('span.automation-form-label', undefined, localize('automation.form.name', "Name"))); @@ -975,8 +1000,8 @@ export function renderForm( )); let resolvedInitialProviderId = initialTarget?.providerId; let resolvedInitialSessionTypeId = initialTarget?.sessionTypeId; - const getInitialSessionTemplate = (folderUri: URI | undefined, providerId: string | undefined, sessionTypeId: string, isQuickChat: boolean) => { - if (!initialTarget || !initialSessionTemplate || initialTarget.kind !== (isQuickChat ? 'quickChat' : 'workspace')) { + const getInitialSessionConfiguration = (folderUri: URI | undefined, providerId: string | undefined, sessionTypeId: string, isQuickChat: boolean) => { + if (!initialTarget || !initialSessionConfiguration || initialTarget.kind !== (isQuickChat ? 'quickChat' : 'workspace')) { return undefined; } if (initialTarget.kind === 'workspace' && (!folderUri || !isEqual(initialTarget.folderUri, folderUri))) { @@ -987,7 +1012,7 @@ export function renderForm( if (resolvedInitialProviderId !== providerId || resolvedInitialSessionTypeId !== sessionTypeId) { return undefined; } - return initialSessionTemplate; + return initialSessionConfiguration; }; const updateAutomationSessionTarget = () => { const folderUri = isolationModel.folderUriObs.get(); @@ -1004,7 +1029,7 @@ export function renderForm( kind: 'quickChat', providerId, sessionTypeId: pick.sessionTypeId, - sessionTemplate: getInitialSessionTemplate(undefined, providerId, pick.sessionTypeId, true), + sessionConfiguration: getInitialSessionConfiguration(undefined, providerId, pick.sessionTypeId, true), }); } } else if (folderUri) { @@ -1013,7 +1038,7 @@ export function renderForm( folderUri, providerId: pick.providerId, sessionTypeId: pick.sessionTypeId, - sessionTemplate: getInitialSessionTemplate(folderUri, pick.providerId, pick.sessionTypeId, false), + sessionConfiguration: getInitialSessionConfiguration(folderUri, pick.providerId, pick.sessionTypeId, false), }); } }; @@ -1050,6 +1075,29 @@ export function renderForm( const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); const editorOverflowWidgetsDomNode = layoutService.getContainer(DOM.getWindow(promptHost)).appendChild($('.chat-editor-overflow.automation-dialog-editor-overflow.monaco-editor')); disposables.add(toDisposable(() => editorOverflowWidgetsDomNode.remove())); + const activeAutomationSession = disposables.add(disposableObservableValue(form, undefined)); + disposables.add(autorun(reader => { + const session = sessionsManagementService.automationSession.read(reader); + activeAutomationSession.set(session ? new VisibleSession(session, session.mainChat.read(reader)) : undefined, undefined); + })); + const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptRow)); + ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); + ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); + ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); + const newChatModelPickerService = new NewChatModelPickerService(); + const sessionModelSelection = disposables.add(instantiationService.createInstance(SessionModelSelection, activeAutomationSession)); + const scopedInstantiationService = disposables.add(instantiationService.createChild(new ServiceCollection( + [IContextKeyService, scopedContextKeyService], + [ISessionContext, new SessionContext(activeAutomationSession)], + [INewChatModelPickerService, newChatModelPickerService], + [ISessionModelSelection, sessionModelSelection], + ))); + const usesCombinedConfigPicker = SessionUsesCombinedConfigPickerContext.bindTo(scopedContextKeyService); + disposables.add(autorun(reader => { + const session = activeAutomationSession.read(reader); + setActiveSessionContextKeys(session, scopedContextKeyService, reader); + usesCombinedConfigPicker.set(!!session && sessionsManagementService.usesCombinedNewSessionConfigPicker(session)); + })); const chatInputStyles: IChatInputStyles = { overlayBackground: 'var(--vscode-input-background)', @@ -1064,12 +1112,12 @@ export function renderForm( renderInputToolbarBelowInput: false, renderWorkingSet: false, enableImplicitContext: false, - supportsChangingModes: true, - hideCustomChatModes: true, + supportsChangingModes: false, suppressModePreferredModel: true, suppressModelPersistence: true, menus: { executeToolbar: MenuId.AutomationsDialogInput, + inputToolbar: MenuId.AutomationsDialogInputToolbar, telemetrySource: 'automations.dialog', }, widgetViewKindTag: 'automations-dialog', @@ -1154,105 +1202,54 @@ export function renderForm( unlockFromCodingAgent: () => { }, }; - // Bind context keys required by chat input toolbar `when` clauses. - const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptHost)); - ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); - ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); - ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); - const scopedInstantiationService = disposables.add( - instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService])) - ); - const chatInput = disposables.add( scopedInstantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, chatInputOptions, chatInputStyles, false), ); chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor)); - - if (initialMode) { - const getUnfilteredInitialMode = () => { - const modes = chatInput.currentChatModesObs.get(); - return modes.findModeById(initialMode) ?? modes.findModeByName(initialMode); - }; - const isHiddenCustomInitialMode = () => { - const mode = getUnfilteredInitialMode(); - return !!mode && chatInputOptions.hideCustomChatModes && !isModeConsideredBuiltIn(mode, productService); - }; - - if (isHiddenCustomInitialMode()) { - logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}". Falling back to the default mode.`); - } else { - chatInput.setChatMode(initialMode, /* storeSelection */ false); - } - // Retry on cold-start when extension-contributed modes arrive late. - if (chatInput.currentModeObs.get().id !== initialMode && !isHiddenCustomInitialMode()) { - const baseline = chatInput.currentModeObs.get().id; - const retry = disposables.add(new MutableDisposable()); - const tryApply = () => { - if (chatInput.currentModeObs.get().id !== baseline) { - retry.clear(); - return; - } - if (isHiddenCustomInitialMode()) { - logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}" after modes updated. Falling back to the default mode.`); - retry.clear(); - return; - } - const modes = chatInput.currentChatModesObs.get(); - if (modes.findModeById(initialMode) || modes.findModeByName(initialMode)) { - chatInput.setChatMode(initialMode, /* storeSelection */ false); - if (chatInput.currentModeObs.get().id === initialMode) { - retry.clear(); - } - } - }; - retry.value = autorun(reader => { - const modes = chatInput.currentChatModesObs.read(reader); - reader.store.add(modes.onDidChange(tryApply)); - tryApply(); - }); - } - } - if (initialPermissionLevel && isChatPermissionLevel(initialPermissionLevel)) { - chatInput.setPermissionLevel(initialPermissionLevel); - } - // On edit, apply the saved model with late-arrival retry if needed. - chatInput.resetLanguageModelToDefault(); - - const resolveInitialModelId = () => initialModelId ? resolveAutomationModelIdentifier( - languageModelsService, - initialModelId, - state.sessionTypeId, - sessionTypePicker.modelTargetChatSessionType.get(), - ) : undefined; - const resolvedInitialModelId = resolveInitialModelId(); - if (resolvedInitialModelId && !chatInput.switchModelByIdentifier(resolvedInitialModelId, /* storeSelection */ false)) { - const baseline = chatInput.selectedLanguageModel.get()?.identifier; - const retry = disposables.add(new MutableDisposable()); - retry.value = Event.any( - languageModelsService.onDidChangeLanguageModels, - Event.fromObservableLight(sessionTypePicker.modelTargetChatSessionType), - )(() => { - if (chatInput.selectedLanguageModel.get()?.identifier !== baseline) { - retry.clear(); - return; - } - const modelIdentifier = resolveInitialModelId(); - if (modelIdentifier && chatInput.switchModelByIdentifier(modelIdentifier, /* storeSelection */ false)) { - retry.clear(); - } - }); - } + const sessionConfiguration = DOM.append(promptRow, $('.automation-session-configuration')); + const sessionConfigContainer = DOM.append(sessionConfiguration, $('.automation-session-config.sessions-chat-config-toolbar')); + const compactModelPicker = observableValue(sessionConfigContainer, false); + const sessionConfigToolbar = disposables.add(createNewSessionConfigToolbar(sessionConfigContainer, scopedInstantiationService, compactModelPicker)); + const sessionControlsContainer = DOM.append(sessionConfiguration, $('.automation-session-controls')); + const sessionControlsToolbar = disposables.add(createNewSessionControlToolbar(sessionControlsContainer, scopedInstantiationService)); + const sessionConfigLayout = disposables.add(new ChatInputPickerResponsiveLayout('AutomationDialog.sessionConfig', sessionConfigContainer, { + getItems: () => getAutomationSessionToolbarResponsiveItems(sessionConfigToolbar, compactModelPicker), + hasOverflow: () => sessionConfigToolbar.hasOverflow(), + relayout: () => sessionConfigToolbar.relayout(), + })); + sessionConfigLayout.layout(); + const sessionControlsLayout = disposables.add(new ChatInputPickerResponsiveLayout('AutomationDialog.sessionControls', sessionControlsContainer, { + getItems: () => getAutomationSessionToolbarResponsiveItems(sessionControlsToolbar), + hasOverflow: () => sessionControlsToolbar.hasOverflow(), + relayout: () => sessionControlsToolbar.relayout(), + })); + sessionControlsLayout.layout(); + const sessionConfigurationUnavailable = DOM.append(sessionConfiguration, $('span.automation-session-configuration-unavailable', { + role: 'status', + 'aria-atomic': 'true', + })); + disposables.add(autorun(reader => { + sessionConfigurationUnavailable.textContent = automationSessionDraftSynchronizer.availability.read(reader) === 'unavailable' + ? localize('automation.form.sessionConfigurationUnavailable', "Session configuration unavailable") + : ''; + })); disposables.add(chatInput.inputEditor.onDidChangeModelContent(() => { revalidate(); })); - chatInput.layout(580); + const layoutChatInput = () => { + const width = promptHost.getBoundingClientRect().width; + if (width > 0) { + chatInput.layout(width); + } + }; + layoutChatInput(); queueMicrotask(() => { if (!disposables.isDisposed) { - chatInput.layout(580); + layoutChatInput(); } }); @@ -1286,10 +1283,7 @@ export function renderForm( return { getPrompt: () => chatInput.inputEditor.getValue(), - getMode: () => chatInput.currentModeObs.get().id, - getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(), - getModelId: () => chatInput.selectedLanguageModel.get()?.identifier, - getSessionTemplate: () => automationSessionDraftSynchronizer.getSessionTemplate(), + getSessionConfiguration: () => automationSessionDraftSynchronizer.getSessionConfiguration(), getBranch: () => isolationModel.persistedBranch, waitForAutomationSessionSync: () => { updateAutomationSessionTarget(); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 5e7c755621e4e9..26b262e4990416 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -15,18 +15,16 @@ import { IContextViewService } from '../../../../platform/contextview/browser/co import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { createWorkbenchDialogOptions } from '../../../../workbench/browser/parts/dialogs/dialog.js'; import { AutomationTarget, IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; -import { isAutoApprovePolicyRestricted, isAutoApproveValuePolicyRestricted } from '../../../../workbench/contrib/chat/common/agentHostConfigPolicy.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; import { IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; const $ = DOM.$; @@ -70,11 +68,9 @@ export class AutomationDialogService implements IAutomationDialogService { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextViewService private readonly contextViewService: IContextViewService, @IConfigurationService private readonly configurationService: IConfigurationService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ILogService private readonly logService: ILogService, - @IProductService private readonly productService: IProductService, @IHostService private readonly hostService: IHostService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @@ -88,6 +84,12 @@ export class AutomationDialogService implements IAutomationDialogService { const isEdit = !!existing; const initialTarget = initial?.target; const initialWorkspaceTarget = initialTarget?.kind === 'workspace' ? initialTarget : undefined; + const initialSessionConfiguration: IAutomationSessionConfiguration | undefined = initial ? { + sessionTemplate: initial.sessionTemplate, + modelId: initial.modelId, + mode: initial.mode, + permissionLevel: initial.permissionLevel, + } : undefined; const state: IFormState = { name: initial?.name ?? '', @@ -112,9 +114,7 @@ export class AutomationDialogService implements IAutomationDialogService { let cancelButton: IButton | undefined; let revalidate: () => void = () => { }; let getPrompt: () => string = () => initial?.prompt ?? ''; - let getMode: () => string | undefined = () => initial?.mode; - let getPermissionLevel: () => string | undefined = () => initial?.permissionLevel; - let getModelId: () => string | undefined = () => initial?.modelId; + let getSessionConfiguration = async () => initialSessionConfiguration; let getBranch: () => string | undefined = () => initialWorkspaceTarget?.isolation.kind === 'worktree' ? initialWorkspaceTarget.isolation.branch : undefined; let waitForAutomationSessionSync: () => Promise = async () => { }; let getFocusableElements: () => readonly HTMLElement[] = () => []; @@ -168,11 +168,9 @@ export class AutomationDialogService implements IAutomationDialogService { const formPane = DOM.append(container, $('.automation-form-pane')); const form = DOM.append(formPane, $('.automation-form')); - const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.languageModelsService, this.layoutService, this.logService, this.productService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initialTarget, initial?.sessionTemplate, initial?.mode, initial?.permissionLevel, initial?.modelId); + const handle = renderForm(form, state, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.sessionsManagementService, this.workspaceTrustRequestService, initial?.prompt ?? '', initialTarget, initialSessionConfiguration); getPrompt = handle.getPrompt; - getMode = handle.getMode; - getPermissionLevel = handle.getPermissionLevel; - getModelId = handle.getModelId; + getSessionConfiguration = handle.getSessionConfiguration; getBranch = handle.getBranch; waitForAutomationSessionSync = handle.waitForAutomationSessionSync; getFocusableElements = handle.getFocusableElements; @@ -222,13 +220,11 @@ export class AutomationDialogService implements IAutomationDialogService { }; const prompt = getPrompt(); - const mode = getMode(); - const selectedPermissionLevel = getPermissionLevel(); - const permissionLevel = initial?.permissionLevel !== undefined - && isAutoApproveValuePolicyRestricted(initial.permissionLevel, isAutoApprovePolicyRestricted(this.configurationService)) - ? initial.permissionLevel - : selectedPermissionLevel; - const modelId = getModelId(); + const sessionConfiguration = await getSessionConfiguration(); + const sessionTemplate = sessionConfiguration?.sessionTemplate; + const mode = sessionConfiguration?.mode; + const permissionLevel = sessionConfiguration?.permissionLevel; + const modelId = sessionConfiguration?.modelId; const branch = getBranch(); const target = createAutomationTarget(state, branch); if (!target) { @@ -241,9 +237,12 @@ export class AutomationDialogService implements IAutomationDialogService { prompt, schedule, target, - modelId: modelId ?? null, - mode: mode ?? null, - permissionLevel: permissionLevel ?? null, + ...(sessionConfiguration ? { + sessionTemplate: sessionTemplate ?? null, + modelId: modelId ?? null, + mode: mode ?? null, + permissionLevel: permissionLevel ?? null, + } : {}), enabled: state.enabled, }; return { kind: 'update', id: existing.id, value: patch }; @@ -254,6 +253,7 @@ export class AutomationDialogService implements IAutomationDialogService { prompt, schedule, target, + sessionTemplate, modelId, mode, permissionLevel, diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index 668f808f9e3b05..f650c73a869a55 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -298,38 +298,60 @@ min-width: 0; } -/* - * Hide chips from the embedded chat input's primary toolbar that don't fit - * the automation dialog's reduced surface: - * - "Configure Tools" (`workbench.action.chat.configureTools`, - * `.codicon-settings-compact`) — tool selection is not relevant for a - * scheduled prompt. - * - "Add Context" (`workbench.action.chat.attachContext`, - * `.codicon-add-compact`, the `+`) — the dialog doesn't support - * attachments; the prompt is the only payload sent at run time. - * - "List MCP Servers" (`workbench.mcp.listServer`, `.codicon-server`) — - * MCP server management belongs in the live chat surface, not a one-off - * prompt definition. - * - * We hide the `.action-label` (which carries the codicon class) rather than - * the parent `.action-item`. Targeting the `.action-item` would require a - * `:has()` selector, whose invalidation bookkeeping is paid across every - * `.action-item` in the workbench (see microsoft/vscode#324985). Since - * `.action-item` has no intrinsic padding/margin (actionbar.css), hiding the - * label collapses the slot to zero width, so there is no visual gap. - * - * Known limitation: the hidden chips remain in the toolbar's arrow-key focus - * rotation (the action bar skips only disabled/separator items, not - * `display:none` ones). This is an accepted temporary trade-off — the dialog - * will migrate to `newChatInput.ts`, which builds its own toolbars and never - * renders these `MenuId.ChatInput` chips at all. - */ -.automation-form-prompt-host .chat-input-toolbar .action-label.codicon-settings-compact, -.automation-form-prompt-host .chat-input-toolbar .action-label.codicon-add-compact, -.automation-form-prompt-host .chat-input-toolbar .action-item.chat-mcp { +.automation-session-configuration { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--vscode-spacing-size80); + min-width: 0; + min-height: 22px; + padding-top: var(--vscode-spacing-size40); +} + +.automation-session-config, +.automation-session-controls { + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.automation-session-configuration-unavailable { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.automation-session-config .monaco-toolbar, +.automation-session-controls .monaco-toolbar, +.automation-session-config .monaco-action-bar, +.automation-session-controls .monaco-action-bar, +.automation-session-config .actions-container, +.automation-session-controls .actions-container { + min-width: 0; + overflow: hidden; +} + +.automation-session-configuration .compact-picker .sessions-chat-dropdown-label, +.automation-session-configuration .compact-picker .chat-input-picker-label, +.automation-session-configuration .compact-picker .chat-session-option-label { display: none; } +.automation-session-controls .action-item.compact-picker, +.automation-session-controls .action-item.compact-picker .action-label { + box-sizing: border-box; + width: 22px; + min-width: 22px; +} + +.automation-session-controls .action-item.compact-picker { + padding: 0; +} + +.automation-session-controls .action-item.compact-picker .action-label { + justify-content: flex-start; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size20) var(--vscode-spacing-size20) var(--vscode-spacing-size80); +} + /* * Suppress every inter-chip divider drawn by chat.css inside the dialog, * in both the primary (`.chat-input-toolbar`) and secondary diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index d336c0e2912f4c..e9d66044572af4 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -32,12 +32,12 @@ import { IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../. import { createWorkbenchDialogOptions } from '../../../../../workbench/browser/parts/dialogs/dialog.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { GitRefType, IGitRepository, IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ISession, ISessionWorkspace, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; +import { IAutomationSessionConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, resolveAutomationModelIdentifier, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; +import { AutomationIsolationGroupActionViewItem, AutomationSessionDraftSynchronizer, canSelectAutomationWorkspace, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from '../../browser/automationDialog.js'; import { AutomationIsolationModel } from '../../common/isolationGroupModel.js'; const FOLDER = URI.file('/workspace'); @@ -147,11 +147,11 @@ function createWorkspace(requiresWorkspaceTrust: boolean): ISessionWorkspace { }; } -function createAutomationDraftService() { +function createAutomationDraftService(captureSupported = true) { const automationSession = observableValue('automationSession', undefined); const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string; sessionTemplate?: IAutomationSessionTemplate }> = []; const discarded: string[] = []; - const sessionTemplates = new Map(); + const sessionConfigurations = new Map(); let nextId = 1; const createDraft = (kind: 'workspace' | 'quickChat', providerId: string | undefined, sessionTypeId: string, folderUri?: URI, sessionTemplate?: IAutomationSessionTemplate): ISession => { const previous = automationSession.get(); @@ -164,7 +164,7 @@ function createAutomationDraftService() { sessionType: sessionTypeId, }); created.push({ kind, providerId, sessionTypeId, folderUri: folderUri?.toString(), ...(sessionTemplate ? { sessionTemplate } : {}) }); - sessionTemplates.set(session.sessionId, sessionTemplate); + sessionConfigurations.set(session.sessionId, { sessionTemplate }); automationSession.set(session, undefined); return session; }; @@ -172,7 +172,7 @@ function createAutomationDraftService() { automationSession, createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri, options?.sessionTemplate), createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default', undefined, options?.sessionTemplate), - getAutomationSessionTemplate: async session => sessionTemplates.get(session.sessionId), + getAutomationSessionConfiguration: async session => captureSupported ? sessionConfigurations.get(session.sessionId) : null, discardAutomationSession: session => { const current = automationSession.get(); if (!current || (session && session.sessionId !== current.sessionId)) { @@ -182,7 +182,7 @@ function createAutomationDraftService() { automationSession.set(undefined, undefined); }, }); - return { service, created, discarded }; + return { service, created, discarded, sessionConfigurations }; } suite('Automation session draft synchronization', () => { @@ -212,6 +212,7 @@ suite('Automation session draft synchronization', () => { discarded, currentSession: service.automationSession.get()?.sessionId, errorCount, + availability: synchronizer.availability.get(), }, { created: [ { kind: 'workspace', providerId: 'provider-a', sessionTypeId: 'type-a', folderUri: 'file:///workspace' }, @@ -222,6 +223,7 @@ suite('Automation session draft synchronization', () => { discarded: ['automation-1', 'automation-2', 'automation-3', 'automation-4'], currentSession: undefined, errorCount: 0, + availability: 'idle', }); }); @@ -239,9 +241,50 @@ suite('Automation session draft synchronization', () => { folderUri: URI.parse('file:///workspace'), providerId: 'provider', sessionTypeId: 'type', - sessionTemplate, + sessionConfiguration: { sessionTemplate }, }); - const captured = await synchronizer.getSessionTemplate(); + + test('distinguishes a valid empty capture from unsupported capture', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { + modelId: 'model', + config: { mode: 'plan' }, + }, + modelId: 'model', + mode: 'plan', + }; + const supported = createAutomationDraftService(); + const supportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(supported.service, async () => true, () => { })); + supportedSynchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + await supportedSynchronizer.waitForSync(); + const supportedSessionId = supported.service.automationSession.get()!.sessionId; + supported.sessionConfigurations.set(supportedSessionId, {}); + + const unsupported = createAutomationDraftService(false); + const unsupportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(unsupported.service, async () => true, () => { })); + unsupportedSynchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + assert.deepStrictEqual({ + supported: await supportedSynchronizer.getSessionConfiguration(), + unsupported: await unsupportedSynchronizer.getSessionConfiguration(), + }, { + supported: {}, + unsupported: sessionConfiguration, + }); + }); + const captured = await synchronizer.getSessionConfiguration(); assert.deepStrictEqual({ created, @@ -254,7 +297,7 @@ suite('Automation session draft synchronization', () => { folderUri: 'file:///workspace', sessionTemplate, }], - captured: sessionTemplate, + captured: { sessionTemplate }, }); }); @@ -295,10 +338,12 @@ suite('Automation session draft synchronization', () => { created, currentSession: service.automationSession.get()?.sessionId, errorCount, + availability: synchronizer.availability.get(), }, { created: [], currentSession: undefined, errorCount: 1, + availability: 'unavailable', }); }); @@ -334,10 +379,12 @@ suite('Automation session draft synchronization', () => { createCount, errorCount, sessionId: automationSession.get()?.sessionId, + availability: synchronizer.availability.get(), }, { createCount: 2, errorCount: 1, sessionId: 'automation-retry', + availability: 'available', }); }); }); @@ -944,36 +991,6 @@ suite('Automation branch picker', () => { }); }); - test('resolves a legacy model identifier to the selected concrete target', () => { - const legacyIdentifier = 'copilotcli/gpt-5.6-sol'; - const concreteIdentifier = 'agent-host-copilotcli:gpt-5.6-sol'; - const unrelatedIdentifier = 'other/gpt-5.6-sol'; - const modelIds = [legacyIdentifier, unrelatedIdentifier]; - const models = new Map([ - [legacyIdentifier, upcastPartial({ id: 'gpt-5.6-sol', targetChatSessionType: 'copilotcli' })], - [concreteIdentifier, upcastPartial({ id: 'gpt-5.6-sol', targetChatSessionType: 'agent-host-copilotcli' })], - [unrelatedIdentifier, upcastPartial({ id: 'gpt-5.6-sol', targetChatSessionType: 'other' })], - ]); - const languageModelsService = upcastPartial({ - getLanguageModelIds: () => modelIds, - lookupLanguageModel: identifier => models.get(identifier), - }); - - const beforeConcreteTargetArrives = resolveAutomationModelIdentifier(languageModelsService, legacyIdentifier, 'copilotcli', 'agent-host-copilotcli'); - modelIds.push(concreteIdentifier); - - assert.deepStrictEqual({ - beforeConcreteTargetArrives, - afterConcreteTargetArrives: resolveAutomationModelIdentifier(languageModelsService, legacyIdentifier, 'copilotcli', 'agent-host-copilotcli'), - alreadyConcrete: resolveAutomationModelIdentifier(languageModelsService, concreteIdentifier, 'copilotcli', 'agent-host-copilotcli'), - unrelated: resolveAutomationModelIdentifier(languageModelsService, unrelatedIdentifier, 'copilotcli', 'agent-host-copilotcli'), - }, { - beforeConcreteTargetArrives: legacyIdentifier, - afterConcreteTargetArrives: concreteIdentifier, - alreadyConcrete: concreteIdentifier, - unrelated: unrelatedIdentifier, - }); - }); }); suite('Automation dialog keyboard navigation', () => { diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 03955581cd7814..2c7c3e0c1f12b4 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -100,9 +100,9 @@ import { chatInputStackClass, chatInputStackSlotClass, ChatInputStackSlot, refre import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; -import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; import { ISessionModelSelection, SessionModelSelection } from './sessionModelSelection.js'; import { hasSendableModelSelection } from './sessionModelPickerState.js'; +import { createNewSessionConfigToolbar, createNewSessionControlToolbar } from './newSessionConfigToolbars.js'; import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import { IChatStatusItemService } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusItemService.js'; @@ -765,9 +765,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const newChatBottomContainer = dom.append(parent, dom.$('.new-chat-bottom-container')); const newChatControlsContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-controls-container')); const sessionControlsContainer = this._sessionControlsContainer = dom.append(newChatControlsContainer, dom.$('.new-chat-session-controls')); - this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, sessionControlsContainer, Menus.NewSessionControl, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - })); + this._register(createNewSessionControlToolbar(sessionControlsContainer, this._scopedInstantiationService)); this._register({ dispose: () => sessionControlsContainer.remove() }); const repoConfigContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-repo-config-container')); @@ -1144,16 +1142,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Session config pickers (such as model) — rendered via MenuWorkbenchToolBar // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); - const configToolbar = this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - actionViewItemProvider: (action) => { - if (action.id === 'sessions.modelPicker') { - const picker = this._scopedInstantiationService.createInstance(ModelPicker, this._compactModelPicker); - return new ModelPickerActionViewItem(picker); - } - return undefined; - }, - })); + const configToolbar = this._register(createNewSessionConfigToolbar(configContainer, this._scopedInstantiationService, this._compactModelPicker)); // Dictation mic button. Shares the STT service, mic // device, and gating (backend support + `dictation.enabled`) diff --git a/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts new file mode 100644 index 00000000000000..d362f9809029fe --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Menus } from '../../../browser/menus.js'; +import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; + +/** Creates the provider/model toolbar shared by New Session configuration surfaces. */ +export function createNewSessionConfigToolbar(container: HTMLElement, instantiationService: IInstantiationService, compactModelPicker: IObservable): MenuWorkbenchToolBar { + return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionConfig, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + actionViewItemProvider: action => { + if (action.id === 'sessions.modelPicker') { + const picker = instantiationService.createInstance(ModelPicker, compactModelPicker); + return new ModelPickerActionViewItem(picker); + } + return undefined; + }, + }); +} + +/** Creates the provider-owned control toolbar shared by New Session configuration surfaces. */ +export function createNewSessionControlToolbar(container: HTMLElement, instantiationService: IInstantiationService): MenuWorkbenchToolBar { + return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionControl, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + }); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index 2f01f89da9aaa9..008e087beb7ab0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts @@ -88,7 +88,6 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IChatService private readonly chatService: IChatService, @@ -97,7 +96,6 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC @ILogService private readonly logService: ILogService, ) { super(); - const modePickerModel = this._register(instantiationService.createInstance(ModePickerModel)); let settingAgentInternally = false; const initAgentFromActiveSession = () => { @@ -112,11 +110,8 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC this._register(autorun(reader => { const session = sessionsService.activeSession.read(reader); - const provider = this._getProvider(session, sessionsProvidersService); const selectedAgentUri = session?.mode.read(reader)?.id; - modePickerModel.setSession(provider ? session : undefined, selectedAgentUri); - const isUntitled = session?.status.read(reader) === SessionStatus.Untitled; this._syncChatInputMode(session, selectedAgentUri, sessionsProvidersService); this._initAgent(session, selectedAgentUri, isUntitled, sessionsProvidersService, () => settingAgentInternally = true, () => settingAgentInternally = false); @@ -141,8 +136,15 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC const factory = (_action: IAction, _options: IActionViewItemOptions, scopedInstantiationService: IInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); + const modePickerModel = scopedInstantiationService.createInstance(ModePickerModel); const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel, session); const disposableStore = new DisposableStore(); + disposableStore.add(modePickerModel); + disposableStore.add(autorun(reader => { + const scopedSession = session.read(reader); + const provider = this._getProvider(scopedSession, sessionsProvidersService); + modePickerModel.setSession(provider ? scopedSession : undefined, scopedSession?.mode.read(reader)?.id); + })); disposableStore.add(picker.onDidSelect(mode => { this._selectMode(mode, session.get(), sessionsProvidersService); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index cb343066c893de..a998495b4d98cb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -30,6 +30,7 @@ import { ChangesetKind } from '../../../../../platform/agentHost/common/changese import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_MODE_VALUES, omitTransientSessionConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata, type IAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -61,7 +62,7 @@ import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; import { ChatInteractivity, ChatModelSource, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, getGitHubPullRequestRefs, getHighestPriorityPullRequestIcon, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, IGitHubPullRequestRef, isActiveSessionStatus, ISession, ISessionAgentRef, ISessionArtifact, ISessionCapabilities, ISessionChangesSummary, ISessionChatCustomization, ISessionChangeset, ISessionCreationReference, ISessionFileChange, ISessionTurnFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, sessionWorkspaceEqual, SessionRemoteConnectionFailureReason, SessionRemoteConnectionStatus, SessionStatus, SessionTypeAuthRequirement, toSessionId, TURN_CHANGES_CHANGESET_ID } from '../../../../services/sessions/common/session.js'; import { dedupeLinks, getPresentedArtifacts, linkKey, partitionSessionArtifacts } from './agentHostSessionArtifacts.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestRefPresentation } from '../../../github/browser/pullRequestIconStatus.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; @@ -2065,6 +2066,7 @@ class NewSession extends Disposable { private _config: ResolveSessionConfigResult | undefined = { schema: { type: 'object', properties: {} }, values: {} }; private _configResolution: Promise | undefined; private _configOperation: Promise | undefined; + private readonly _explicitlySetConfigProperties = new Set(); /** * Monotonic counter for in-flight {@link resolveConfig} calls. Each call @@ -2109,6 +2111,7 @@ class NewSession extends Disposable { private readonly _activeClientScope: IAgentCustomizationScope; private readonly _initialMetadata: Record | undefined; + private readonly _initialSessionTemplate: IAutomationSessionTemplate | undefined; get initialMetadata(): Record | undefined { return this._initialMetadata; } private readonly _logService: ILogService; @@ -2135,6 +2138,7 @@ class NewSession extends Disposable { this._activeClientScope = ctx.activeClientScope; this._register(this._activeClientScope); this._initialMetadata = ctx.initialMetadata; + this._initialSessionTemplate = ctx.initialSessionTemplate; const resource = URI.from({ scheme: ctx.resourceScheme, path: `/${generateUuid()}` }); this._isActiveSessionObs = derived(this, reader => isEqual(sessionsService.activeSession.read(reader)?.resource, resource)); @@ -2242,6 +2246,7 @@ class NewSession extends Disposable { } getSelectedAgent(): ISessionAgentRef | undefined { return this._selectedAgent; } + getInitialSessionTemplate(): IAutomationSessionTemplate | undefined { return this._initialSessionTemplate; } clearSelectedAgent(): void { this._selectedAgent = undefined; this._mode.set(undefined, undefined); @@ -2371,7 +2376,7 @@ class NewSession extends Disposable { * during the async re-resolve. {@link resolveConfig} replaces both * schema and values when its response lands. */ - setConfigValue(property: string, value: unknown): void { + setConfigValue(property: string, value: unknown, explicitlySet = false): void { const current = this._config; const values = { ...(current?.values ?? {}) }; if (value === undefined) { @@ -2383,9 +2388,16 @@ class NewSession extends Disposable { schema: current?.schema ?? { type: 'object', properties: {} }, values, }; + if (explicitlySet) { + this._explicitlySetConfigProperties.add(property); + } this._syncWorktreePending(); } + wasConfigValueExplicitlySet(property: string): boolean { + return this._explicitlySetConfigProperties.has(property); + } + /** * `true` while a {@link resolveConfig} round-trip is in flight. See * {@link _isResolvingConfig} for why this is distinct from {@link ISession.loading}. @@ -2703,6 +2715,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement abstract readonly label: string; abstract readonly icon: ThemeIcon; abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; + readonly usesCombinedNewSessionConfigPicker = true; get order(): number { return 0; } @@ -3519,7 +3532,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } - return this._createDraftSession(sessionType, workspace, false, options?.metadata, options?.sessionTemplate); + return this._createDraftSession( + sessionType, + workspace, + false, + options?.metadata, + options?.automationConfiguration ?? (options?.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : undefined), + ); } startNewSessionRequest(sessionId: string, activity?: string): IDisposable { @@ -3542,7 +3561,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // workspace-less: no `resolveWorkspace`, no `workingDirectory`. The // agent host runs it in a throwaway scratch cwd and tags it via the // `quickChat` create flag. - return this._createDraftSession(sessionType, undefined, true, options?.metadata, options?.sessionTemplate); + return this._createDraftSession( + sessionType, + undefined, + true, + options?.metadata, + options?.automationConfiguration ?? (options?.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : undefined), + ); } /** @@ -3550,7 +3575,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * given session type. Shared by {@link createNewSession} (workspace-bound) * and {@link createQuickChat} (workspace-less, `quickChat === true`). */ - private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record, initialSessionTemplate?: IAutomationSessionTemplate): ISession { + private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean, initialMetadata?: Record, initialAutomationConfiguration?: IAutomationSessionConfiguration): ISession { // Tear-down of superseded drafts is handled by the management layer // (it calls `deleteNewSession` on the previous pending session). Each // new session is tracked independently in `_newSessions` so several can @@ -3559,6 +3584,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const connection = this.connection; const resourceScheme = this.resourceSchemeForProvider(sessionType.id); const activeClientScope = this._activeClientService.acquireScope(resourceScheme, workspace?.folders.map(folder => folder.root) ?? []); + const initialSessionTemplate = this._resolveAutomationSessionTemplate(sessionType.id, initialAutomationConfiguration); + const initialConfigValues = initialAutomationConfiguration + ? this._normalizeAutomationSessionConfig(initialSessionTemplate?.config) + : this._initialNewSessionConfig(workspace); let newSession: NewSession; try { newSession = this._instantiationService.createInstance(NewSession, { @@ -3571,7 +3600,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement backendSessionScheme: this._backendSessionScheme(sessionType.id), authenticationPending: this.authenticationPending, logService: this._logService, - initialConfigValues: initialSessionTemplate ? { ...initialSessionTemplate.config } : this._initialNewSessionConfig(workspace), + initialConfigValues, initialSessionTemplate, initialConfigSchema: this._seededConfigSchema(), initialMetadata, @@ -3619,6 +3648,28 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return newSession.session; } + private _resolveAutomationSessionTemplate(sessionTypeId: string, configuration: IAutomationSessionConfiguration | undefined): IAutomationSessionTemplate | undefined { + if (!configuration || configuration.sessionTemplate) { + return configuration?.sessionTemplate; + } + const config = applyLegacyAutomationSessionConfig(sessionTypeId, undefined, configuration.mode, configuration.permissionLevel); + if (!configuration.modelId && Object.keys(config).length === 0) { + return undefined; + } + return { + ...(configuration.modelId ? { modelId: configuration.modelId } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + } + + private _normalizeAutomationSessionConfig(config: Readonly> | undefined): Record { + const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService); + return Object.fromEntries(Object.entries(config ?? {}).map(([key, value]) => [ + key, + normalizeSessionConfigValue(key, value, policyRestricted), + ])); + } + protected _resumeNewSessionAfterAuthenticationSettles(): void { const connection = this.connection; if (!connection) { @@ -3837,7 +3888,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Dynamic session config ---------------------------------------------- - async getAutomationSessionTemplate(sessionId: string): Promise { + async getAutomationSessionConfiguration(sessionId: string): Promise { const newSession = this._getNewSession(sessionId); if (!newSession) { return undefined; @@ -3846,7 +3897,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (this._getNewSession(sessionId) !== newSession) { return undefined; } - const config = omitTransientSessionConfigValues({ ...newSession.getConfigValues() }); + const config = omitTransientSessionConfigValues({ + ...newSession.getConfigValues(), + }); + const initialConfig = newSession.getInitialSessionTemplate()?.config ?? {}; + for (const [key, value] of Object.entries(initialConfig)) { + if (!newSession.wasConfigValueExplicitlySet(key)) { + config[key] = value; + } + } delete config[SessionConfigKey.Isolation]; delete config[SessionConfigKey.Branch]; delete config[SessionConfigKey.WorktreeBranchPrefix]; @@ -3857,13 +3916,20 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement delete config[SessionConfigKey.AgentMergeController]; const modelId = newSession.getSelectedModelId(); const agent = newSession.getSelectedAgent(); - if (!modelId && !agent && Object.keys(config).length === 0) { - return undefined; - } + const sessionTemplate = !modelId && !agent && Object.keys(config).length === 0 + ? undefined + : { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + const mode = config[SessionConfigKey.Mode]; + const permissionLevel = config[SessionConfigKey.AutoApprove]; return { - ...(modelId ? { modelId } : {}), - ...(agent ? { agent: { uri: agent.uri } } : {}), - ...(Object.keys(config).length > 0 ? { config } : {}), + sessionTemplate, + modelId, + mode: typeof mode === 'string' ? mode : undefined, + permissionLevel: typeof permissionLevel === 'string' ? permissionLevel : undefined, }; } @@ -3925,7 +3991,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (property === SessionConfigKey.Isolation) { newSession.setConfigValue(SessionConfigKey.Branch, undefined); } - newSession.setConfigValue(property, normalizedValue); + newSession.setConfigValue(property, normalizedValue, true); this._onDidChangeSessionConfig.fire(sessionId); await newSession.trackConfigResolution(this._refreshNewSessionConfig(newSession)); return; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 2c22db7792db02..96cee2a68927e2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -25,7 +25,7 @@ import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../.. import { IChatPetService } from '../../../../../../workbench/contrib/chat/browser/chatPetService.js'; import { Menus } from '../../../../../browser/menus.js'; import { SessionUsesCombinedConfigPickerContext, IsPhoneLayoutContext } from '../../../../../common/contextkeys.js'; -import { type IAgentHostSessionsProvider, isAgentHostProvider, isAgentHostProviderId } from '../../../../../common/agentHostSessionsProvider.js'; +import { type IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../../common/agentHostSessionsProvider.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -372,6 +372,7 @@ class MobileChatInputConfigPickerContribution extends Disposable implements IWor @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IContextKeyService contextKeyService: IContextKeyService, ) { super(); @@ -383,7 +384,7 @@ class MobileChatInputConfigPickerContribution extends Disposable implements IWor const usesCombinedPicker = SessionUsesCombinedConfigPickerContext.bindTo(contextKeyService); this._register(autorun(reader => { const session = sessionsService.activeSession.read(reader); - usesCombinedPicker.set(!!session && isAgentHostProviderId(session.providerId)); + usesCombinedPicker.set(!!session && sessionsProvidersService.getProvider(session.providerId)?.usesCombinedNewSessionConfigPicker === true); })); this._register(actionViewItemService.register( 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 c6e98cfe9b4745..c6d648bbbb2c51 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 @@ -4473,12 +4473,20 @@ suite('LocalAgentHostSessionsProvider', () => { config: { mode: 'plan', autoApprove: 'assisted', + providerOption: { enabled: true }, + clearedOption: true, }, }; agentHost.resolveSessionConfigResult = { - schema: { type: 'object', properties: {} }, + schema: { + type: 'object', + properties: { + clearedOption: { type: 'boolean', title: 'Cleared option' }, + }, + }, values: { - ...sessionTemplate.config, + mode: 'plan', + autoApprove: 'assisted', [SessionConfigKey.WorktreeBranchPrefix]: 'stale-prefix/', [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], }, @@ -4489,22 +4497,76 @@ suite('LocalAgentHostSessionsProvider', () => { provider.sessionTypes[0].id, { sessionTemplate }, ); - - const captured = await provider.getAutomationSessionTemplate(session.sessionId); + await provider.getAutomationSessionConfiguration(session.sessionId); + await provider.setSessionConfigValue(session.sessionId, 'clearedOption', false); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); assert.deepStrictEqual({ captured, - initialConfig: agentHost.resolveSessionConfigRequests.at(-1)?.config, + initialConfig: agentHost.resolveSessionConfigRequests.at(-2)?.config, modelId: session.modelId.get(), agentUri: session.mode.get()?.id, }, { - captured: sessionTemplate, + captured: { + sessionTemplate: { + ...sessionTemplate, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }, + modelId: sessionTemplate.modelId, + mode: 'plan', + permissionLevel: 'assisted', + }, initialConfig: sessionTemplate.config, modelId: sessionTemplate.modelId, agentUri: sessionTemplate.agent.uri, }); }); + test('Automation drafts display policy-clamped approvals without overwriting the saved preference', async () => { + const sessionTemplate = { + config: { + autoApprove: 'autoApprove', + }, + }; + agentHost.resolveSessionConfigResult = { + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', enum: ['default', 'autoApprove'] }, + }, + }, + values: { autoApprove: 'default' }, + }; + const provider = createProvider(disposables, agentHost, undefined, { + configurationService: createPolicyRestrictedConfigurationService(), + }); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { sessionTemplate }, + ); + + const capturedWithoutEdit = await provider.getAutomationSessionConfiguration(session.sessionId); + await provider.setSessionConfigValue(session.sessionId, SessionConfigKey.AutoApprove, 'default'); + const capturedAfterEdit = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + displayed: provider.getSessionConfig(session.sessionId)?.values.autoApprove, + initialConfig: agentHost.resolveSessionConfigRequests.at(-2)?.config?.autoApprove, + capturedWithoutEdit: capturedWithoutEdit?.sessionTemplate?.config?.autoApprove, + capturedAfterEdit: capturedAfterEdit?.sessionTemplate?.config?.autoApprove, + }, { + displayed: 'default', + initialConfig: 'default', + capturedWithoutEdit: 'autoApprove', + capturedAfterEdit: 'default', + }); + }); + test('createNewSession drops an invalid remembered mode instead of forwarding it', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify({ diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 92ff799e942920..e48a316a94c0af 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -24,10 +24,11 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbe import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ChatModelSource, ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, ISideChatSelection, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, ISessionChangeset, IChatCheckpoints, ChatInteractivity, SessionTypeAuthRequirement } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { ChatMode, IChatMode, IChatModeService, isBuiltinChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; @@ -125,6 +126,7 @@ export interface ICopilotChatSession { readonly permissionLevel: IObservable; setPermissionLevel(level: ChatPermissionLevel): void; + readonly initialAutomationSessionConfiguration?: IAutomationSessionConfiguration; readonly branch: IObservable; setBranch(branch: string | undefined): void; @@ -335,6 +337,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { readonly resource: URI, readonly sessionWorkspace: ISessionWorkspace, providerId: string, + readonly initialAutomationSessionConfiguration: IAutomationSessionConfiguration | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IGitService private readonly gitService: IGitService, @IGitHubService private readonly gitHubService: IGitHubService, @@ -527,6 +530,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { setMode(mode: IChatMode | undefined): void { if (this._mode?.id !== mode?.id) { this._mode = mode; + this._modeObservable.set(mode ? { id: mode.id, kind: mode.kind } : undefined, undefined); const modeName = mode?.isBuiltin ? undefined : mode?.name.get(); this.setOption(AGENT_OPTION_ID, modeName ?? ''); } @@ -690,6 +694,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession readonly sessionWorkspace: ISessionWorkspace, readonly target: AgentSessionTarget, providerId: string, + readonly initialAutomationSessionConfiguration: IAutomationSessionConfiguration | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, @@ -1651,18 +1656,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._findChatSession(sessionId); } - createNewSession(workspaceUri: URI, sessionTypeId: string): ISession { + createNewSession(workspaceUri: URI, sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { const workspace = this.resolveWorkspace(workspaceUri); if (!workspace) { throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } + const automationConfiguration = options?.automationConfiguration + ?? (options?.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : undefined); if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { if (sessionTypeId !== CopilotCloudSessionType.id) { throw new Error('Only Copilot Cloud sessions can be created for GitHub repositories'); } const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id); + const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id, automationConfiguration); + this._applyAutomationSessionConfiguration(session, automationConfiguration); this._newSessions.set(session.sessionId, session); return this._chatToSession(session); } @@ -1671,12 +1679,43 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id); + const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id, automationConfiguration); session.setPermissionLevel(this._defaultPermissionLevel()); + this._applyAutomationSessionConfiguration(session, automationConfiguration); this._newSessions.set(session.sessionId, session); return this._chatToSession(session); } + async getAutomationSessionConfiguration(sessionId: string): Promise { + const session = this._newSessions.get(sessionId); + if (!session) { + return undefined; + } + const modelId = session.modelId.get(); + const initialConfiguration = session.initialAutomationSessionConfiguration; + const initialTemplate = initialConfiguration?.sessionTemplate; + const initialMode = initialConfiguration?.mode ?? initialTemplate?.config?.[SessionConfigKey.Mode]; + const mode = session.mode.get()?.id ?? (typeof initialMode === 'string' ? initialMode : undefined); + const initialPermissionLevel = initialConfiguration?.permissionLevel ?? initialTemplate?.config?.[SessionConfigKey.AutoApprove]; + const permissionLevel = session instanceof RemoteNewSession && isChatPermissionLevel(initialPermissionLevel) + ? initialPermissionLevel + : session.permissionLevel.get(); + const config = { ...initialTemplate?.config }; + if (mode) { + config[SessionConfigKey.Mode] = mode; + } else { + delete config[SessionConfigKey.Mode]; + } + config[SessionConfigKey.AutoApprove] = permissionLevel; + const agent = initialTemplate?.agent; + const sessionTemplate: IAutomationSessionTemplate = { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; + return { sessionTemplate, modelId, mode, permissionLevel }; + } + createQuickChat(_sessionTypeId: string, _options?: ISessionsProviderCreateSessionOptions): ISession { // This provider is workspace-bound and does not advertise // `supportsQuickChats`; callers must gate on that capability. @@ -1697,6 +1736,29 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return isChatPermissionLevel(level) ? level : ChatPermissionLevel.Default; } + private _applyAutomationSessionConfiguration(session: ICopilotChatSession, configuration: IAutomationSessionConfiguration | undefined): void { + if (!configuration) { + return; + } + const template = configuration.sessionTemplate; + const modelId = configuration.modelId ?? template?.modelId; + if (modelId) { + session.setModelId(modelId, ChatModelSource.Chosen); + } + const mode = configuration.mode ?? template?.config?.[SessionConfigKey.Mode]; + if (typeof mode === 'string') { + const restored = this._setSessionMode(session, mode); + if (!restored && session instanceof CopilotCLISession) { + session.setModeById(mode, ChatModeKind.Agent); + void this._resolveAutomationSessionMode(session, mode); + } + } + const permissionLevel = configuration.permissionLevel ?? template?.config?.[SessionConfigKey.AutoApprove]; + if (!(session instanceof RemoteNewSession) && isChatPermissionLevel(permissionLevel)) { + session.setPermissionLevel(permissionLevel); + } + } + get onDidChangeModels(): Event { // Models can change because language models are (un)registered or because // the extension host updates a cloud session's `models` option group. @@ -1809,44 +1871,63 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } setMode(sessionId: string, modeId: string): void { - const setSessionMode = (session: ICopilotChatSession): void => { - let mode: IChatMode | undefined; - switch (modeId) { - case ChatModeKind.Agent: - mode = ChatMode.Agent; - break; - case ChatModeKind.Edit: - mode = ChatMode.Edit; - break; - case ChatModeKind.Ask: - mode = ChatMode.Ask; - break; - default: { - const modes = this.chatModeService.createModes(session.resource); - try { - mode = modes.findModeById(modeId) ?? modes.findModeByName(modeId); - } finally { - modes.dispose(); - } - break; - } - } - - if (mode) { - session.setMode(mode); - } - }; - const newSession = this._newSessions.get(sessionId); if (newSession) { - setSessionMode(newSession); + this._setSessionMode(newSession, modeId); return; } this._ensureSessionCache(); const session = this._findChatSession(sessionId); if (session) { - setSessionMode(session); + this._setSessionMode(session, modeId); + } + } + + private _setSessionMode(session: ICopilotChatSession, modeId: string): boolean { + let mode: IChatMode | undefined; + switch (modeId) { + case ChatModeKind.Agent: + mode = ChatMode.Agent; + break; + case ChatModeKind.Edit: + mode = ChatMode.Edit; + break; + case ChatModeKind.Ask: + mode = ChatMode.Ask; + break; + default: { + const modes = this.chatModeService.createModes(session.resource); + try { + mode = modes.findModeById(modeId) ?? modes.findModeByName(modeId); + } finally { + modes.dispose(); + } + break; + } + } + if (mode) { + session.setMode(mode); + return true; + } + return false; + } + + private async _resolveAutomationSessionMode(session: CopilotCLISession, modeId: string): Promise { + const modes = this.chatModeService.createModes(session.resource); + try { + await modes.waitForPendingUpdates(); + if (this._newSessions.get(session.sessionId) !== session || session.mode.get()?.id !== modeId) { + return; + } + const mode = modes.findModeById(modeId) ?? modes.findModeByName(modeId); + if (mode) { + session.setMode(mode); + } + } catch (error) { + this.logService.error(`[CopilotChatSessionsProvider] Failed to restore Automation mode '${modeId}'.`, error); + } finally { + modes.dispose(); } } @@ -2128,7 +2209,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); - const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); + const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id, undefined); session.setModelId(chat.modelId.get(), ChatModelSource.CarriedOver); session.setIsolationMode('workspace'); session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1)); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 9f75811203a094..79936a328a4f29 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -33,6 +33,7 @@ import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/con import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ILanguageModelToolsService } from '../../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IChatModes, IChatModeService } from '../../../../../../workbench/contrib/chat/common/chatModes.js'; import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitRepository, IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; @@ -41,7 +42,7 @@ import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; import { CloudSandboxSessionsProvider } from '../../../remoteAgentHost/browser/cloudSandboxSessionsProvider.js'; -import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; +import { ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; @@ -421,6 +422,17 @@ function createProviderForSendTests( instantiationService.stub(INotificationService, new class extends mock() { override warn(message: unknown): void { opts?.notifications?.push(String(message)); } }()); + instantiationService.stub(IChatModeService, { + createModes: () => upcastPartial({ + onDidChange: Event.None, + builtin: [], + custom: [], + findModeById: () => undefined, + findModeByName: () => undefined, + waitForPendingUpdates: async () => { }, + dispose: () => { }, + }), + }); instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] }); instantiationService.stub(IGitService, { openRepository: async () => undefined }); instantiationService.stub(IInstantiationService, instantiationService); @@ -2043,6 +2055,73 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(session?.permissionLevel.get(), ChatPermissionLevel.Default); }); + + test('restores and captures Automation session configuration', async () => { + const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { })); + const sessionTemplate = { + modelId: 'model', + config: { + providerOption: true, + }, + }; + const automationConfiguration = { + sessionTemplate, + modelId: 'model', + mode: ChatModeKind.Ask, + permissionLevel: ChatPermissionLevel.Autopilot, + }; + + const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id, { automationConfiguration }); + const session = provider.getSession(sessionInfo.sessionId); + const captured = await provider.getAutomationSessionConfiguration(sessionInfo.sessionId); + + assert.deepStrictEqual({ + modelId: session?.modelId.get(), + mode: session?.mode.get()?.id, + permissionLevel: session?.permissionLevel.get(), + captured, + }, { + modelId: 'model', + mode: ChatModeKind.Ask, + permissionLevel: ChatPermissionLevel.Autopilot, + captured: { + sessionTemplate: { + modelId: 'model', + config: { + providerOption: true, + mode: ChatModeKind.Ask, + autoApprove: ChatPermissionLevel.Autopilot, + }, + }, + modelId: 'model', + mode: ChatModeKind.Ask, + permissionLevel: ChatPermissionLevel.Autopilot, + }, + }); + }); + + test('preserves an Automation mode while custom modes resolve', async () => { + const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { })); + const mode = 'file:///agents/reviewer.agent.md'; + const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id, { + automationConfiguration: { + mode, + permissionLevel: ChatPermissionLevel.Default, + }, + }); + + const captured = await provider.getAutomationSessionConfiguration(sessionInfo.sessionId); + + assert.deepStrictEqual({ + sessionMode: provider.getSession(sessionInfo.sessionId)?.mode.get()?.id, + capturedMode: captured?.mode, + templateMode: captured?.sessionTemplate?.config?.mode, + }, { + sessionMode: mode, + capturedMode: mode, + templateMode: mode, + }); + }); }); function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts index 14d1f3c15a33f1..64e2f8059ea7e6 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts @@ -75,6 +75,7 @@ suite('Copilot SandboxPicker', () => { workspace, AgentSessionProviders.Cloud, 'default-copilot', + undefined, )); if (options.useSandbox) { providerSession.setUseSandbox(true); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index d100c3e59dec02..eaf035de5e0c1d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -495,7 +495,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousNewSession = this._newSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); + const session = provider.createNewSession(folderUri, sessionTypeId, { + metadata: options?.metadata, + sessionTemplate: options?.sessionTemplate, + ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), + }); // Providers no longer dispose the previous new session implicitly, so // dispose the one this composer just replaced. Use its own provider @@ -514,7 +518,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); + const session = provider.createNewSession(folderUri, sessionTypeId, { + metadata: options?.metadata, + sessionTemplate: options?.sessionTemplate, + ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), + }); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -582,7 +590,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousNewSession = this._newSession.get(); - const session = provider.createQuickChat(sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); + const session = provider.createQuickChat(sessionTypeId, { + metadata: options?.metadata, + sessionTemplate: options?.sessionTemplate, + ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), + }); this._newSession.set(session, undefined); this.storageService.store(LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY, sessionTypeId, StorageScope.PROFILE, StorageTarget.USER); @@ -598,7 +610,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationQuickChat(options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createQuickChat(sessionTypeId, { metadata: options?.metadata, sessionTemplate: options?.sessionTemplate }); + const session = provider.createQuickChat(sessionTypeId, { + metadata: options?.metadata, + sessionTemplate: options?.sessionTemplate, + ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), + }); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -606,8 +622,15 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } - async getAutomationSessionTemplate(session: ISession) { - return this._getProvider(session)?.getAutomationSessionTemplate?.(session.sessionId); + async getAutomationSessionConfiguration(session: ISession) { + const provider = this._getProvider(session); + return provider?.getAutomationSessionConfiguration + ? provider.getAutomationSessionConfiguration(session.sessionId) + : null; + } + + usesCombinedNewSessionConfigPicker(session: ISession): boolean { + return this._getProvider(session)?.usesCombinedNewSessionConfigPicker === true; } async createNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise { diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 3cbf28eeafc097..bef533dfa28c29 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -10,7 +10,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; -import { IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; +import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; /** Raised when unattended session creation targets a workspace that requires trust. */ export class WorkspaceNotTrustedError extends Error { @@ -96,6 +96,8 @@ export interface ICreateNewSessionOptions { readonly permissionLevel?: string; /** Provider-owned session values restored into an Automation draft. */ readonly sessionTemplate?: IAutomationSessionTemplate; + /** Complete provider-owned Automation draft state. */ + readonly automationConfiguration?: IAutomationSessionConfiguration; /** * Optional worktree isolation mode (`worktree` or `workspace`) to apply * via {@link ISessionsProvider.setIsolationMode}. Skipped if the @@ -395,8 +397,14 @@ export interface ISessionsManagementService { */ discardAutomationSession(session?: ISession): void; - /** Capture the provider-owned values currently selected on an Automation draft. */ - getAutomationSessionTemplate(session: ISession): Promise; + /** + * Capture the provider-owned values currently selected on an Automation draft. + * `null` means the provider does not support capture; `undefined` means the draft was replaced. + */ + getAutomationSessionConfiguration(session: ISession): Promise; + + /** Whether the session's provider combines Mode and Model controls on phone layouts. */ + usesCombinedNewSessionConfigPicker(session: ISession): boolean; /** * Create a new session for the given folder. diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 035cbd9de4b1c5..b69bc62fe19729 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -53,6 +53,16 @@ export interface ISessionsProviderCreateSessionOptions { readonly metadata?: Record; /** Provider-owned values restored into the draft before its first configuration resolution. */ readonly sessionTemplate?: IAutomationSessionTemplate; + /** Complete Automation state for providers that also own compatibility projections. */ + readonly automationConfiguration?: IAutomationSessionConfiguration; +} + +/** Provider-owned Automation draft state plus temporary compatibility projections. */ +export interface IAutomationSessionConfiguration { + readonly sessionTemplate?: IAutomationSessionTemplate; + readonly modelId?: string; + readonly mode?: string; + readonly permissionLevel?: string; } /** Programmatic worktree settings applied together before a new session starts. */ @@ -243,6 +253,9 @@ export interface ISessionsProvider { */ readonly supportsQuickChats?: boolean; + /** Whether phone layouts replace separate Mode and Model controls with one picker. */ + readonly usesCombinedNewSessionConfigPicker?: boolean; + /** * Optional. Fires when a capability flag that consumers gate UI on (e.g. * {@link supportsQuickChats}) changes at runtime, so they can re-evaluate. @@ -310,7 +323,7 @@ export interface ISessionsProvider { deleteNewSession(sessionId: string): void; /** Capture the provider-owned values currently selected on an Automation draft. */ - getAutomationSessionTemplate?(sessionId: string): Promise; + getAutomationSessionConfiguration?(sessionId: string): Promise; /** * Get the session types supported for a given workspace URI. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 492176ccd7ac3a..1b5ce9e2cf287e 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -216,7 +216,8 @@ class MockSessionStore implements ISessionsManagementService { createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } - getAutomationSessionTemplate(): Promise { return Promise.resolve(undefined); } + getAutomationSessionConfiguration(): Promise { return Promise.resolve(undefined); } + usesCombinedNewSessionConfigPicker(): boolean { return false; } createQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } forkChatInSession(_session: ISession, _sourceChat: URI, _turnId: string): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 6b509a7277afc7..6fc334809e8fb2 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -2961,14 +2961,14 @@ suite('SessionsManagementService', () => { createOptions.push(options); return drafts[createIndex++]; } - override async getAutomationSessionTemplate(): Promise { return sessionTemplate; } + override async getAutomationSessionConfiguration() { return { sessionTemplate }; } override deleteNewSession(sessionId: string): void { deleted.push(sessionId); } }(drafts[0]); const { service } = createSessionsManagementService(drafts[0], disposables, provider); const folderUri = URI.parse('test:///folder'); const firstAutomationSession = service.createAutomationSession(folderUri, { sessionTemplate }); - const capturedTemplate = await service.getAutomationSessionTemplate(firstAutomationSession); + const capturedConfiguration = await service.getAutomationSessionConfiguration(firstAutomationSession); service.createNewSession(folderUri); service.createAutomationQuickChat({ sessionTemplate }); service.discardAutomationSession(firstAutomationSession); @@ -2978,13 +2978,13 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual({ newSession: service.newSession.get()?.sessionId, automationSession: service.automationSession.get()?.sessionId, - capturedTemplate, + capturedConfiguration, createOptions, deleted, }, { newSession: 'new-session', automationSession: undefined, - capturedTemplate: sessionTemplate, + capturedConfiguration: { sessionTemplate }, createOptions: [ { metadata: undefined, sessionTemplate }, { metadata: undefined, sessionTemplate: undefined }, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1882af7904d22a..6d7000716e0a92 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -441,6 +441,7 @@ export class OpenPermissionPickerAction extends Action2 { ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.inAutomationsDialog.negate(), ContextKeyExpr.or( ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), 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 aaac9757e0a8f6..d51ea36e44d841 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -73,7 +73,6 @@ import { WorkbenchList } from '../../../../../../platform/list/browser/listServi import { canLog, ILogService, LogLevel } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; -import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IVoiceModeOnboardingService } from '../../../../agentsVoice/browser/voiceModeOnboarding.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; @@ -168,7 +167,7 @@ import { ChatPetAchievementIds, didExplicitlySwitchChatPetModel } from '../../ch import { IChatPetService } from '../../chatPetService.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { ModelPickerActionItem, IModelPickerDelegate, IModelPickerPresentationOptions } from './modelPicker/modelPickerActionItem.js'; -import { IModePickerDelegate, isModeConsideredBuiltIn, ModePickerActionItem } from './modePickerActionItem.js'; +import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; @@ -275,6 +274,7 @@ export interface IChatInputPartOptions { menus: { executeToolbar: MenuId; telemetrySource: string; + inputToolbar?: MenuId; inputSideToolbar?: MenuId; }; editorOverflowWidgetsDomNode?: HTMLElement; @@ -310,15 +310,6 @@ export interface IChatInputPartOptions { * Returns true when the action was handled. */ secondaryToolbarOverflowActionHandler?: (actionId: string, anchor: HTMLElement) => boolean; - /** - * When true, the mode picker hides custom agents and only offers the - * built-in modes (Agent / Ask / Edit / Plan, gated by their normal - * visibility rules). Custom-agent discovery is workspace-scoped and - * doesn't follow the dialog's folder selection, so surfacing custom - * agents tied to the workbench's open folders would mislead the user - * when scheduling against a different folder. - */ - hideCustomChatModes?: boolean; /** * When true, suppress the autorun that switches the current language * model to a mode's declared preferred model (`IChatMode.model`). @@ -916,7 +907,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatAttachmentWidgetRegistry private readonly _chatAttachmentWidgetRegistry: IChatAttachmentWidgetRegistry, @IChatInputNotificationService private readonly chatInputNotificationService: IChatInputNotificationService, @IChatPhoneInputPresenter private readonly chatPhoneInputPresenter: IChatPhoneInputPresenter, - @IProductService private readonly productService: IProductService, @IVoiceModeOnboardingService private readonly voiceModeOnboardingService: IVoiceModeOnboardingService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @@ -1449,35 +1439,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _createModePickerDelegate(): IModePickerDelegate { - // When `hideCustomChatModes` is set (e.g. the automations dialog), - // strip genuinely user-defined custom agents from the picker - // while preserving extension-contributed modes (Plan / new-Ask / - // new-Edit) that the picker categorises as built-in via - // `isModeConsideredBuiltIn`. Those live in `IChatModes.custom` but - // are part of the built-in product surface, not the - // folder-scoped agent files we want to hide. The underlying - // observable is untouched so mode validation, model picking and - // persistence continue to see the real list. - const productService = this.productService; - const currentChatModes: IObservable = this.options.hideCustomChatModes - ? derived(reader => { - const inner = this._currentChatModesObservable.read(reader); - const filteredCustom = inner.custom.filter(m => isModeConsideredBuiltIn(m, productService)); - const wrapped: IChatModes = { - onDidChange: inner.onDidChange, - builtin: inner.builtin, - custom: filteredCustom, - findModeById: (id: string) => inner.builtin.find(m => m.id === id) ?? filteredCustom.find(m => m.id === id), - findModeByName: (name: string) => inner.builtin.find(m => m.name.read(undefined) === name) ?? filteredCustom.find(m => m.name.read(undefined) === name), - waitForPendingUpdates: () => inner.waitForPendingUpdates(), - }; - return wrapped; - }) - : this._currentChatModesObservable; - return { currentMode: this._currentModeObservable, - currentChatModes, + currentChatModes: this._currentChatModesObservable, sessionResource: () => this._widget?.viewModel?.sessionResource, // Direct setter for hosts that embed `ChatInputPart` without // registering an `IChatWidget` (e.g. the automations dialog). @@ -3545,7 +3509,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); this._register(dom.addStandardDisposableListener(this.attachmentsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); - this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.options.renderInputToolbarBelowInput ? this.attachmentsContainer : toolbarsContainer, MenuId.ChatInput, { + const inputToolbarMenu = this.options.menus.inputToolbar ?? MenuId.ChatInput; + this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.options.renderInputToolbarBelowInput ? this.attachmentsContainer : toolbarsContainer, inputToolbarMenu, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -3557,7 +3522,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge actionMinWidth: 48, getActionMinWidth: getInputActionMinWidth, allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, - getOverflowAction: (action, getAnchor) => getOverflowAction(action, MenuId.ChatInput, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), + getOverflowAction: (action, getAnchor) => getOverflowAction(action, inputToolbarMenu, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), }, actionViewItemProvider: (action, options) => { // Phone-layout branch: when an agents-window phone presenter From 6f677db46442305534de01008200663474114209 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 03:08:21 +0200 Subject: [PATCH 06/15] automations: refactor: unify fallback session configuration Pass the complete provider-owned Automation configuration during older-host draft creation so browser fallback and AHP execution use the same template semantics. Keep workspace isolation and branch configuration target-owned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../automations/browser/automationRunner.ts | 23 ++++++-- .../test/browser/automationRunner.test.ts | 59 ++++++++++++++----- .../localAgentHostSessionsProvider.test.ts | 40 +++++++++++++ .../browser/sessionsManagementService.ts | 12 +++- .../browser/sessionsManagementService.test.ts | 37 ++++++++++++ 5 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index 883e428c911d92..16a8bcaa27cbdf 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -16,6 +16,7 @@ import { IAutomationService } from '../../../../workbench/contrib/chat/common/au import { publishAutomationRun, publishAutomationRunError } from '../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; /** Sessions-layer runner. Never throws; failures are recorded on the run row. */ export class AutomationRunner implements IAutomationRunner { @@ -82,14 +83,26 @@ export class AutomationRunner implements IAutomationRunner { ? target.isolation.kind === 'folder' ? 'workspace' : target.isolation.kind === 'worktree' ? 'worktree' : undefined : undefined; const branch = target.kind === 'workspace' && target.isolation.kind === 'worktree' ? target.isolation.branch : undefined; + const automationConfiguration: IAutomationSessionConfiguration | undefined = automation.sessionTemplate !== undefined + || automation.modelId !== undefined + || automation.mode !== undefined + || automation.permissionLevel !== undefined + ? { + sessionTemplate: automation.sessionTemplate, + modelId: automation.modelId, + mode: automation.mode, + permissionLevel: automation.permissionLevel, + } + : undefined; - const createOptions: ICreateNewSessionOptions | undefined = target.providerId !== undefined || target.sessionTypeId !== undefined || automation.modelId !== undefined || automation.mode !== undefined || automation.permissionLevel !== undefined || isolationMode !== undefined || branch !== undefined + const createOptions: ICreateNewSessionOptions | undefined = target.providerId !== undefined || target.sessionTypeId !== undefined || automationConfiguration !== undefined || isolationMode !== undefined || branch !== undefined ? { providerId: target.providerId, sessionTypeId: target.sessionTypeId, - modelId: automation.modelId, - modeId: automation.mode, - permissionLevel: automation.permissionLevel, + ...(automationConfiguration ? { + sessionTemplate: automation.sessionTemplate, + automationConfiguration, + } : {}), isolationMode, branch, } @@ -162,7 +175,7 @@ export class AutomationRunner implements IAutomationRunner { title: automation.name?.substring(0, 100), }; - this.logService.trace(`[AutomationRunner] running ${automation.id}: target=${target.kind}, provider=${createOptions?.providerId ?? '(default)'}, sessionType=${createOptions?.sessionTypeId ?? '(default)'}, model=${createOptions?.modelId ?? '(default)'}, mode=${createOptions?.modeId ?? '(default)'}, permissionLevel=${createOptions?.permissionLevel ?? '(default)'}`); + this.logService.trace(`[AutomationRunner] running ${automation.id}: target=${target.kind}, provider=${createOptions?.providerId ?? '(default)'}, sessionType=${createOptions?.sessionTypeId ?? '(default)'}, model=${automationConfiguration?.modelId ?? '(default)'}, mode=${automationConfiguration?.mode ?? '(default)'}, permissionLevel=${automationConfiguration?.permissionLevel ?? '(default)'}`); this.logService.info(`[AutomationRunner] creating a session for run ${runId} (automation ${automation.id}).`); let session: ISession | undefined; diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index 3362dbc6d56902..b21f39a3e0e77d 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -367,9 +367,6 @@ suite('AutomationRunner', () => { createOptions: { providerId: 'local-agent-host', sessionTypeId: 'copilotcli', - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: undefined, branch: undefined, }, @@ -575,9 +572,6 @@ suite('AutomationRunner', () => { assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { providerId: 'local-agent-host', sessionTypeId: 'agent-host-copilotcli', - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: undefined, branch: undefined, }); @@ -601,9 +595,50 @@ suite('AutomationRunner', () => { assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, - modeId: 'agent', - permissionLevel: 'autopilot', + sessionTemplate: undefined, + automationConfiguration: { + sessionTemplate: undefined, + modelId: undefined, + mode: 'agent', + permissionLevel: 'autopilot', + }, + isolationMode: undefined, + branch: undefined, + }); + }); + + test('passes the complete session template at draft creation', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + const sessionTemplate = { + modelId: 'model', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: true, + }, + }; + const automation = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: hourly(), + target: workspaceTarget(FOLDER_A, { providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }), + sessionTemplate, + }); + + await runner.runOnce(automation, 'schedule', 1).whenCompleted; + + assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { + providerId: 'local-agent-host', + sessionTypeId: 'copilotcli', + sessionTemplate, + automationConfiguration: { + sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, + }, isolationMode: undefined, branch: undefined, }); @@ -633,18 +668,12 @@ suite('AutomationRunner', () => { { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: 'worktree', branch: 'feature/worktree', }, { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, - modeId: undefined, - permissionLevel: undefined, isolationMode: 'workspace', branch: undefined, }, 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 c6d648bbbb2c51..42a1a7c2838ffe 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 @@ -4526,6 +4526,46 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('createNewSession restores legacy Automation fields at the provider boundary', async () => { + const provider = createProvider(disposables, agentHost); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { + automationConfiguration: { + modelId: 'agent-host-copilotcli:auto', + mode: 'autopilot', + permissionLevel: 'assisted', + }, + }, + ); + const captured = await provider.getAutomationSessionConfiguration(session.sessionId); + + assert.deepStrictEqual({ + initialConfig: agentHost.resolveSessionConfigRequests.at(-1)?.config, + modelId: session.modelId.get(), + captured, + }, { + initialConfig: { + mode: 'autopilot', + autoApprove: 'assisted', + }, + modelId: 'agent-host-copilotcli:auto', + captured: { + sessionTemplate: { + modelId: 'agent-host-copilotcli:auto', + config: { + mode: 'autopilot', + autoApprove: 'assisted', + }, + }, + modelId: 'agent-host-copilotcli:auto', + mode: 'autopilot', + permissionLevel: 'assisted', + }, + }); + }); + test('Automation drafts display policy-clamped approvals without overwriting the saved preference', async () => { const sessionTemplate = { config: { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index eaf035de5e0c1d..4628cd92778dae 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -877,7 +877,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa throw new WorkspaceNotTrustedError(); } } - const session = provider.createNewSession(folderUri, sessionTypeId, { metadata: createOptions?.metadata, sessionTemplate: createOptions?.sessionTemplate }); + const session = provider.createNewSession(folderUri, sessionTypeId, { + metadata: createOptions?.metadata, + sessionTemplate: createOptions?.sessionTemplate, + ...(createOptions?.automationConfiguration ? { automationConfiguration: createOptions.automationConfiguration } : {}), + }); this._unlistedNewSessions.set(session.resource, session); const requestActivity = new MutableDisposable(); try { @@ -901,7 +905,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async createAndSendQuickChatRequest(options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(createOptions); - const session = provider.createQuickChat(sessionTypeId, { metadata: createOptions?.metadata, sessionTemplate: createOptions?.sessionTemplate }); + const session = provider.createQuickChat(sessionTypeId, { + metadata: createOptions?.metadata, + sessionTemplate: createOptions?.sessionTemplate, + ...(createOptions?.automationConfiguration ? { automationConfiguration: createOptions.automationConfiguration } : {}), + }); return this._configureAndSendNewSession(provider, session, options, createOptions, false, token); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 6fc334809e8fb2..5e428969eb96e3 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -1843,6 +1843,43 @@ suite('SessionsManagementService', () => { assert.strictEqual(view.activeSession.get(), undefined); }); + test('createAndSendNewChatRequest restores Automation configuration during draft creation', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + }); + let providerOptions: ISessionsProviderCreateSessionOptions | undefined; + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override createNewSession(_folderUri?: URI, _sessionTypeId?: string, options?: ISessionsProviderCreateSessionOptions): ISession { + providerOptions = options; + return session; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const sessionTemplate = { + modelId: 'model', + config: { mode: 'plan', autoApprove: 'assisted' }, + }; + const automationConfiguration = { + sessionTemplate, + modelId: 'model', + mode: 'plan', + permissionLevel: 'assisted', + }; + + await service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { + sessionTemplate, + automationConfiguration, + }); + + assert.deepStrictEqual(providerOptions, { + metadata: undefined, + sessionTemplate, + automationConfiguration, + }); + }); + test('createAndSendNewChatRequest prepares request options while configuring the provisional session', async () => { const session = stubSession({ sessionId: 's1', From aa958f948501e49ed5da320482767c69251fb0bb Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 03:35:45 +0200 Subject: [PATCH 07/15] automations: refactor: expose canonical provider configuration Round-trip complete provider session templates through Automation tools and stop new dialog and AHP projections from writing flattened aliases. Keep legacy rows and inputs compatible while enforcing template-first execution, duplication, telemetry, and rollback semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../browser/automationDialogService.ts | 9 -- .../automations/browser/automationRunner.ts | 8 +- .../automations/browser/automationService.ts | 16 ++- .../automations/browser/automationTools.ts | 126 ++++++++++++++++-- .../browser/providerAutomationService.ts | 11 +- .../test/browser/automationRunner.test.ts | 9 +- .../test/browser/automationService.test.ts | 35 +++++ .../test/browser/automationTools.test.ts | 93 ++++++++++++- .../browser/providerAutomationService.test.ts | 9 +- .../browser/agentHostAutomationStore.ts | 16 +-- .../browser/agentHostAutomationStore.test.ts | 10 +- .../browser/copilotChatSessionsProvider.ts | 6 +- .../sessions/browser/views/automationsView.ts | 4 +- .../test/browser/automationsView.test.ts | 16 ++- .../chat/common/automations/automation.ts | 6 +- .../common/automations/automationService.ts | 6 + .../common/automations/automationTelemetry.ts | 11 +- 17 files changed, 319 insertions(+), 72 deletions(-) diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 26b262e4990416..279239612fdd97 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -222,9 +222,6 @@ export class AutomationDialogService implements IAutomationDialogService { const prompt = getPrompt(); const sessionConfiguration = await getSessionConfiguration(); const sessionTemplate = sessionConfiguration?.sessionTemplate; - const mode = sessionConfiguration?.mode; - const permissionLevel = sessionConfiguration?.permissionLevel; - const modelId = sessionConfiguration?.modelId; const branch = getBranch(); const target = createAutomationTarget(state, branch); if (!target) { @@ -239,9 +236,6 @@ export class AutomationDialogService implements IAutomationDialogService { target, ...(sessionConfiguration ? { sessionTemplate: sessionTemplate ?? null, - modelId: modelId ?? null, - mode: mode ?? null, - permissionLevel: permissionLevel ?? null, } : {}), enabled: state.enabled, }; @@ -254,9 +248,6 @@ export class AutomationDialogService implements IAutomationDialogService { schedule, target, sessionTemplate, - modelId, - mode, - permissionLevel, enabled: state.enabled, }; return { kind: 'create', value: create }; diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index 16a8bcaa27cbdf..42ad59399b9ee5 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -83,15 +83,17 @@ export class AutomationRunner implements IAutomationRunner { ? target.isolation.kind === 'folder' ? 'workspace' : target.isolation.kind === 'worktree' ? 'worktree' : undefined : undefined; const branch = target.kind === 'workspace' && target.isolation.kind === 'worktree' ? target.isolation.branch : undefined; + const templateMode = automation.sessionTemplate?.config?.['mode']; + const templatePermissionLevel = automation.sessionTemplate?.config?.['autoApprove']; const automationConfiguration: IAutomationSessionConfiguration | undefined = automation.sessionTemplate !== undefined || automation.modelId !== undefined || automation.mode !== undefined || automation.permissionLevel !== undefined ? { sessionTemplate: automation.sessionTemplate, - modelId: automation.modelId, - mode: automation.mode, - permissionLevel: automation.permissionLevel, + modelId: automation.sessionTemplate?.modelId ?? automation.modelId, + mode: typeof templateMode === 'string' ? templateMode : automation.mode, + permissionLevel: typeof templatePermissionLevel === 'string' ? templatePermissionLevel : automation.permissionLevel, } : undefined; diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index 71b945c588a6e0..0b0cedc3b4a108 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -726,13 +726,17 @@ function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomatio const target = patch.target ? normalizeAutomationTarget(patch.target) : current.target; const targetAuthorityChanged = patch.target !== undefined && (target.providerId !== current.target.providerId || target.sessionTypeId !== current.target.sessionTypeId); - const modelId = patch.modelId === null ? undefined : (patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId)); - const mode = patch.mode === null ? undefined : (patch.mode ?? (targetAuthorityChanged ? undefined : current.mode)); - const permissionLevel = patch.permissionLevel === null + const currentModelId = current.sessionTemplate?.modelId ?? current.modelId; + const currentMode = readString(current.sessionTemplate?.config?.['mode']) ?? current.mode; + const currentPermissionLevel = readString(current.sessionTemplate?.config?.['autoApprove']) ?? current.permissionLevel; + const templatePatched = patch.sessionTemplate !== undefined; + const modelId = templatePatched ? undefined : patch.modelId === null ? undefined : (patch.modelId ?? (targetAuthorityChanged ? undefined : currentModelId)); + const mode = templatePatched ? undefined : patch.mode === null ? undefined : (patch.mode ?? (targetAuthorityChanged ? undefined : currentMode)); + const permissionLevel = templatePatched || patch.permissionLevel === null ? undefined : patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) ? patch.permissionLevel - : targetAuthorityChanged ? ChatPermissionLevel.Default : current.permissionLevel; + : targetAuthorityChanged ? ChatPermissionLevel.Default : currentPermissionLevel; const sessionTemplate = patch.sessionTemplate === null ? undefined : patch.sessionTemplate ?? (targetAuthorityChanged @@ -752,6 +756,10 @@ function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomatio }; } +function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + function normalizeAutomationTarget(target: AutomationTarget): AutomationTarget { if (target.kind === 'quickChat') { if (!target.providerId || !target.sessionTypeId) { diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 056d0bc0551190..8af427150f6101 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -15,7 +15,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { AutomationInterval, AutomationTarget, AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { AutomationInterval, AutomationTarget, AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, IAutomationSchedule, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationRunDispatch, IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; import { type AutomationMutationGuard, ConfigureAutomationToolReferenceName, IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, serializeAutomationEditableState } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; @@ -55,9 +55,10 @@ interface IAutomationToolOutput { readonly providerId: string; readonly sessionTypeId: string; }; - readonly modelId: string | null; - readonly mode: string | null; - readonly permissionLevel: string | null; + readonly modelId?: string | null; + readonly mode?: string | null; + readonly permissionLevel?: string | null; + readonly sessionTemplate?: IAutomationSessionTemplate; readonly enabled: boolean; readonly createdAt: string; readonly updatedAt: string; @@ -151,7 +152,7 @@ export class RunAutomationTool implements IToolImpl { icon: Codicon.play, displayName: localize('automation.tool.run.displayName', "Run Automation"), userDescription: localize('automation.tool.run.userDescription', "Run a configured agent automation now"), - modelDescription: 'Run a configured automation immediately by stable ID. Call listAutomations first to obtain the current ID. This starts a fresh agent session in the background using the saved prompt, target, model, mode, and permission level, even when scheduled runs are disabled. The tool returns after session dispatch commits; do not run it again unless the user asks.', + modelDescription: 'Run a configured automation immediately by stable ID. Call listAutomations first to obtain the current ID. This starts a fresh agent session in the background using the saved prompt, target, and provider session configuration, even when scheduled runs are disabled. The tool returns after session dispatch commits; do not run it again unless the user asks.', source: ToolDataSource.Internal, when: automationToolWhen, runsInWorkspace: false, @@ -374,6 +375,8 @@ Create a new automation only when the user explicitly asks for an automation, or Omit "automationId" to create an automation; "name", "prompt", and "schedule.interval" are then required. If "target" is omitted, the automation targets the current Agents window session. Include "automationId" to update an existing automation, and only provide fields that should change. Call listAutomations first to obtain the stable ID and current values. +Use "sessionTemplate" for provider-owned Model, Agent, Mode, Approvals, and other configuration returned by listAutomations. Omit it on unrelated partial updates, or set it to null to reset provider configuration. Do not combine it with the legacy "modelId", "mode", or "permissionLevel" aliases. + The change uses the current tool-approval policy. When approval is required, the user sees a normal tool confirmation. If the user cancels or denies the request, do not retry unless they ask you to.`, source: ToolDataSource.Internal, when: automationToolWhen, @@ -459,15 +462,40 @@ The change uses the current tool-approval policy. When approval is required, the }, modelId: { type: ['string', 'null'], - description: 'Language model ID, or null to use the provider default.', + description: 'Legacy model alias. Use sessionTemplate for provider-owned configuration.', }, mode: { type: ['string', 'null'], - description: 'Provider mode identifier, or null to use the provider default.', + description: 'Legacy Mode alias. Use sessionTemplate for provider-owned configuration.', }, permissionLevel: { enum: [...chatPermissionLevels, null], - description: 'Permission level, or null to use the provider default.', + description: 'Legacy Approvals alias. Use sessionTemplate for provider-owned configuration.', + }, + sessionTemplate: { + type: ['object', 'null'], + additionalProperties: false, + description: 'Provider-owned session configuration returned by listAutomations, or null to reset it.', + properties: { + modelId: { + type: ['string', 'null'], + description: 'Provider model identifier, or null to use its default.', + }, + agent: { + type: ['object', 'null'], + additionalProperties: false, + description: 'Provider custom-agent selection, or null for none.', + properties: { + uri: { type: 'string' }, + }, + required: ['uri'], + }, + config: { + type: ['object', 'null'], + description: 'Opaque JSON-safe provider configuration.', + additionalProperties: true, + }, + }, }, enabled: { type: 'boolean', @@ -644,7 +672,7 @@ The change uses the current tool-approval policy. When approval is required, the throw new AutomationToolInputError('configureAutomation input must be an object.'); } const input = rawInput; - assertKnownProperties(input, ['automationId', 'name', 'prompt', 'schedule', 'target', 'modelId', 'mode', 'permissionLevel', 'enabled'], 'configureAutomation input'); + assertKnownProperties(input, ['automationId', 'name', 'prompt', 'schedule', 'target', 'modelId', 'mode', 'permissionLevel', 'sessionTemplate', 'enabled'], 'configureAutomation input'); const automationId = readOptionalNonEmptyString(input, 'automationId'); const existing = automationId ? this.automationService.getAutomation(automationId) : undefined; @@ -667,6 +695,10 @@ The change uses the current tool-approval policy. When approval is required, the const modelId = readOptionalNullableNonEmptyString(input, 'modelId'); const mode = readOptionalNullableNonEmptyString(input, 'mode'); const permissionLevel = readOptionalNullableEnum(input, 'permissionLevel', chatPermissionLevels); + const sessionTemplate = parseSessionTemplate(input); + if (sessionTemplate !== undefined && (modelId !== undefined || mode !== undefined || permissionLevel !== undefined)) { + throw new AutomationToolInputError('"sessionTemplate" cannot be combined with legacy "modelId", "mode", or "permissionLevel" aliases.'); + } const enabled = readOptionalBoolean(input, 'enabled'); const proposedValues: IUpdateAutomationOptions = { @@ -677,6 +709,7 @@ The change uses the current tool-approval policy. When approval is required, the ...(modelId !== undefined ? { modelId } : {}), ...(mode !== undefined ? { mode } : {}), ...(permissionLevel !== undefined ? { permissionLevel } : {}), + ...(sessionTemplate !== undefined ? { sessionTemplate } : {}), ...(enabled !== undefined ? { enabled } : {}), }; const validateTargetAvailability = input.target !== undefined @@ -704,6 +737,7 @@ The change uses the current tool-approval policy. When approval is required, the ...(modelId ? { modelId } : {}), ...(mode ? { mode } : {}), ...(permissionLevel ? { permissionLevel } : {}), + ...(sessionTemplate ? { sessionTemplate } : {}), ...(enabled !== undefined ? { enabled } : {}), }, validateTargetAvailability, @@ -845,6 +879,70 @@ function parseTarget(input: Record, existing: IAutomationDescri return { kind: 'workspace', folderUri, providerId, sessionTypeId, isolation }; } +function parseSessionTemplate(input: Record): IAutomationSessionTemplate | null | undefined { + const value = input['sessionTemplate']; + if (value === undefined || value === null) { + return value; + } + if (!isRecord(value)) { + throw new AutomationToolInputError('"sessionTemplate" must be an object or null.'); + } + assertKnownProperties(value, ['modelId', 'agent', 'config'], '"sessionTemplate"'); + const modelId = readOptionalNullableNonEmptyString(value, 'modelId'); + + const rawAgent = value['agent']; + let agent: IAutomationSessionTemplate['agent']; + if (rawAgent !== undefined && rawAgent !== null) { + if (!isRecord(rawAgent)) { + throw new AutomationToolInputError('"sessionTemplate.agent" must be an object or null.'); + } + assertKnownProperties(rawAgent, ['uri'], '"sessionTemplate.agent"'); + const uri = readOptionalNonEmptyString(rawAgent, 'uri'); + if (!uri) { + throw new AutomationToolInputError('"sessionTemplate.agent.uri" is required.'); + } + agent = { uri }; + } + + const rawConfig = value['config']; + let config: Readonly> | undefined; + if (rawConfig !== undefined && rawConfig !== null) { + if (!isRecord(rawConfig)) { + throw new AutomationToolInputError('"sessionTemplate.config" must be an object or null.'); + } + config = cloneJsonObject(rawConfig, 'sessionTemplate.config'); + } + return { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent } : {}), + ...(config ? { config } : {}), + }; +} + +function cloneJsonObject(value: Record, field: string): Record { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new AutomationToolInputError(`"${field}" must contain only JSON values.`); + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneJsonValue(entry, `${field}.${key}`)])); +} + +function cloneJsonValue(value: unknown, field: string): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (Array.isArray(value)) { + return value.map((entry, index) => cloneJsonValue(entry, `${field}[${index}]`)); + } + if (isRecord(value)) { + return cloneJsonObject(value, field); + } + throw new AutomationToolInputError(`"${field}" must be JSON-safe.`); +} + function parseUri(value: string, field: string): URI { try { const uri = URI.parse(value, true); @@ -877,9 +975,13 @@ function toAutomationToolOutput(automation: IAutomationDescriptor): IAutomationT prompt: automation.prompt, schedule: automation.schedule, target, - modelId: automation.modelId ?? null, - mode: automation.mode ?? null, - permissionLevel: automation.permissionLevel ?? null, + ...(automation.sessionTemplate + ? { sessionTemplate: automation.sessionTemplate } + : { + modelId: automation.modelId ?? null, + mode: automation.mode ?? null, + permissionLevel: automation.permissionLevel ?? null, + }), enabled: automation.enabled, createdAt: automation.createdAt, updatedAt: automation.updatedAt, diff --git a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index 0bbd54c7e0c1ef..bb6c094fee45c9 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -298,10 +298,13 @@ export class ProviderAutomationService extends Disposable implements IAutomation prompt: previous.prompt, schedule: previous.schedule, target: previous.target, - sessionTemplate: previous.sessionTemplate ?? null, - modelId: previous.modelId ?? null, - mode: previous.mode ?? null, - permissionLevel: previous.permissionLevel ?? null, + ...(previous.sessionTemplate + ? { sessionTemplate: previous.sessionTemplate } + : { + modelId: previous.modelId ?? null, + mode: previous.mode ?? null, + permissionLevel: previous.permissionLevel ?? null, + }), enabled: previous.enabled, }, expected); if (result.kind === 'conflict') { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index b21f39a3e0e77d..95b2d302b37bca 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -625,6 +625,9 @@ suite('AutomationRunner', () => { schedule: hourly(), target: workspaceTarget(FOLDER_A, { providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }), sessionTemplate, + modelId: 'stale-model', + mode: 'interactive', + permissionLevel: 'default', }); await runner.runOnce(automation, 'schedule', 1).whenCompleted; @@ -635,9 +638,9 @@ suite('AutomationRunner', () => { sessionTemplate, automationConfiguration: { sessionTemplate, - modelId: undefined, - mode: undefined, - permissionLevel: undefined, + modelId: 'model', + mode: 'plan', + permissionLevel: 'assisted', }, isolationMode: undefined, branch: undefined, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index 96daaf90e2a133..ef266a78c60524 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -178,6 +178,41 @@ suite('AutomationService', () => { }); }); + test('an explicit session template replaces stale legacy aliases', async () => { + const { service } = createService(); + const automation = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate: { + modelId: 'old-model', + config: { mode: 'interactive', autoApprove: 'default' }, + }, + modelId: 'old-model', + mode: 'interactive', + permissionLevel: 'default', + }); + const sessionTemplate = { + modelId: 'new-model', + config: { mode: 'plan', autoApprove: 'assisted' }, + }; + + const updated = await service.updateAutomation(automation.id, { sessionTemplate }); + + assert.deepStrictEqual({ + sessionTemplate: updated.sessionTemplate, + modelId: updated.modelId, + mode: updated.mode, + permissionLevel: updated.permissionLevel, + }, { + sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, + }); + }); + test('createAutomation with manual schedule leaves nextRunAt undefined', async () => { const { service } = createService(); const a = await service.createAutomation({ diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index db3573e3f6a538..3af7687f668d61 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -106,6 +106,7 @@ class FakeAutomationService extends mock() { prompt: patch.prompt ?? existing.prompt, schedule: patch.schedule ?? existing.schedule, target: patch.target ?? existing.target, + sessionTemplate: patch.sessionTemplate === null ? undefined : patch.sessionTemplate ?? existing.sessionTemplate, modelId: patch.modelId === null ? undefined : patch.modelId ?? existing.modelId, mode: patch.mode === null ? undefined : patch.mode ?? existing.mode, permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? existing.permissionLevel, @@ -406,15 +407,28 @@ suite('AutomationTools', () => { requiresExplicitAutomationIntent: modelDescription.includes('only when the user explicitly asks for an automation'), allowsRecurringScheduleIntent: modelDescription.includes('or for a prompt to run on a recurring schedule'), excludesMonitoringRequests: modelDescription.includes('Do not infer that intent from requests merely to monitor, watch, follow, or keep something'), + usesProviderTemplate: modelDescription.includes('Use "sessionTemplate" for provider-owned Model, Agent, Mode, Approvals'), + rejectsMixedAliases: modelDescription.includes('Do not combine it with the legacy "modelId", "mode", or "permissionLevel" aliases'), }, { requiresExplicitAutomationIntent: true, allowsRecurringScheduleIntent: true, excludesMonitoringRequests: true, + usesProviderTemplate: true, + rejectsMixedAliases: true, }); }); test('listAutomations returns stable IDs and editable fields', async () => { - const automation = createAutomation(); + const sessionTemplate = { + modelId: 'gpt-test', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'agent', + autoApprove: 'default', + providerOption: { enabled: true }, + }, + }; + const automation = createAutomation({ sessionTemplate }); const tool = new ListAutomationsTool(new FakeAutomationService([automation]), createConfigurationService()); const result = await invoke(tool, {}); @@ -432,9 +446,7 @@ suite('AutomationTools', () => { sessionTypeId: 'copilot', isolation: { kind: 'default' }, }, - modelId: 'gpt-test', - mode: 'agent', - permissionLevel: 'default', + sessionTemplate, enabled: true, createdAt: NOW, updatedAt: NOW, @@ -444,6 +456,26 @@ suite('AutomationTools', () => { }); }); + test('listAutomations emits flat aliases only for legacy rows', async () => { + const automation = createAutomation(); + const tool = new ListAutomationsTool(new FakeAutomationService([automation]), createConfigurationService()); + + const result = await invoke(tool, {}); + const listed = JSON.parse(getText(result)).automations[0]; + + assert.deepStrictEqual({ + sessionTemplate: listed.sessionTemplate, + modelId: listed.modelId, + mode: listed.mode, + permissionLevel: listed.permissionLevel, + }, { + sessionTemplate: undefined, + modelId: 'gpt-test', + mode: 'agent', + permissionLevel: 'default', + }); + }); + test('runAutomation confirms and starts a manual run', async () => { const automation = createAutomation(); const automationService = new FakeAutomationService([automation]); @@ -857,6 +889,40 @@ suite('AutomationTools', () => { }]); }); + test('configureAutomation updates the complete provider session template', async () => { + const existing = createAutomation({ + sessionTemplate: { + modelId: 'old-model', + config: { mode: 'interactive', providerOption: false }, + }, + }); + const automationService = new FakeAutomationService([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + const sessionTemplate = { + modelId: 'new-model', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }; + + await invoke(tool, { + automationId: existing.id, + sessionTemplate, + }); + + assert.deepStrictEqual(automationService.updated, [{ + id: existing.id, + patch: { sessionTemplate }, + }]); + }); + test('configureAutomation rejects editable changes made while awaiting approval', async () => { const existing = createAutomation(); const automationService = new FakeAutomationService([existing]); @@ -1160,13 +1226,32 @@ suite('AutomationTools', () => { branch: 'main', }, }); + const mixedConfigurationResult = await invoke(tool, { + name: 'Mixed configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target: { kind: 'workspace', folderUri: FOLDER.toString() }, + mode: 'agent', + sessionTemplate: { config: { mode: 'plan' } }, + }); + const unsafeConfigurationResult = await invoke(tool, { + name: 'Unsafe configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target: { kind: 'workspace', folderUri: FOLDER.toString() }, + sessionTemplate: { config: { value: new Date(0) } }, + }); assert.deepStrictEqual({ staleError: staleResult.toolResultError, targetError: malformedTargetResult.toolResultError, + mixedConfigurationError: mixedConfigurationResult.toolResultError, + unsafeConfigurationError: unsafeConfigurationResult.toolResultError, }, { staleError: 'Automation "missing" does not exist. Call listAutomations to refresh the available IDs.', targetError: '"target.folderUri" must be a valid absolute URI.', + mixedConfigurationError: '"sessionTemplate" cannot be combined with legacy "modelId", "mode", or "permissionLevel" aliases.', + unsafeConfigurationError: '"sessionTemplate.config.value" must contain only JSON values.', }); }); diff --git a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts index 2d0ec7065aa580..2dd0f328d4f849 100644 --- a/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/providerAutomationService.test.ts @@ -414,6 +414,9 @@ suite('ProviderAutomationService', () => { prompt: 'prompt', schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, target: legacyTarget, + modelId: 'legacy-model', + mode: 'ask', + permissionLevel: 'autopilot', }); await assert.rejects(service.updateAutomation(created.id, { @@ -446,9 +449,9 @@ suite('ProviderAutomationService', () => { legacyPrompt: 'prompt', legacySchedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, legacyTarget: { ...legacyTarget, folderUri: FOLDER.toString() }, - legacyModelId: undefined, - legacyMode: undefined, - legacyPermissionLevel: undefined, + legacyModelId: 'legacy-model', + legacyMode: 'ask', + legacyPermissionLevel: 'autopilot', legacyEnabled: true, legacyRunStatuses: ['pending'], }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 70f730bfba0483..3015c8352e2591 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -664,7 +664,6 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._logService.warn(`[AgentHostAutomationStore] Cannot project Automation with no provider: resource=${state.resource}.`); return undefined; } - const config = state.definition.session.config; const modelId = this._projectModelId(state.definition.session.model?.id, state.definition.session.provider); const newestRun = state.runs[0]; return { @@ -674,9 +673,6 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro schedule: projectSchedule(state.definition.triggers), target, sessionTemplate: projectAutomationSessionTemplate(state.definition, modelId), - modelId, - mode: readString(config?.[SessionConfigKey.Mode]), - permissionLevel: readString(config?.[SessionConfigKey.AutoApprove]), enabled: state.definition.enabled, createdAt: state.createdAt, updatedAt: state.modifiedAt, @@ -918,11 +914,15 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro const target = patch.target ?? current.target; const targetAuthorityChanged = patch.target !== undefined && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); - const modelId = patch.modelId === null + const currentModelId = current.sessionTemplate?.modelId ?? current.modelId; + const currentMode = readString(current.sessionTemplate?.config?.[SessionConfigKey.Mode]) ?? current.mode; + const currentPermissionLevel = readString(current.sessionTemplate?.config?.[SessionConfigKey.AutoApprove]) ?? current.permissionLevel; + const templatePatched = patch.sessionTemplate !== undefined; + const modelId = templatePatched || patch.modelId === null ? undefined - : patch.modelId ?? (targetAuthorityChanged ? undefined : current.modelId); - const mode = patch.mode === null ? undefined : patch.mode ?? (targetAuthorityChanged ? undefined : current.mode); - const permissionLevel = patch.permissionLevel === null ? undefined : patch.permissionLevel ?? (targetAuthorityChanged ? undefined : current.permissionLevel); + : patch.modelId ?? (targetAuthorityChanged ? undefined : currentModelId); + const mode = templatePatched || patch.mode === null ? undefined : patch.mode ?? (targetAuthorityChanged ? undefined : currentMode); + const permissionLevel = templatePatched || patch.permissionLevel === null ? undefined : patch.permissionLevel ?? (targetAuthorityChanged ? undefined : currentPermissionLevel); const provider = target.sessionTypeId ?? this._providerFromModelId(modelId); const sessionTemplate = patch.sessionTemplate === null ? undefined diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 113721e9fb967d..3e76a77267a02c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -867,7 +867,7 @@ suite('AgentHostAutomationStore', () => { hostDirectory: create.type === ActionType.AutomationCreateRequested ? create.definition.session.workingDirectories : undefined, hostModel: create.type === ActionType.AutomationCreateRequested ? create.definition.session.model?.id : undefined, clientDirectory: automation.target.kind === 'workspace' ? automation.target.folderUri.toString() : undefined, - clientModel: automation.modelId, + clientModel: automation.sessionTemplate?.modelId, clientSession: claim.run.sessionResource?.toString(), }, { hostDirectory: ['file:///workspace'], @@ -901,7 +901,7 @@ suite('AgentHostAutomationStore', () => { assert.deepStrictEqual({ hostModel: create.type === ActionType.AutomationCreateRequested ? create.definition.session.model?.id : undefined, - clientModel: automation.modelId, + clientModel: automation.sessionTemplate?.modelId, }, { hostModel: 'auto', clientModel: 'agent-host-copilotcli:auto', @@ -934,7 +934,7 @@ suite('AgentHostAutomationStore', () => { assert.deepStrictEqual({ hostModel: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.model : undefined, - clientModel: updated.modelId, + clientModel: updated.sessionTemplate?.modelId, }, { hostModel: undefined, clientModel: undefined, @@ -975,7 +975,7 @@ suite('AgentHostAutomationStore', () => { assert.deepStrictEqual({ hostProviders: createActions.map(action => action.definition.session.provider), hostModels: createActions.map(action => action.definition.session.model?.id), - clientModels: [defaultProvider.modelId, nativeColon.modelId], + clientModels: [defaultProvider.sessionTemplate?.modelId, nativeColon.sessionTemplate?.modelId], }, { hostProviders: ['copilotcli', 'copilotcli'], hostModels: ['auto', 'openai/gpt-5:high'], @@ -1023,7 +1023,7 @@ suite('AgentHostAutomationStore', () => { }); assert.deepStrictEqual({ - modelId: store.getAutomation('host-authored')?.modelId, + modelId: store.getAutomation('host-authored')?.sessionTemplate?.modelId, sessionResource: store.runs.get()[0].sessionResource?.toString(), }, { modelId: 'agent-host-codex:auto', diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index e48a316a94c0af..6bdae1b56506d5 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1741,11 +1741,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return; } const template = configuration.sessionTemplate; - const modelId = configuration.modelId ?? template?.modelId; + const modelId = template?.modelId ?? configuration.modelId; if (modelId) { session.setModelId(modelId, ChatModelSource.Chosen); } - const mode = configuration.mode ?? template?.config?.[SessionConfigKey.Mode]; + const mode = template?.config?.[SessionConfigKey.Mode] ?? configuration.mode; if (typeof mode === 'string') { const restored = this._setSessionMode(session, mode); if (!restored && session instanceof CopilotCLISession) { @@ -1753,7 +1753,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions void this._resolveAutomationSessionMode(session, mode); } } - const permissionLevel = configuration.permissionLevel ?? template?.config?.[SessionConfigKey.AutoApprove]; + const permissionLevel = template?.config?.[SessionConfigKey.AutoApprove] ?? configuration.permissionLevel; if (!(session instanceof RemoteNewSession) && isChatPermissionLevel(permissionLevel)) { session.setPermissionLevel(permissionLevel); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index b0c4944b46abb1..cd8ab5d17c7043 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -1358,9 +1358,7 @@ registerAction2(class DuplicateAutomationAction extends Action2 { prompt: automation.prompt, schedule: automation.schedule, target: automation.target, - modelId: automation.modelId, - mode: automation.mode, - permissionLevel: automation.permissionLevel, + sessionTemplate: automation.sessionTemplate, enabled: automation.enabled, }, }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index 58d477c0ce981e..3103148058dc45 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -861,9 +861,15 @@ suite('AutomationsCardsWidget', () => { prompt: 'Review all open issues', schedule: { interval: 'weekly', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 1 }, target: { kind: 'quickChat', providerId: 'provider', sessionTypeId: 'agent' }, - modelId: 'model', - mode: 'agent', - permissionLevel: 'autopilot', + sessionTemplate: { + modelId: 'model', + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { + mode: 'agent', + autoApprove: 'autopilot', + providerOption: true, + }, + }, enabled: false, }); automationService.setAutomations([source]); @@ -902,9 +908,7 @@ suite('AutomationsCardsWidget', () => { prompt: 'Review all open issues', schedule: source.schedule, target: source.target, - modelId: 'model', - mode: 'agent', - permissionLevel: 'autopilot', + sessionTemplate: source.sessionTemplate, enabled: false, }, }, diff --git a/src/vs/workbench/contrib/chat/common/automations/automation.ts b/src/vs/workbench/contrib/chat/common/automations/automation.ts index 9ac0b7ac6e123e..2c00079065abbd 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automation.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automation.ts @@ -78,13 +78,13 @@ export interface IAutomationDescriptor { /** Complete provider-owned session template. */ readonly sessionTemplate?: IAutomationSessionTemplate; - /** Optional language model identifier to seed the new session with. */ + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly modelId?: string; - /** Optional provider mode identifier. Defaults to the provider's mode. */ + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly mode?: string; - /** Optional permission level (`default`/`assisted`/`autoApprove`/`autopilot`). Defaults to the provider's level. */ + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly permissionLevel?: string; readonly enabled: boolean; diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index c6e8c301c140b6..1bcf54e09e6435 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -41,8 +41,11 @@ export interface ICreateAutomationOptions { readonly schedule: IAutomationSchedule; readonly target: AutomationTarget; readonly sessionTemplate?: IAutomationSessionTemplate; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly modelId?: string; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly mode?: string; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly permissionLevel?: string; readonly enabled?: boolean; } @@ -57,8 +60,11 @@ export interface IUpdateAutomationOptions { readonly schedule?: IAutomationSchedule; readonly target?: AutomationTarget; readonly sessionTemplate?: IAutomationSessionTemplate | null; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly modelId?: string | null; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly mode?: string | null; + /** @deprecated Compatibility input translated into {@link sessionTemplate}. */ readonly permissionLevel?: string | null; readonly enabled?: boolean; } diff --git a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts index 60e2508221d682..6d0268627b9b0c 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts @@ -34,7 +34,7 @@ type AutomationCreateClassification = { export function publishAutomationCreated(telemetryService: ITelemetryService, automation: IAutomationDescriptor): void { telemetryService.publicLog2('automation.create', { intervalKind: automation.schedule.interval, - permissionLevel: automation.permissionLevel ?? '', + permissionLevel: getAutomationPermissionLevel(automation), isolationMode: getAutomationIsolationMode(automation), enabled: automation.enabled, }); @@ -115,7 +115,7 @@ export function publishAutomationRun(telemetryService: ITelemetryService, args: intervalKind: args.automation.schedule.interval, success: args.success, durationMs: Math.max(0, Math.round(args.durationMs)), - permissionLevel: args.automation.permissionLevel ?? '', + permissionLevel: getAutomationPermissionLevel(args.automation), isolationMode: getAutomationIsolationMode(args.automation), }); } @@ -129,6 +129,13 @@ function getAutomationIsolationMode(automation: IAutomationDescriptor): string { : automation.target.isolation.kind === 'worktree' ? 'worktree' : ''; } +const automationPermissionLevels = new Set(['default', 'assisted', 'autoApprove', 'autopilot']); + +function getAutomationPermissionLevel(automation: IAutomationDescriptor): string { + const value = automation.sessionTemplate?.config?.['autoApprove'] ?? automation.permissionLevel; + return typeof value === 'string' && automationPermissionLevels.has(value) ? value : ''; +} + type AutomationRunErrorEvent = { trigger: AutomationRunTrigger; intervalKind: AutomationInterval; From 90a7cf01c1e9360e36dcb3e7cca02ff04aad26f3 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 04:05:03 +0200 Subject: [PATCH 08/15] automations: test: validate config after host restart Specify canonical Automation template and draft ownership across Sessions and Agent Host. Add recorded AHP coverage proving independent Mode and Approvals survive host restart into the created run session, and document the remaining Claude/Codex coverage gap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 23 ++++++ ...mode-and-approvals-after-host-restart.yaml | 38 +++++++++ .../node/e2e/suites/agentHostE2ESuites.ts | 3 +- .../test/node/e2e/suites/automationsSuite.ts | 82 ++++++++++++++++++- src/vs/sessions/AUTOMATIONS.md | 19 ++++- src/vs/sessions/SESSIONS.md | 4 + .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 6 ++ 7 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index e5cd4e99dcc043..eb94aef4ddedb5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -314,6 +314,29 @@ A user can reopen an Agent Host session and ask Copilot to edit a file. The file in `mcpPluginSuite.ts`, record one scenario at a time, and review the resulting Responses fixtures before enabling the group. +### Automation restart execution coverage is Copilot-scoped + +Users can save independent Mode and Approvals choices on an Automation and expect every later run to use them, including after the Agent Host restarts. The black-box restart scenario currently covers Copilot only, so equivalent Claude and Codex configuration persistence could regress without this suite detecting it. + +- Test: `an automation run restores Mode and Approvals after host restart`. +- Scope: Claude and Codex. +- Expected: after the host restarts, the Automation definition retains its provider Mode and Approvals and the next manual run creates a session with those same effective values. +- Observed: Copilot has deterministic model-backed replay coverage. Claude and Codex variants are not registered and have no captures. +- Gate: `automationsSuite.ts` registers the model-backed scenario only when `config.provider === 'copilotcli'`. +- Reproduce: + + Extend the provider gate and adapt the provider-specific Mode and Approvals values, then record one provider at a time: + + ```bash + AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts \ + --grep "an automation run restores Mode and Approvals after host restart" + + AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "an automation run restores Mode and Approvals after host restart" + ``` + ### Claude paused-turn cancellation is not replay-stable - Tests: diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml new file mode 100644 index 00000000000000..95ea61e602080e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-an-automation-run-restores-mode-and-approvals-after-host-restart.yaml @@ -0,0 +1,38 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ran". + response: + content: ran + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "ran". + - role: assistant + content: ran + - role: user + content: |- + You have not yet marked the task as complete using the task_complete tool. If you were planning, stop planning and start implementing. You aren't done until you have fully completed the task. + + IMPORTANT: Do NOT call task_complete if: + - You have open questions or ambiguities - make good decisions and keep working + - You encountered an error - try to resolve it or find an alternative approach + - There are remaining steps - complete them first + + Keep working autonomously until the task is truly finished, then call task_complete. + response: + content: + - type: tool_use + id: toolcall_0 + name: task_complete + input: + summary: Replied "ran" as requested. There is no further task to implement. + stopReason: tool_use diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index 3aefe4bf299509..dbe01da65c3f9a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -146,9 +146,10 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption } }); + defineAutomationsTests(context); + // Suites that contain only conformance-tier scenarios. if (options.tier === 'conformance') { - defineAutomationsTests(context); defineHostFeaturesTests(context); defineStateOperationsTests(context); defineClientFilesystemTests(context); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts index f5e065f088b381..d4ecd6f6a1573c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts @@ -4,16 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mkdtemp } from 'fs/promises'; +import { tmpdir } from 'os'; +import { retry } from '../../../../../../base/common/async.js'; import { equals } from '../../../../../../base/common/objects.js'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; import { AGENT_HOST_AUTOMATIONS_ENABLED_CONFIG_KEY, AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY } from '../../../../common/automationMigration.js'; -import type { FetchAutomationRunsResult, InitializeResult, ListAutomationTriggerDefinitionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; +import type { FetchAutomationRunsResult, InitializeResult, ListAutomationTriggerDefinitionsResult, RunAutomationResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { AutomationOperation, type AutomationDefinition, type AutomationEntry } from '../../../../common/state/protocol/state.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ActionType, type AutomationRemovedAction, type AutomationSetAction } from '../../../../common/state/sessionActions.js'; +import { ActionType, type AutomationRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationSetAction } from '../../../../common/state/sessionActions.js'; import type { AhpNotification } from '../../../../common/state/sessionProtocol.js'; -import { AUTOMATION_CATALOG_URI, MessageKind, ROOT_STATE_URI, type AutomationState, type RootState } from '../../../../common/state/sessionState.js'; -import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { AUTOMATION_CATALOG_URI, MessageKind, ROOT_STATE_URI, type AutomationRunState, type AutomationState, type RootState } from '../../../../common/state/sessionState.js'; +import { resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; /** The migration gate's message, checked before the enablement gate's. */ @@ -382,4 +390,70 @@ export function defineAutomationsTests(context: IAgentHostE2ETestContext): void restored: { title: 'Survives restart', operations: GATED_OPERATIONS }, }); }); + + if (context.tier === 'parity' && config.provider === 'copilotcli') { + test('an automation run restores Mode and Approvals after host restart', async function () { + this.timeout(240_000); + const workspace = await mkdtemp(join(tmpdir(), 'ahp-automation-session-config-')); + context.tempDirs.push(workspace); + await initializeRoot('automations-session-config'); + await openAutomationGates(); + await subscribeCatalog(); + const resource = automationResource('session-config'); + const requestedConfig = { + [SessionConfigKey.Mode]: 'autopilot', + [SessionConfigKey.AutoApprove]: 'assisted', + }; + await createAutomation(resource, { + ...buildDefinition('Configured run'), + session: { + provider: config.provider, + workingDirectories: [URI.file(workspace).toString()], + config: requestedConfig, + }, + }); + + await context.restartServer(); + await initializeRoot('automations-session-config-verify'); + await context.client.call('authenticate', { + channel: ROOT_STATE_URI, + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + token: config.githubToken ?? resolveGitHubToken(), + }, 30_000); + await openAutomationGates(); + const restored = entryFor(await subscribeCatalog(), resource); + const run = await context.client.call('runAutomation', { + channel: AUTOMATION_CATALOG_URI, + automation: resource, + requestId: `request-${generateUuid()}`, + }, 30_000); + const runSnapshot = await context.client.call('subscribe', { channel: run.resource }); + let primarySession = (runSnapshot.snapshot?.state as AutomationRunState | undefined)?.primarySession; + if (!primarySession) { + const notification = await context.client.waitForNotification(candidate => + isActionNotification(candidate, ActionType.AutomationRunPrimarySessionChanged) + && getActionEnvelope(candidate).channel === run.resource, + ); + primarySession = (getActionEnvelope(notification).action as AutomationRunPrimarySessionChangedAction).primarySession; + } + assert.ok(primarySession); + context.createdSessions.push(primarySession); + const createdSession = await fetchSessionWithChat(context.client, primarySession); + await retry(async () => { + const current = await fetchSessionWithChat(context.client, primarySession); + assert.strictEqual(current.turns.at(-1)?.state, 'complete'); + }, 100, 300); + + assert.deepStrictEqual({ + restoredConfig: restored?.definition.session.config, + sessionConfig: { + mode: createdSession.config?.values[SessionConfigKey.Mode], + autoApprove: createdSession.config?.values[SessionConfigKey.AutoApprove], + }, + }, { + restoredConfig: requestedConfig, + sessionConfig: requestedConfig, + }); + }); + } } diff --git a/src/vs/sessions/AUTOMATIONS.md b/src/vs/sessions/AUTOMATIONS.md index 3291f09c69a0b7..4e6f557ff24781 100644 --- a/src/vs/sessions/AUTOMATIONS.md +++ b/src/vs/sessions/AUTOMATIONS.md @@ -56,7 +56,7 @@ The Sessions layer direction remains defined by [LAYERS.md](LAYERS.md). Non-prov - editable name and prompt; - schedule; - execution target; -- optional model, mode, and permission selection; +- optional provider-owned session template; - enabled state; - runtime timestamps. @@ -72,6 +72,16 @@ The same logical agent may be available from multiple providers. Consumers must Workspace targets may omit `providerId` and `sessionTypeId`. Such definitions use global legacy routing and cannot migrate to a provider until their target identifies one. Quick-chat targets always identify both. +### Session template + +`IAutomationSessionTemplate` is the canonical session configuration for new definitions. It contains an optional model, optional custom agent, and opaque provider-owned configuration. Shared Automation code preserves this data but does not interpret provider Mode or Approvals vocabularies. + +Target identity remains separate from the template. The target owns provider selection, workspace, isolation, and branch. The template owns model, custom agent, Mode, Approvals, and other provider-defined session configuration. + +Saved template values are preferences, not durable permission grants. The owning provider resolves them against its current model and agent availability, configuration schema, feature enablement, and managed policy whenever a draft or run session is created. Policy may change the effective value without rewriting the saved preference. + +Legacy `modelId`, `mode`, and `permissionLevel` fields remain decode and input aliases for older ledgers and callers. New dialog and AHP projections write the session template. Compatibility aliases are translated at the owning store or provider boundary and have no authority to replace an explicit template. + ### Run `IAutomationRun` records one execution attempt. `pending` and `running` are non-terminal; `completed` and `failed` are terminal. A run may expose the created session resource once that session is committed. @@ -126,6 +136,8 @@ Before Agent Host authority is activated, the legacy store owns: - session-resource linkage; - terminal lifecycle updates. +The browser runner passes the complete saved session template into provider draft creation. Provider-owned configuration is therefore applied before the first resolution and request, using the same template that host-owned execution consumes. Workspace isolation and branch remain target-owned and are configured separately. + Renderer-window leader election prevents duplicate scheduled execution across windows. ### Agent Host authority @@ -311,12 +323,17 @@ Updates that do not change the target remain allowed while an active run delays 8. Migration and retargeting preserve concurrent edits through snapshot comparison. 9. Mixed expected deferrals and real failures are reported as failures. 10. Provider-specific state stays behind `ISessionsProviderAutomations`; UI and tools consume provider-neutral models. +11. Same-target edits preserve unknown template values unless the user explicitly changes them. +12. Retargeting does not carry a previous provider's template into the new authority. +13. Runtime policy and provider schema are revalidated for every run without treating saved configuration as a grant. ## Concrete behavior Focused tests own concrete migration, retry, repair, conflict, and execution behavior: - `contrib/automations/test/browser/automationService.test.ts`; +- `contrib/automations/test/browser/automationRunner.test.ts`; +- `contrib/automations/test/browser/automationTools.test.ts`; - `contrib/automations/test/browser/providerAutomationService.test.ts`; - `contrib/automations/test/browser/automationScheduler.test.ts`; - `contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts`. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 1d52a2c1e306b5..ddb82e271b888d 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -139,6 +139,10 @@ A provider that supersedes sessions from another provider may implement `resolve `createNewSession` and `createQuickChat` return untitled drafts. A draft remains `Untitled` while its first request is prepared; `isNewSessionRequestInProgress` separately lets the UI present that activity without treating the session as committed. Draft preparation receives the first query so a provider can materialize query-dependent execution state before replacing the draft. A draft enters the committed catalog when its first request is sent. The management service owns the currently presented draft; the provider owns its backend resources. `deleteNewSession` disposes an abandoned draft. +Automation editing uses an independent draft so it cannot replace the ordinary New Session composer. `ISessionsProviderCreateSessionOptions.automationConfiguration` restores the saved provider template and temporary compatibility projections before the draft's first configuration resolution. Providers that expose editable Automation configuration implement `getAutomationSessionConfiguration` to capture the current template. The management service distinguishes an unsupported capture hook from a valid empty template and a draft that was replaced while capture was pending. + +Provider-specific configuration remains opaque to shared Sessions code. Scoped Automation and New Session surfaces consume the same provider menu contributions and `ISessionContext`; providers may advertise presentation capabilities such as a combined phone Mode/Model picker without exposing provider identity checks to shared UI. + ### Operations Providers implement only operations advertised by their contracts, including request sending, model selection, rename, archive, read state, deletion, and chat creation. Capability checks happen before invocation. Once invoked, an operation returns a defined result or rejects; unsupported behavior must not be reported as a success-shaped fallback. diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index d99f1fb1e6ff40..16068dec28e188 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -48,6 +48,10 @@ Within that contract, Agent Host providers expose the host's `ahp-automations:// Imported prompts retain Automation provenance through `MessageKind.Automation`. The projection converts editor-qualified model identifiers to provider-native `ModelSelection.id` values at the AHP boundary while preserving the editor identity exposed to Sessions. The provider also mirrors `chat.automations.enabled` and `chat.automations.runTimeoutMinutes` into host configuration; disabling Automations removes new run authority without deleting definitions or terminating sessions already running. +`AutomationDefinition.session` is authoritative for host-owned model, custom-agent, and provider configuration. The projection removes target-owned working directory, isolation, and branch values from the editor-facing template and restores them only at the AHP boundary. Unknown provider values remain opaque and survive same-target edits. + +The browser fallback and host-owned executor both create sessions from this template. A draft restores it before the first `resolveSessionConfig` call and captures the provider-resolved state when saved. Initial values that are unavailable or policy-clamped remain saved preferences until the user explicitly changes them; the effective draft and every run still use current schema and managed-policy enforcement. + ## Identity The local provider uses: @@ -91,6 +95,8 @@ create draft The first send waits for tracked draft configuration. Cancellation disposes the draft. Later configuration changes are scoped to the committed session and do not recreate the entire facade. +Automation drafts use the same `NewSession` implementation but are tracked separately by the management service. They may receive an initial Automation configuration and can be captured asynchronously after pending configuration resolution. Capture rechecks draft identity, omits transient and target-owned values, preserves untouched opaque preferences, and rejects superseded drafts. + Existing-session requests route by the provider resource and chat resource. Host notifications update adapters and catalog membership reactively. ## Persistence and discovery From 3eec4b34e0ba0d4e62fbbcf45196514bdf0be7fa Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 14:25:12 +0200 Subject: [PATCH 09/15] automations: fix: harden provider configuration handling Keep canonical provider templates opaque and authoritative while limiting legacy Autopilot repair to load/import boundaries. Bound dialog capture and preserve per-target configuration across failures and retargeting, retain legacy duplicate/worktree settings, and improve loading accessibility. Add regression coverage for #333723 compatibility, canonical reloads, capture races, and tool configuration limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../agentHost/common/automationMigration.ts | 6 +- .../test/common/automationMigration.test.ts | 32 +++ .../automations/browser/automationDialog.ts | 145 +++++++++++--- .../browser/automationDialogService.ts | 17 +- .../automations/browser/automationRunner.ts | 22 +-- .../automations/browser/automationService.ts | 55 ++---- .../automations/browser/automationTools.ts | 31 ++- .../browser/media/automationDialog.css | 12 ++ .../test/browser/automationDialog.test.ts | 183 ++++++++++++++---- .../test/browser/automationRunner.test.ts | 4 - .../test/browser/automationService.test.ts | 63 ++++-- .../test/browser/automationTools.test.ts | 44 +++++ .../chat/browser/newSessionConfigToolbars.ts | 6 +- .../browser/agentHostAutomationStore.ts | 5 +- .../browser/baseAgentHostSessionsProvider.ts | 19 +- .../mobile/mobileChatInputConfigPicker.ts | 4 +- .../localAgentHostSessionsProvider.test.ts | 39 ++++ .../sessions/browser/views/automationsView.ts | 8 +- .../test/browser/automationsView.test.ts | 27 +++ .../common/automations/automationTelemetry.ts | 15 +- 20 files changed, 571 insertions(+), 166 deletions(-) create mode 100644 src/vs/platform/agentHost/test/common/automationMigration.test.ts diff --git a/src/vs/platform/agentHost/common/automationMigration.ts b/src/vs/platform/agentHost/common/automationMigration.ts index 5b01894608776e..3808594887c0d3 100644 --- a/src/vs/platform/agentHost/common/automationMigration.ts +++ b/src/vs/platform/agentHost/common/automationMigration.ts @@ -51,6 +51,10 @@ export function migrateLegacyAutomationSessionConfig(provider: string | undefine && !KNOWN_MODE_VALUES.has(config[SessionConfigKey.Mode])) { return { ...config, [SessionConfigKey.Mode]: 'autopilot' }; } + return migrateCombinedAutopilotConfig(config); +} + +function migrateCombinedAutopilotConfig(config: Record): Record { if (config[SessionConfigKey.AutoApprove] !== 'autopilot') { return config; } @@ -78,5 +82,5 @@ export function applyLegacyAutomationSessionConfig(provider: string | undefined, } else { result[SessionConfigKey.AutoApprove] = permissionLevel; } - return migrateLegacyAutomationSessionConfig(provider, result); + return migrateCombinedAutopilotConfig(result); } diff --git a/src/vs/platform/agentHost/test/common/automationMigration.test.ts b/src/vs/platform/agentHost/test/common/automationMigration.test.ts new file mode 100644 index 00000000000000..e25b5b1713fdc5 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/automationMigration.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { applyLegacyAutomationSessionConfig, migrateLegacyAutomationSessionConfig } from '../../common/automationMigration.js'; + +suite('Automation migration', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('steady-state writes do not reinterpret generic or custom modes as Autopilot', () => { + assert.deepStrictEqual([ + applyLegacyAutomationSessionConfig('copilotcli', { mode: 'agent', autoApprove: 'assisted' }, 'agent', 'assisted'), + applyLegacyAutomationSessionConfig('copilotcli', { mode: 'reviewer', autoApprove: 'assisted' }, 'reviewer', 'assisted'), + ], [ + { mode: 'agent', autoApprove: 'assisted' }, + { mode: 'reviewer', autoApprove: 'assisted' }, + ]); + }); + + test('load-time migration still repairs transitional and combined Autopilot rows', () => { + assert.deepStrictEqual([ + migrateLegacyAutomationSessionConfig('copilotcli', { mode: 'agent', autoApprove: 'assisted' }), + migrateLegacyAutomationSessionConfig('copilotcli', { autoApprove: 'autopilot' }), + ], [ + { mode: 'autopilot', autoApprove: 'assisted' }, + { mode: 'autopilot', autoApprove: 'assisted' }, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 05ac7d016e807a..25ff208010a86d 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../base/browser/dom.js'; +import { raceTimeout } from '../../../../base/common/async.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IButton } from '../../../../base/browser/ui/button/button.js'; @@ -16,7 +17,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, disposableObservableValue, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, disposableObservableValue, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -212,7 +213,7 @@ export interface IValidationState { interface IRenderFormHandle { readonly getPrompt: () => string; - readonly getSessionConfiguration: () => Promise; + readonly getSessionConfiguration: () => Promise; readonly getBranch: () => string | undefined; readonly waitForAutomationSessionSync: () => Promise; readonly getFocusableElements: () => readonly HTMLElement[]; @@ -228,10 +229,19 @@ type AutomationSessionDraftService = Pick< 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' >; +export type AutomationSessionConfigurationCapture = + | { readonly kind: 'captured'; readonly configuration: IAutomationSessionConfiguration } + | { readonly kind: 'preserved'; readonly configuration: IAutomationSessionConfiguration | undefined }; + +const AUTOMATION_CONFIGURATION_CAPTURE_TIMEOUT_MS = 5_000; +const AUTOMATION_CONFIGURATION_RETARGET_CAPTURE_TIMEOUT_MS = 1_000; + export class AutomationSessionDraftSynchronizer extends Disposable { readonly availability = observableValue<'idle' | 'pending' | 'available' | 'unavailable'>(this, 'idle'); + private readonly configurationsByTarget = new Map(); private requestedTarget: AutomationSessionDraftTarget | undefined; private appliedTarget: AutomationSessionDraftTarget | undefined; + private appliedConfiguration: IAutomationSessionConfiguration | undefined; private session: ISession | undefined; private generation = 0; private syncScheduled = false; @@ -242,11 +252,23 @@ export class AutomationSessionDraftSynchronizer extends Disposable { private readonly sessionsManagementService: AutomationSessionDraftService, private readonly canSelectWorkspace: (folderUri: URI, preferredProviderId: string | undefined) => Promise, private readonly onError: (error: unknown) => void, + private readonly configurationCaptureTimeoutMs = AUTOMATION_CONFIGURATION_CAPTURE_TIMEOUT_MS, + private readonly retargetConfigurationCaptureTimeoutMs = AUTOMATION_CONFIGURATION_RETARGET_CAPTURE_TIMEOUT_MS, ) { super(); } update(target: AutomationSessionDraftTarget | undefined): void { + if (this.targetsEqual(this.requestedTarget, target) + && (!target || !!this.session && this.sessionsManagementService.automationSession.get()?.sessionId === this.session.sessionId)) { + return; + } + if (target?.sessionConfiguration) { + const key = this.targetKey(target); + if (!this.configurationsByTarget.has(key)) { + this.configurationsByTarget.set(key, target.sessionConfiguration); + } + } this.requestedTarget = target; this.generation++; this.availability.set(target ? 'pending' : 'idle', undefined); @@ -261,22 +283,22 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } while (pendingSync !== this.syncPromise); } - async getSessionConfiguration(): Promise { - while (!this.disposed) { + async getSessionConfiguration(): Promise { + for (let attempt = 0; attempt < 2 && !this.disposed; attempt++) { await this.waitForSync(); const generation = this.generation; const session = this.session; const target = this.requestedTarget; - if (!session) { - return target?.sessionConfiguration; + if (!session || !target) { + return { kind: 'preserved', configuration: this.configurationForTarget(target) }; } - const captured = await this.sessionsManagementService.getAutomationSessionConfiguration(session); + const captured = await this.captureSessionConfiguration(session, target, this.configurationCaptureTimeoutMs); if (generation !== this.generation || session !== this.session) { continue; } - return captured === null ? target?.sessionConfiguration : captured; + return captured; } - return undefined; + return { kind: 'preserved', configuration: this.configurationForTarget(this.requestedTarget) }; } private scheduleSync(): void { @@ -315,20 +337,28 @@ export class AutomationSessionDraftSynchronizer extends Disposable { if (this.disposed || generation !== this.generation) { return; } + if (this.session && this.appliedTarget) { + await this.captureSessionConfiguration(this.session, this.appliedTarget, this.retargetConfigurationCaptureTimeoutMs); + if (this.disposed || generation !== this.generation) { + return; + } + } + const sessionConfiguration = this.configurationForTarget(target); this.session = target.kind === 'quickChat' ? this.sessionsManagementService.createAutomationQuickChat({ providerId: target.providerId, sessionTypeId: target.sessionTypeId, - sessionTemplate: target.sessionConfiguration?.sessionTemplate, - automationConfiguration: target.sessionConfiguration, + sessionTemplate: sessionConfiguration?.sessionTemplate, + automationConfiguration: sessionConfiguration, }) : this.sessionsManagementService.createAutomationSession(target.folderUri, { providerId: target.providerId, sessionTypeId: target.sessionTypeId, - sessionTemplate: target.sessionConfiguration?.sessionTemplate, - automationConfiguration: target.sessionConfiguration, + sessionTemplate: sessionConfiguration?.sessionTemplate, + automationConfiguration: sessionConfiguration, }); this.appliedTarget = target; + this.appliedConfiguration = sessionConfiguration; this.availability.set('available', undefined); } catch (error) { if (!this.disposed && generation === this.generation) { @@ -346,7 +376,7 @@ export class AutomationSessionDraftSynchronizer extends Disposable { || this.appliedTarget.kind !== target.kind || this.appliedTarget.providerId !== target.providerId || this.appliedTarget.sessionTypeId !== target.sessionTypeId - || this.appliedTarget.sessionConfiguration !== target.sessionConfiguration) { + || this.appliedConfiguration !== this.configurationForTarget(target)) { return false; } return target.kind === 'quickChat' @@ -359,6 +389,47 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } this.session = undefined; this.appliedTarget = undefined; + this.appliedConfiguration = undefined; + } + + private async captureSessionConfiguration(session: ISession, target: AutomationSessionDraftTarget, timeoutMs: number): Promise { + try { + const result = await raceTimeout( + this.sessionsManagementService.getAutomationSessionConfiguration(session).then(configuration => ({ configuration })), + timeoutMs, + ); + if (!result) { + throw new Error(`Timed out after ${timeoutMs}ms while capturing Automation session configuration.`); + } + if (result.configuration === null || result.configuration === undefined) { + return { kind: 'preserved', configuration: this.configurationForTarget(target) }; + } + this.configurationsByTarget.set(this.targetKey(target), result.configuration); + return { kind: 'captured', configuration: result.configuration }; + } catch (error) { + this.onError(error); + return { kind: 'preserved', configuration: this.configurationForTarget(target) }; + } + } + + private configurationForTarget(target: AutomationSessionDraftTarget | undefined): IAutomationSessionConfiguration | undefined { + return target ? this.configurationsByTarget.get(this.targetKey(target)) ?? target.sessionConfiguration : undefined; + } + + private targetsEqual(first: AutomationSessionDraftTarget | undefined, second: AutomationSessionDraftTarget | undefined): boolean { + if (first === second) { + return true; + } + if (!first || !second || first.kind !== second.kind || first.providerId !== second.providerId || first.sessionTypeId !== second.sessionTypeId || first.sessionConfiguration !== second.sessionConfiguration) { + return false; + } + return first.kind === 'quickChat' || (second.kind === 'workspace' && isEqual(first.folderUri, second.folderUri)); + } + + private targetKey(target: AutomationSessionDraftTarget): string { + return target.kind === 'quickChat' + ? `quickChat:${target.providerId}:${target.sessionTypeId}` + : `workspace:${target.folderUri.toString()}:${target.providerId ?? ''}:${target.sessionTypeId}`; } override dispose(): void { @@ -1070,7 +1141,8 @@ export function renderForm( revalidate(); })); - const promptRow = DOM.append(form, $('.automation-form-row')); + const promptSection = DOM.append(form, $('.automation-prompt-section')); + const promptRow = DOM.append(promptSection, $('.automation-form-row')); DOM.append(promptRow, $('span.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); const editorOverflowWidgetsDomNode = layoutService.getContainer(DOM.getWindow(promptHost)).appendChild($('.chat-editor-overflow.automation-dialog-editor-overflow.monaco-editor')); @@ -1080,7 +1152,7 @@ export function renderForm( const session = sessionsManagementService.automationSession.read(reader); activeAutomationSession.set(session ? new VisibleSession(session, session.mainChat.read(reader)) : undefined, undefined); })); - const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptRow)); + const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptSection)); ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); @@ -1093,7 +1165,9 @@ export function renderForm( [ISessionModelSelection, sessionModelSelection], ))); const usesCombinedConfigPicker = SessionUsesCombinedConfigPickerContext.bindTo(scopedContextKeyService); + const sessionTypesChanged = observableSignalFromEvent(form, sessionsManagementService.onDidChangeSessionTypes); disposables.add(autorun(reader => { + sessionTypesChanged.read(reader); const session = activeAutomationSession.read(reader); setActiveSessionContextKeys(session, scopedContextKeyService, reader); usesCombinedConfigPicker.set(!!session && sessionsManagementService.usesCombinedNewSessionConfigPicker(session)); @@ -1208,12 +1282,28 @@ export function renderForm( chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); disposables.add(scopedInstantiationService.createInstance(AutomationInputCompletions, chatInput.inputEditor)); - const sessionConfiguration = DOM.append(promptRow, $('.automation-session-configuration')); + const sessionConfigurationRow = DOM.append(promptSection, $('.automation-form-row')); + const sessionConfigurationLabel = DOM.append(sessionConfigurationRow, $('span.automation-form-label', { + id: 'automation-session-configuration-label', + }, localize('automation.form.sessionConfiguration', "Session configuration"))); + const sessionConfiguration = DOM.append(sessionConfigurationRow, $('.automation-session-configuration', { + role: 'group', + 'aria-labelledby': sessionConfigurationLabel.id, + })); const sessionConfigContainer = DOM.append(sessionConfiguration, $('.automation-session-config.sessions-chat-config-toolbar')); const compactModelPicker = observableValue(sessionConfigContainer, false); - const sessionConfigToolbar = disposables.add(createNewSessionConfigToolbar(sessionConfigContainer, scopedInstantiationService, compactModelPicker)); + const sessionConfigToolbar = disposables.add(createNewSessionConfigToolbar( + sessionConfigContainer, + scopedInstantiationService, + compactModelPicker, + localize('automation.form.sessionModelAndAgent', "Session model and agent"), + )); const sessionControlsContainer = DOM.append(sessionConfiguration, $('.automation-session-controls')); - const sessionControlsToolbar = disposables.add(createNewSessionControlToolbar(sessionControlsContainer, scopedInstantiationService)); + const sessionControlsToolbar = disposables.add(createNewSessionControlToolbar( + sessionControlsContainer, + scopedInstantiationService, + localize('automation.form.sessionModeAndApprovals', "Session mode and approvals"), + )); const sessionConfigLayout = disposables.add(new ChatInputPickerResponsiveLayout('AutomationDialog.sessionConfig', sessionConfigContainer, { getItems: () => getAutomationSessionToolbarResponsiveItems(sessionConfigToolbar, compactModelPicker), hasOverflow: () => sessionConfigToolbar.hasOverflow(), @@ -1231,9 +1321,20 @@ export function renderForm( 'aria-atomic': 'true', })); disposables.add(autorun(reader => { - sessionConfigurationUnavailable.textContent = automationSessionDraftSynchronizer.availability.read(reader) === 'unavailable' - ? localize('automation.form.sessionConfigurationUnavailable', "Session configuration unavailable") - : ''; + const availability = automationSessionDraftSynchronizer.availability.read(reader); + const pending = availability === 'pending'; + const controlsUnavailable = availability !== 'available'; + sessionConfiguration.classList.toggle('controls-unavailable', controlsUnavailable); + sessionConfiguration.setAttribute('aria-busy', String(pending)); + for (const container of [sessionConfigContainer, sessionControlsContainer]) { + container.toggleAttribute('inert', controlsUnavailable); + container.setAttribute('aria-hidden', String(controlsUnavailable)); + } + sessionConfigurationUnavailable.textContent = pending + ? localize('automation.form.sessionConfigurationLoading', "Loading session configuration…") + : availability === 'unavailable' + ? localize('automation.form.sessionConfigurationUnavailable', "Session configuration unavailable") + : ''; })); disposables.add(chatInput.inputEditor.onDidChangeModelContent(() => { diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 279239612fdd97..0ae970efddcf5e 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -25,7 +25,7 @@ import { IHostService } from '../../../../workbench/services/host/browser/host.j import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; -import { IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; +import { AutomationSessionConfigurationCapture, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; const $ = DOM.$; @@ -114,7 +114,7 @@ export class AutomationDialogService implements IAutomationDialogService { let cancelButton: IButton | undefined; let revalidate: () => void = () => { }; let getPrompt: () => string = () => initial?.prompt ?? ''; - let getSessionConfiguration = async () => initialSessionConfiguration; + let getSessionConfiguration = async (): Promise => ({ kind: 'preserved', configuration: initialSessionConfiguration }); let getBranch: () => string | undefined = () => initialWorkspaceTarget?.isolation.kind === 'worktree' ? initialWorkspaceTarget.isolation.branch : undefined; let waitForAutomationSessionSync: () => Promise = async () => { }; let getFocusableElements: () => readonly HTMLElement[] = () => []; @@ -220,7 +220,8 @@ export class AutomationDialogService implements IAutomationDialogService { }; const prompt = getPrompt(); - const sessionConfiguration = await getSessionConfiguration(); + const sessionConfigurationCapture = await getSessionConfiguration(); + const sessionConfiguration = sessionConfigurationCapture.configuration; const sessionTemplate = sessionConfiguration?.sessionTemplate; const branch = getBranch(); const target = createAutomationTarget(state, branch); @@ -234,7 +235,7 @@ export class AutomationDialogService implements IAutomationDialogService { prompt, schedule, target, - ...(sessionConfiguration ? { + ...(sessionConfigurationCapture.kind === 'captured' ? { sessionTemplate: sessionTemplate ?? null, } : {}), enabled: state.enabled, @@ -247,7 +248,13 @@ export class AutomationDialogService implements IAutomationDialogService { prompt, schedule, target, - sessionTemplate, + ...(sessionTemplate + ? { sessionTemplate } + : sessionConfiguration ? { + ...(sessionConfiguration.modelId !== undefined ? { modelId: sessionConfiguration.modelId } : {}), + ...(sessionConfiguration.mode !== undefined ? { mode: sessionConfiguration.mode } : {}), + ...(sessionConfiguration.permissionLevel !== undefined ? { permissionLevel: sessionConfiguration.permissionLevel } : {}), + } : {}), enabled: state.enabled, }; return { kind: 'create', value: create }; diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index 42ad59399b9ee5..ca5bae016c9212 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -83,19 +83,15 @@ export class AutomationRunner implements IAutomationRunner { ? target.isolation.kind === 'folder' ? 'workspace' : target.isolation.kind === 'worktree' ? 'worktree' : undefined : undefined; const branch = target.kind === 'workspace' && target.isolation.kind === 'worktree' ? target.isolation.branch : undefined; - const templateMode = automation.sessionTemplate?.config?.['mode']; - const templatePermissionLevel = automation.sessionTemplate?.config?.['autoApprove']; - const automationConfiguration: IAutomationSessionConfiguration | undefined = automation.sessionTemplate !== undefined - || automation.modelId !== undefined - || automation.mode !== undefined - || automation.permissionLevel !== undefined - ? { - sessionTemplate: automation.sessionTemplate, - modelId: automation.sessionTemplate?.modelId ?? automation.modelId, - mode: typeof templateMode === 'string' ? templateMode : automation.mode, - permissionLevel: typeof templatePermissionLevel === 'string' ? templatePermissionLevel : automation.permissionLevel, - } - : undefined; + const automationConfiguration: IAutomationSessionConfiguration | undefined = automation.sessionTemplate + ? { sessionTemplate: automation.sessionTemplate } + : automation.modelId !== undefined || automation.mode !== undefined || automation.permissionLevel !== undefined + ? { + modelId: automation.modelId, + mode: automation.mode, + permissionLevel: automation.permissionLevel, + } + : undefined; const createOptions: ICreateNewSessionOptions | undefined = target.providerId !== undefined || target.sessionTypeId !== undefined || automationConfiguration !== undefined || isolationMode !== undefined || branch !== undefined ? { diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index 0b0cedc3b4a108..6b23e4ede40c8b 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -8,7 +8,6 @@ import { derived, IObservable, ISettableObservable, observableValue, transaction import { URI, UriComponents } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { applyLegacyAutomationSessionConfig } from '../../../../platform/agentHost/common/automationMigration.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult } from '../../../services/sessions/common/sessionsProvider.js'; @@ -188,10 +187,13 @@ export class AutomationStore extends Disposable implements IAutomationStore { prompt: options.prompt, schedule: options.schedule, target: normalizeAutomationTarget(options.target), - ...(options.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : {}), - modelId: options.modelId, - mode: options.mode, - permissionLevel: isChatPermissionLevel(options.permissionLevel) ? options.permissionLevel : undefined, + ...(options.sessionTemplate + ? { sessionTemplate: options.sessionTemplate } + : { + modelId: options.modelId, + mode: options.mode, + permissionLevel: isChatPermissionLevel(options.permissionLevel) ? options.permissionLevel : undefined, + }), enabled: options.enabled ?? true, createdAt: nowIso, updatedAt: nowIso, @@ -685,11 +687,11 @@ function deserializeLegacyAutomation(s: ILegacySerializedAutomation): IAutomatio } function createAutomationFromSerialized(s: ISerializedAutomationBase, target: AutomationTarget): IAutomationDescriptor { + const sessionTemplate = deserializeAutomationSessionTemplate(s.sessionTemplate); // Default to most restrictive if the persisted value is invalid. - const permissionLevel = isChatPermissionLevel(s.permissionLevel) + const permissionLevel = !sessionTemplate && isChatPermissionLevel(s.permissionLevel) ? s.permissionLevel - : ChatPermissionLevel.Default; - const sessionTemplate = deserializeAutomationSessionTemplate(s.sessionTemplate); + : sessionTemplate ? undefined : ChatPermissionLevel.Default; return Object.freeze({ id: s.id, @@ -698,8 +700,8 @@ function createAutomationFromSerialized(s: ISerializedAutomationBase, target: Au schedule: s.schedule, target, ...(sessionTemplate ? { sessionTemplate } : {}), - modelId: s.modelId, - mode: s.mode, + modelId: sessionTemplate ? undefined : s.modelId, + mode: sessionTemplate ? undefined : s.mode, permissionLevel, enabled: s.enabled, createdAt: s.createdAt, @@ -726,10 +728,14 @@ function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomatio const target = patch.target ? normalizeAutomationTarget(patch.target) : current.target; const targetAuthorityChanged = patch.target !== undefined && (target.providerId !== current.target.providerId || target.sessionTypeId !== current.target.sessionTypeId); - const currentModelId = current.sessionTemplate?.modelId ?? current.modelId; - const currentMode = readString(current.sessionTemplate?.config?.['mode']) ?? current.mode; - const currentPermissionLevel = readString(current.sessionTemplate?.config?.['autoApprove']) ?? current.permissionLevel; const templatePatched = patch.sessionTemplate !== undefined; + const legacyConfigurationPatched = patch.modelId !== undefined || patch.mode !== undefined || patch.permissionLevel !== undefined; + if (current.sessionTemplate && !templatePatched && !targetAuthorityChanged && legacyConfigurationPatched) { + throw new Error('A canonical Automation session template cannot be updated through legacy configuration aliases.'); + } + const currentModelId = current.sessionTemplate ? undefined : current.modelId; + const currentMode = current.sessionTemplate ? undefined : current.mode; + const currentPermissionLevel = current.sessionTemplate ? undefined : current.permissionLevel; const modelId = templatePatched ? undefined : patch.modelId === null ? undefined : (patch.modelId ?? (targetAuthorityChanged ? undefined : currentModelId)); const mode = templatePatched ? undefined : patch.mode === null ? undefined : (patch.mode ?? (targetAuthorityChanged ? undefined : currentMode)); const permissionLevel = templatePatched || patch.permissionLevel === null @@ -739,9 +745,9 @@ function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomatio : targetAuthorityChanged ? ChatPermissionLevel.Default : currentPermissionLevel; const sessionTemplate = patch.sessionTemplate === null ? undefined - : patch.sessionTemplate ?? (targetAuthorityChanged + : patch.sessionTemplate ?? (targetAuthorityChanged || legacyConfigurationPatched ? undefined - : synchronizeAutomationSessionTemplate(current.sessionTemplate, target.sessionTypeId, modelId, mode, permissionLevel)); + : current.sessionTemplate); return { ...current, name: patch.name ?? current.name, @@ -756,10 +762,6 @@ function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomatio }; } -function readString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - function normalizeAutomationTarget(target: AutomationTarget): AutomationTarget { if (target.kind === 'quickChat') { if (!target.providerId || !target.sessionTypeId) { @@ -808,21 +810,6 @@ function deserializeAutomationSessionTemplate(value: unknown): IAutomationSessio }; } -function synchronizeAutomationSessionTemplate(template: IAutomationSessionTemplate | undefined, provider: string | undefined, modelId: string | undefined, mode: string | undefined, permissionLevel: string | undefined): IAutomationSessionTemplate | undefined { - if (!template) { - return undefined; - } - const config = applyLegacyAutomationSessionConfig(provider, template.config, mode, permissionLevel); - if (!modelId && !template.agent && Object.keys(config).length === 0) { - return undefined; - } - return { - ...(modelId ? { modelId } : {}), - ...(template.agent ? { agent: template.agent } : {}), - ...(Object.keys(config).length > 0 ? { config } : {}), - }; -} - function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 8af427150f6101..51cff61161d976 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -36,6 +36,9 @@ const manualRunLeaderWindowId = 0; const automationIntervals: readonly AutomationInterval[] = ['manual', 'hourly', 'daily', 'weekly']; const automationIsolationKinds: readonly AutomationWorkspaceIsolation['kind'][] = ['default', 'folder', 'worktree']; const chatPermissionLevels: readonly ChatPermissionLevel[] = [ChatPermissionLevel.Default, ChatPermissionLevel.Assisted, ChatPermissionLevel.AutoApprove, ChatPermissionLevel.Autopilot]; +const MAX_SESSION_TEMPLATE_CONFIG_DEPTH = 32; +const MAX_SESSION_TEMPLATE_CONFIG_NODES = 10_000; +const MAX_SESSION_TEMPLATE_CONFIG_LENGTH = 65_536; interface IAutomationToolOutput { readonly id: string; @@ -910,7 +913,12 @@ function parseSessionTemplate(input: Record): IAutomationSessio if (!isRecord(rawConfig)) { throw new AutomationToolInputError('"sessionTemplate.config" must be an object or null.'); } - config = cloneJsonObject(rawConfig, 'sessionTemplate.config'); + const cloneState = { nodes: 0 }; + assertJsonComplexity('sessionTemplate.config', cloneState, 0); + config = cloneJsonObject(rawConfig, 'sessionTemplate.config', cloneState, 0); + if (JSON.stringify(config).length > MAX_SESSION_TEMPLATE_CONFIG_LENGTH) { + throw new AutomationToolInputError(`"sessionTemplate.config" must not exceed ${MAX_SESSION_TEMPLATE_CONFIG_LENGTH} characters.`); + } } return { ...(modelId ? { modelId } : {}), @@ -919,15 +927,16 @@ function parseSessionTemplate(input: Record): IAutomationSessio }; } -function cloneJsonObject(value: Record, field: string): Record { +function cloneJsonObject(value: Record, field: string, state: { nodes: number }, depth: number): Record { const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) { throw new AutomationToolInputError(`"${field}" must contain only JSON values.`); } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneJsonValue(entry, `${field}.${key}`)])); + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneJsonValue(entry, `${field}.${key}`, state, depth + 1)])); } -function cloneJsonValue(value: unknown, field: string): unknown { +function cloneJsonValue(value: unknown, field: string, state: { nodes: number }, depth: number): unknown { + assertJsonComplexity(field, state, depth); if (value === null || typeof value === 'string' || typeof value === 'boolean') { return value; } @@ -935,14 +944,24 @@ function cloneJsonValue(value: unknown, field: string): unknown { return value; } if (Array.isArray(value)) { - return value.map((entry, index) => cloneJsonValue(entry, `${field}[${index}]`)); + return value.map((entry, index) => cloneJsonValue(entry, `${field}[${index}]`, state, depth + 1)); } if (isRecord(value)) { - return cloneJsonObject(value, field); + return cloneJsonObject(value, field, state, depth); } throw new AutomationToolInputError(`"${field}" must be JSON-safe.`); } +function assertJsonComplexity(field: string, state: { nodes: number }, depth: number): void { + if (depth > MAX_SESSION_TEMPLATE_CONFIG_DEPTH) { + throw new AutomationToolInputError(`"${field}" exceeds the maximum nesting depth of ${MAX_SESSION_TEMPLATE_CONFIG_DEPTH}.`); + } + state.nodes++; + if (state.nodes > MAX_SESSION_TEMPLATE_CONFIG_NODES) { + throw new AutomationToolInputError(`"sessionTemplate.config" must not contain more than ${MAX_SESSION_TEMPLATE_CONFIG_NODES} values.`); + } +} + function parseUri(value: string, field: string): URI { try { const uri = URI.parse(value, true); diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index f650c73a869a55..eb357799d3e823 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -227,6 +227,12 @@ padding: 2px 2px 4px; } +.automation-prompt-section { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); +} + /* * Host for the embedded ChatInputPart. The composer brings its own * background, border, and rounded corners (see chat.css @@ -308,6 +314,12 @@ padding-top: var(--vscode-spacing-size40); } +.automation-session-configuration.controls-unavailable .automation-session-config, +.automation-session-configuration.controls-unavailable .automation-session-controls { + opacity: 0.5; + pointer-events: none; +} + .automation-session-config, .automation-session-controls { min-width: 0; diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index e9d66044572af4..7aeb5b33d861e9 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -147,7 +147,7 @@ function createWorkspace(requiresWorkspaceTrust: boolean): ISessionWorkspace { }; } -function createAutomationDraftService(captureSupported = true) { +function createAutomationDraftService(captureSupported = true, captureError?: Error, capturePromise?: Promise) { const automationSession = observableValue('automationSession', undefined); const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string; sessionTemplate?: IAutomationSessionTemplate }> = []; const discarded: string[] = []; @@ -172,7 +172,15 @@ function createAutomationDraftService(captureSupported = true) { automationSession, createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri, options?.sessionTemplate), createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default', undefined, options?.sessionTemplate), - getAutomationSessionConfiguration: async session => captureSupported ? sessionConfigurations.get(session.sessionId) : null, + getAutomationSessionConfiguration: async session => { + if (captureError) { + throw captureError; + } + if (capturePromise) { + return capturePromise; + } + return captureSupported ? sessionConfigurations.get(session.sessionId) : null; + }, discardAutomationSession: session => { const current = automationSession.get(); if (!current || (session && session.sessionId !== current.sessionId)) { @@ -244,46 +252,6 @@ suite('Automation session draft synchronization', () => { sessionConfiguration: { sessionTemplate }, }); - test('distinguishes a valid empty capture from unsupported capture', async () => { - const sessionConfiguration: IAutomationSessionConfiguration = { - sessionTemplate: { - modelId: 'model', - config: { mode: 'plan' }, - }, - modelId: 'model', - mode: 'plan', - }; - const supported = createAutomationDraftService(); - const supportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(supported.service, async () => true, () => { })); - supportedSynchronizer.update({ - kind: 'workspace', - folderUri: URI.parse('file:///workspace'), - providerId: 'provider', - sessionTypeId: 'type', - sessionConfiguration, - }); - await supportedSynchronizer.waitForSync(); - const supportedSessionId = supported.service.automationSession.get()!.sessionId; - supported.sessionConfigurations.set(supportedSessionId, {}); - - const unsupported = createAutomationDraftService(false); - const unsupportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(unsupported.service, async () => true, () => { })); - unsupportedSynchronizer.update({ - kind: 'workspace', - folderUri: URI.parse('file:///workspace'), - providerId: 'provider', - sessionTypeId: 'type', - sessionConfiguration, - }); - - assert.deepStrictEqual({ - supported: await supportedSynchronizer.getSessionConfiguration(), - unsupported: await unsupportedSynchronizer.getSessionConfiguration(), - }, { - supported: {}, - unsupported: sessionConfiguration, - }); - }); const captured = await synchronizer.getSessionConfiguration(); assert.deepStrictEqual({ @@ -297,10 +265,139 @@ suite('Automation session draft synchronization', () => { folderUri: 'file:///workspace', sessionTemplate, }], - captured: { sessionTemplate }, + captured: { kind: 'captured', configuration: { sessionTemplate } }, }); }); + test('distinguishes a valid empty capture from unsupported capture', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { + modelId: 'model', + config: { mode: 'plan' }, + }, + modelId: 'model', + mode: 'plan', + }; + const supported = createAutomationDraftService(); + const supportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(supported.service, async () => true, () => { })); + supportedSynchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + await supportedSynchronizer.waitForSync(); + const supportedSessionId = supported.service.automationSession.get()!.sessionId; + supported.sessionConfigurations.set(supportedSessionId, {}); + + const unsupported = createAutomationDraftService(false); + const unsupportedSynchronizer = disposables.add(new AutomationSessionDraftSynchronizer(unsupported.service, async () => true, () => { })); + unsupportedSynchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + assert.deepStrictEqual({ + supported: await supportedSynchronizer.getSessionConfiguration(), + unsupported: await unsupportedSynchronizer.getSessionConfiguration(), + }, { + supported: { kind: 'captured', configuration: {} }, + unsupported: { kind: 'preserved', configuration: sessionConfiguration }, + }); + }); + + test('preserves known configuration when capture fails', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan' } }, + }; + const { service } = createAutomationDraftService(true, new Error('capture failed')); + let errorCount = 0; + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => errorCount++)); + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + assert.deepStrictEqual({ + capture: await synchronizer.getSessionConfiguration(), + errorCount, + }, { + capture: { kind: 'preserved', configuration: sessionConfiguration }, + errorCount: 1, + }); + }); + + test('bounds configuration capture and preserves known configuration on timeout', async () => { + const sessionConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan' } }, + }; + const { service } = createAutomationDraftService(true, undefined, new Promise(() => { })); + let errorCount = 0; + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => errorCount++, 1)); + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration, + }); + + assert.deepStrictEqual({ + capture: await synchronizer.getSessionConfiguration(), + errorCount, + }, { + capture: { kind: 'preserved', configuration: sessionConfiguration }, + errorCount: 1, + }); + }); + + test('carries captured configuration when returning to a previous target', async () => { + const initialConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'interactive' } }, + }; + const capturedConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan', autoApprove: 'assisted' } }, + }; + const { service, created, sessionConfigurations } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => { })); + const firstTarget = { + kind: 'workspace', + folderUri: URI.parse('file:///first'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: initialConfiguration, + } as const; + + synchronizer.update(firstTarget); + await synchronizer.waitForSync(); + sessionConfigurations.set(service.automationSession.get()!.sessionId, capturedConfiguration); + synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///second'), providerId: 'provider', sessionTypeId: 'type' }); + await synchronizer.waitForSync(); + synchronizer.update(firstTarget); + await synchronizer.waitForSync(); + + assert.deepStrictEqual(created.map(entry => ({ + folderUri: entry.folderUri, + sessionTemplate: entry.sessionTemplate, + })), [{ + folderUri: 'file:///first', + sessionTemplate: initialConfiguration.sessionTemplate, + }, { + folderUri: 'file:///second', + sessionTemplate: undefined, + }, { + folderUri: 'file:///first', + sessionTemplate: capturedConfiguration.sessionTemplate, + }]); + }); + test('ignores stale workspace validation', async () => { const { service, created } = createAutomationDraftService(); const firstWorkspaceValidation = new DeferredPromise(); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index 95b2d302b37bca..e590712155a5fa 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -597,7 +597,6 @@ suite('AutomationRunner', () => { sessionTypeId: undefined, sessionTemplate: undefined, automationConfiguration: { - sessionTemplate: undefined, modelId: undefined, mode: 'agent', permissionLevel: 'autopilot', @@ -638,9 +637,6 @@ suite('AutomationRunner', () => { sessionTemplate, automationConfiguration: { sessionTemplate, - modelId: 'model', - mode: 'plan', - permissionLevel: 'assisted', }, isolationMode: undefined, branch: undefined, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index ef266a78c60524..b9101c6870848e 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -144,38 +144,69 @@ suite('AutomationService', () => { assert.deepStrictEqual({ schemaVersion: persisted.schemaVersion, template: restored.automations.get()[0].sessionTemplate, + modelId: restored.automations.get()[0].modelId, + mode: restored.automations.get()[0].mode, + permissionLevel: restored.automations.get()[0].permissionLevel, }, { schemaVersion: 4, template: sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, }); }); - test('folds legacy field updates into an existing session template', async () => { + test('provider-neutral updates preserve opaque templates without projecting legacy aliases', async () => { const { service } = createService(); + const sessionTemplate = { + modelId: 'old-model', + config: { mode: 'ask', autoApprove: 'autopilot', providerOption: true }, + }; const automation = await service.createAutomation({ name: 'Daily review', prompt: 'Summarize what changed', schedule: dailySchedule(), - target: workspaceTarget(), - sessionTemplate: { - modelId: 'old-model', - config: { mode: 'autopilot', autoApprove: 'assisted', providerOption: true }, - }, - modelId: 'old-model', - mode: 'autopilot', - permissionLevel: 'assisted', + target: { ...workspaceTarget(), providerId: 'default-copilot', sessionTypeId: 'copilotcli' }, + sessionTemplate, + modelId: 'stale-model', + mode: 'interactive', + permissionLevel: 'default', }); - const updated = await service.updateAutomation(automation.id, { - modelId: 'new-model', - mode: 'agent', - permissionLevel: 'autoApprove', + const updated = await service.updateAutomation(automation.id, { name: 'Updated review' }); + + assert.deepStrictEqual({ + sessionTemplate: updated.sessionTemplate, + modelId: updated.modelId, + mode: updated.mode, + permissionLevel: updated.permissionLevel, + }, { + sessionTemplate, + modelId: undefined, + mode: undefined, + permissionLevel: undefined, }); + }); - assert.deepStrictEqual(updated.sessionTemplate, { - modelId: 'new-model', - config: { mode: 'autopilot', autoApprove: 'autoApprove', providerOption: true }, + test('rejects legacy alias updates to a canonical session template', async () => { + const { service } = createService(); + const sessionTemplate = { + modelId: 'model', + config: { mode: 'ask', autoApprove: 'autopilot' }, + }; + const automation = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + target: workspaceTarget(), + sessionTemplate, }); + + await assert.rejects( + () => service.updateAutomation(automation.id, { mode: 'autopilot' }), + /cannot be updated through legacy configuration aliases/, + ); + assert.deepStrictEqual(service.getAutomation(automation.id)?.sessionTemplate, sessionTemplate); }); test('an explicit session template replaces stale legacy aliases', async () => { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index 3af7687f668d61..a136a3757131f2 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -1255,6 +1255,50 @@ suite('AutomationTools', () => { }); }); + test('configureAutomation bounds opaque provider configuration', async () => { + const tool = new ConfigureAutomationTool( + new FakeAutomationService(), + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + let deeplyNested: Record = {}; + for (let depth = 0; depth < 40; depth++) { + deeplyNested = { nested: deeplyNested }; + } + const target = { kind: 'workspace', folderUri: FOLDER.toString() }; + const deeplyNestedResult = await invoke(tool, { + name: 'Deep configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target, + sessionTemplate: { config: deeplyNested }, + }); + const oversizedResult = await invoke(tool, { + name: 'Large configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target, + sessionTemplate: { config: { value: 'x'.repeat(70_000) } }, + }); + const tooManyValuesResult = await invoke(tool, { + name: 'Wide configuration', + prompt: 'Do not save', + schedule: { interval: 'manual' }, + target, + sessionTemplate: { config: { values: Array.from({ length: 10_001 }, () => null) } }, + }); + + assert.deepStrictEqual({ + depthBounded: typeof deeplyNestedResult.toolResultError === 'string' && deeplyNestedResult.toolResultError.includes('exceeds the maximum nesting depth of 32'), + sizeBounded: oversizedResult.toolResultError, + nodeCountBounded: tooManyValuesResult.toolResultError, + }, { + depthBounded: true, + sizeBounded: '"sessionTemplate.config" must not exceed 65536 characters.', + nodeCountBounded: '"sessionTemplate.config" must not contain more than 10000 values.', + }); + }); + test('disabled Automations cannot be listed, configured, run, or deleted', async () => { const automationService = new FakeAutomationService([createAutomation()]); const configurationService = createConfigurationService(false); diff --git a/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts index d362f9809029fe..258d5c6daf5661 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts @@ -10,8 +10,9 @@ import { Menus } from '../../../browser/menus.js'; import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; /** Creates the provider/model toolbar shared by New Session configuration surfaces. */ -export function createNewSessionConfigToolbar(container: HTMLElement, instantiationService: IInstantiationService, compactModelPicker: IObservable): MenuWorkbenchToolBar { +export function createNewSessionConfigToolbar(container: HTMLElement, instantiationService: IInstantiationService, compactModelPicker: IObservable, ariaLabel?: string): MenuWorkbenchToolBar { return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionConfig, { + ariaLabel, hiddenItemStrategy: HiddenItemStrategy.NoHide, actionViewItemProvider: action => { if (action.id === 'sessions.modelPicker') { @@ -24,8 +25,9 @@ export function createNewSessionConfigToolbar(container: HTMLElement, instantiat } /** Creates the provider-owned control toolbar shared by New Session configuration surfaces. */ -export function createNewSessionControlToolbar(container: HTMLElement, instantiationService: IInstantiationService): MenuWorkbenchToolBar { +export function createNewSessionControlToolbar(container: HTMLElement, instantiationService: IInstantiationService, ariaLabel?: string): MenuWorkbenchToolBar { return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionControl, { + ariaLabel, hiddenItemStrategy: HiddenItemStrategy.NoHide, }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 3015c8352e2591..4ce10ef8e96982 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; -import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; +import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, applyLegacyAutomationSessionConfig, migrateLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -833,7 +833,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro const modelId = sessionTemplate ? sessionTemplate.modelId : descriptor.modelId; const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(modelId); const existingSession = existing && existing.session.provider === provider ? existing.session : undefined; - const config = sessionTemplate + const projectedConfig = sessionTemplate ? { ...sessionTemplate.config } : applyLegacyAutomationSessionConfig( provider, @@ -841,6 +841,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro descriptor.mode, descriptor.permissionLevel, ); + const config = imported ? migrateLegacyAutomationSessionConfig(provider, projectedConfig) : projectedConfig; if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index a998495b4d98cb..d51196d5cda5f2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -3586,7 +3586,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const activeClientScope = this._activeClientService.acquireScope(resourceScheme, workspace?.folders.map(folder => folder.root) ?? []); const initialSessionTemplate = this._resolveAutomationSessionTemplate(sessionType.id, initialAutomationConfiguration); const initialConfigValues = initialAutomationConfiguration - ? this._normalizeAutomationSessionConfig(initialSessionTemplate?.config) + ? { + ...this._derivedNewSessionConfig(workspace), + ...this._normalizeAutomationSessionConfig(initialSessionTemplate?.config), + } : this._initialNewSessionConfig(workspace); let newSession: NewSession; try { @@ -3872,18 +3875,24 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // here (rather than remembered) since it is derived from a setting, not // a user pick; an empty value is omitted so the default branch naming // is preserved. + Object.assign(remembered, this._derivedNewSessionConfig(workspace)); + + return Object.keys(remembered).length > 0 ? remembered : undefined; + } + + private _derivedNewSessionConfig(workspace: ISessionWorkspace | undefined): Record { + const config: Record = {}; const resource = workspace?.folders[0]?.root; const branchPrefix = this._baseConfigurationService.getValue('git.branchPrefix', { resource }); if (typeof branchPrefix === 'string' && branchPrefix.length > 0) { - remembered[SessionConfigKey.WorktreeBranchPrefix] = branchPrefix; + config[SessionConfigKey.WorktreeBranchPrefix] = branchPrefix; } const worktreeIncludeFiles = this._baseConfigurationService.getValue('git.worktreeIncludeFiles', { resource }); if (Array.isArray(worktreeIncludeFiles) && worktreeIncludeFiles.length > 0) { - remembered[SessionConfigKey.WorktreeIncludeFiles] = worktreeIncludeFiles; + config[SessionConfigKey.WorktreeIncludeFiles] = worktreeIncludeFiles; } - - return Object.keys(remembered).length > 0 ? remembered : undefined; + return config; } // -- Dynamic session config ---------------------------------------------- diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 96cee2a68927e2..1ee6dfd389d47f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -8,7 +8,7 @@ import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabe import { Gesture, EventType as TouchEventType } from '../../../../../../base/browser/touch.js'; import { BaseActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { autorun, IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, IObservable, observableSignalFromEvent } from '../../../../../../base/common/observable.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { IActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; @@ -382,7 +382,9 @@ class MobileChatInputConfigPickerContribution extends Disposable implements IWor // bottom sheet. Publish this as a neutral context key so the core model // picker can gate itself out without depending on agent-host identity. const usesCombinedPicker = SessionUsesCombinedConfigPickerContext.bindTo(contextKeyService); + const providersChanged = observableSignalFromEvent(this, sessionsProvidersService.onDidChangeProviders); this._register(autorun(reader => { + providersChanged.read(reader); const session = sessionsService.activeSession.read(reader); usesCombinedPicker.set(!!session && sessionsProvidersService.getProvider(session.providerId)?.usesCombinedNewSessionConfigPicker === true); })); 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 42a1a7c2838ffe..5fb588ec1552f0 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 @@ -4424,6 +4424,45 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('Automation drafts merge derived worktree settings with saved provider configuration', () => { + const configService = new TestConfigurationService(); + configService.setUserConfiguration('git.branchPrefix', 'automation/'); + configService.setUserConfiguration('git.worktreeIncludeFiles', ['product.overrides.json']); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService }); + const session = provider.createNewSession( + URI.parse('file:///home/user/project'), + provider.sessionTypes[0].id, + { + automationConfiguration: { + sessionTemplate: { + config: { + mode: 'plan', + autoApprove: 'assisted', + }, + }, + }, + }, + ); + + assert.deepStrictEqual({ + seededImmediately: provider.getSessionConfig(session.sessionId)?.values, + forwardedToAgentHost: agentHost.resolveSessionConfigRequests.at(-1)?.config, + }, { + seededImmediately: { + worktreeBranchPrefix: 'automation/', + worktreeIncludeFiles: ['product.overrides.json'], + mode: 'plan', + autoApprove: 'assisted', + }, + forwardedToAgentHost: { + worktreeBranchPrefix: 'automation/', + worktreeIncludeFiles: ['product.overrides.json'], + mode: 'plan', + autoApprove: 'assisted', + }, + }); + }); + test('createNewSession gives remembered autoApprove precedence over a configured setting while policy still clamps', async () => { const storageService = disposables.add(new InMemoryStorageService()); storageService.store(STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES, JSON.stringify({ diff --git a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index cd8ab5d17c7043..4483d44d7261d4 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -1358,7 +1358,13 @@ registerAction2(class DuplicateAutomationAction extends Action2 { prompt: automation.prompt, schedule: automation.schedule, target: automation.target, - sessionTemplate: automation.sessionTemplate, + ...(automation.sessionTemplate + ? { sessionTemplate: automation.sessionTemplate } + : { + modelId: automation.modelId, + mode: automation.mode, + permissionLevel: automation.permissionLevel, + }), enabled: automation.enabled, }, }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts index 3103148058dc45..faf810fec4b0a0 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/automationsView.test.ts @@ -950,6 +950,33 @@ suite('AutomationsCardsWidget', () => { }); }); + test('duplicate preserves legacy flat configuration when no session template exists', async () => { + const { automationDialogService, automationService, instantiationService } = setup(); + const source = automation({ + name: 'Legacy review', + sessionTemplate: undefined, + modelId: 'legacy-model', + mode: 'ask', + permissionLevel: 'autopilot', + }); + automationService.setAutomations([source]); + const command = CommandsRegistry.getCommand('sessions.automations.duplicate'); + assert.ok(command); + + await instantiationService.invokeFunction(accessor => command.handler(accessor, source)); + + assert.deepStrictEqual(automationDialogService.lastOptions?.initialValues, { + name: 'Legacy review Copy', + prompt: source.prompt, + schedule: source.schedule, + target: source.target, + modelId: 'legacy-model', + mode: 'ask', + permissionLevel: 'autopilot', + enabled: source.enabled, + }); + }); + test('duplicate dialog failures are logged and reported to the user', async () => { const { automationDialogService, automationService, dialogService, instantiationService, logService } = setup(); const source = automation(); diff --git a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts index 6d0268627b9b0c..381801a4c64082 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts @@ -24,7 +24,7 @@ type AutomationCreateEvent = { type AutomationCreateClassification = { intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence the user picked (manual/hourly/daily/weekly).' }; - permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Permission level chosen (default/autoApprove/autopilot).' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Legacy permission-level alias when available (default/assisted/autoApprove/autopilot).' }; isolationMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode chosen (workspace/worktree).' }; enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the automation was created in the enabled state.' }; owner: 'benvillalobos'; @@ -34,7 +34,7 @@ type AutomationCreateClassification = { export function publishAutomationCreated(telemetryService: ITelemetryService, automation: IAutomationDescriptor): void { telemetryService.publicLog2('automation.create', { intervalKind: automation.schedule.interval, - permissionLevel: getAutomationPermissionLevel(automation), + permissionLevel: automation.permissionLevel ?? '', isolationMode: getAutomationIsolationMode(automation), enabled: automation.enabled, }); @@ -98,7 +98,7 @@ type AutomationRunClassification = { intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence of the automation that ran.' }; success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the run completed without error.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock duration of the run kickoff (recordRunStart through completed/failed).' }; - permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Permission level applied to the run (default/autoApprove/autopilot).' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Legacy permission-level alias applied to the run when available (default/assisted/autoApprove/autopilot).' }; isolationMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode applied to the run (workspace/worktree).' }; owner: 'benvillalobos'; comment: 'Tracks Automations run outcomes and timing.'; @@ -115,7 +115,7 @@ export function publishAutomationRun(telemetryService: ITelemetryService, args: intervalKind: args.automation.schedule.interval, success: args.success, durationMs: Math.max(0, Math.round(args.durationMs)), - permissionLevel: getAutomationPermissionLevel(args.automation), + permissionLevel: args.automation.permissionLevel ?? '', isolationMode: getAutomationIsolationMode(args.automation), }); } @@ -129,13 +129,6 @@ function getAutomationIsolationMode(automation: IAutomationDescriptor): string { : automation.target.isolation.kind === 'worktree' ? 'worktree' : ''; } -const automationPermissionLevels = new Set(['default', 'assisted', 'autoApprove', 'autopilot']); - -function getAutomationPermissionLevel(automation: IAutomationDescriptor): string { - const value = automation.sessionTemplate?.config?.['autoApprove'] ?? automation.permissionLevel; - return typeof value === 'string' && automationPermissionLevels.has(value) ? value : ''; -} - type AutomationRunErrorEvent = { trigger: AutomationRunTrigger; intervalKind: AutomationInterval; From f6b34f38ca4cda291cd53949ce974243decda3c7 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 17:36:11 +0200 Subject: [PATCH 10/15] automations: fix: make configuration saving failure-safe Keep the Automation dialog open when provider configuration cannot be captured, expose cancellable saving progress, and serialize draft retargeting. Unify canonical-template authority across stores, require providers to advertise restoration support, preserve definition-owned state, retain scoped picker models across toolbar rebuilds, and keep legacy fallback configuration available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- src/vs/base/browser/ui/dialog/dialog.ts | 65 ++++--- .../test/browser/ui/dialog/dialog.test.ts | 83 +++++++- .../agentHost/common/sessionConfigKeys.ts | 35 ++++ src/vs/sessions/AUTOMATIONS.md | 2 +- src/vs/sessions/SESSIONS.md | 2 +- .../automations/browser/automationDialog.ts | 146 ++++++++++---- .../browser/automationDialogService.ts | 182 ++++++++++++------ .../automations/browser/automationRunner.ts | 3 + .../automations/browser/automationService.ts | 5 +- .../automations/browser/automationTools.ts | 8 +- .../browser/media/automationDialog.css | 19 +- .../test/browser/automationDialog.test.ts | 76 +++++++- .../test/browser/automationRunner.test.ts | 3 + .../test/browser/automationTools.test.ts | 51 ++++- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- .../agentHost/browser/agentHostAgentPicker.ts | 77 +++++++- .../browser/agentHostAutomationStore.ts | 14 +- .../browser/baseAgentHostSessionsProvider.ts | 24 +-- .../browser/agentHostAutomationStore.test.ts | 72 ++++--- .../localAgentHostSessionsProvider.test.ts | 5 + .../browser/copilotChatSessionsProvider.ts | 1 + .../browser/sessionsManagementService.ts | 56 +++--- .../sessions/common/sessionsManagement.ts | 3 + .../sessions/common/sessionsProvider.ts | 4 +- .../test/browser/sessionNavigation.test.ts | 1 + .../browser/sessionsManagementService.test.ts | 32 +++ .../common/automations/automationService.ts | 16 ++ 27 files changed, 755 insertions(+), 232 deletions(-) diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 34d22b5d162357..486d0e086be26a 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -13,6 +13,7 @@ import { ICheckboxStyles, Checkbox } from '../toggle/toggle.js'; import { IInputBoxStyles, InputBox } from '../inputbox/inputBox.js'; import { Action, toAction } from '../../../common/actions.js'; import { Codicon } from '../../../common/codicons.js'; +import { onUnexpectedError } from '../../../common/errors.js'; import { ThemeIcon } from '../../../common/themables.js'; import { KeyCode, KeyMod } from '../../../common/keyCodes.js'; import { mnemonicButtonLabel } from '../../../common/labels.js'; @@ -69,6 +70,8 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + /** Invoked before a button closes the dialog; return `false` to keep it open. */ + readonly buttonHandler?: (button: number) => boolean | Promise; /** * Temporary escape hatch for dialogs that embed widgets whose popups mount * at window root (outside the dialog DOM). Needed because the focus trap @@ -288,25 +291,37 @@ export class Dialog extends Disposable { return new Promise(resolve => { clearNode(this.buttonsContainer); - const close = () => { - resolve({ - button: this.options.cancelId || 0, - checkboxChecked: this.checkbox ? this.checkbox.checked : undefined - }); - return; - }; - this._register(toDisposable(close)); - const buttonBar = this.buttonBar = this._register(new ButtonBar(this.buttonsContainer, { alignment: this.options?.alignment === DialogContentsAlignment.Vertical ? ButtonBarAlignment.Vertical : ButtonBarAlignment.Horizontal })); const buttonMap = this.rearrangeButtons(this.buttons, this.options.cancelId); - - const onButtonClick = (index: number) => { + let settled = false; + const complete = (button: number, includeValues: boolean) => { + if (settled) { + return; + } + settled = true; resolve({ - button: buttonMap[index].index, + button, checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, - values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined + ...(includeValues ? { values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined } : {}), }); }; + const tryComplete = async (button: number, includeValues: boolean) => { + if (settled) { + return; + } + try { + if (this.options.buttonHandler && !await this.options.buttonHandler(button)) { + return; + } + complete(button, includeValues); + } catch (error) { + onUnexpectedError(error); + } + }; + const close = () => void tryComplete(this.options.cancelId ?? 0, false); + this._register(toDisposable(() => complete(this.options.cancelId ?? 0, false))); + + const onButtonClick = (index: number) => tryComplete(buttonMap[index].index, true); // Buttons buttonMap.forEach((_, index) => { @@ -325,7 +340,7 @@ export class Dialog extends Disposable { run: async () => { await action.run(); - onButtonClick(index); + await onButtonClick(index); } })) })); @@ -350,7 +365,7 @@ export class Dialog extends Disposable { EventHelper.stop(e); } - onButtonClick(index); + void onButtonClick(index); })); }); @@ -373,12 +388,7 @@ export class Dialog extends Disposable { // Enter in input field should OK the dialog if (this.inputs.some(input => input.hasFocus())) { EventHelper.stop(e); - - resolve({ - button: buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0, - checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, - values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined - }); + void tryComplete(buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0, true); } return; // leave default handling @@ -390,11 +400,7 @@ export class Dialog extends Disposable { const noButton = buttonMap.find(button => button.index === 1 && button.index !== this.options.cancelId); if (noButton) { - resolve({ - button: noButton.index, - checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, - values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined - }); + void tryComplete(noButton.index, true); } return; // leave default handling @@ -560,12 +566,7 @@ export class Dialog extends Disposable { if (!this.options.disableCloseAction && !this.options.disableCloseButton) { const actionBar = this._register(new ActionBar(this.toolbarContainer, {})); - const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => { - resolve({ - button: this.options.cancelId || 0, - checkboxChecked: this.checkbox ? this.checkbox.checked : undefined - }); - })); + const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => close())); actionBar.push(action, { icon: true, label: false }); } diff --git a/src/vs/base/test/browser/ui/dialog/dialog.test.ts b/src/vs/base/test/browser/ui/dialog/dialog.test.ts index 5bd29321018bda..6dcd4dff39c5b9 100644 --- a/src/vs/base/test/browser/ui/dialog/dialog.test.ts +++ b/src/vs/base/test/browser/ui/dialog/dialog.test.ts @@ -5,10 +5,11 @@ import assert from 'assert'; import { $, append, getWindow } from '../../../../browser/dom.js'; -import { Button, unthemedButtonStyles } from '../../../../browser/ui/button/button.js'; +import { Button, IButton, unthemedButtonStyles } from '../../../../browser/ui/button/button.js'; import { Dialog, IDialogStyles } from '../../../../browser/ui/dialog/dialog.js'; import { unthemedInboxStyles } from '../../../../browser/ui/inputbox/inputBox.js'; import { ICheckboxStyles } from '../../../../browser/ui/toggle/toggle.js'; +import { DeferredPromise } from '../../../../common/async.js'; import { toDisposable } from '../../../../common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; @@ -112,6 +113,86 @@ suite('Dialog', () => { await result; }); + test('keeps the dialog open when an asynchronous button handler rejects closure', async () => { + const container = append(document.body, $('.test-dialog-container')); + disposables.add(toDisposable(() => container.remove())); + let primaryButton!: IButton; + let attempts = 0; + const dialog = disposables.add(new Dialog(container, 'Message', ['Save', 'Cancel'], { + cancelId: 1, + buttonHandler: async button => { + await Promise.resolve(); + return button !== 0 || ++attempts > 1; + }, + buttonOptions: [{ + styleButton: button => primaryButton = button, + }], + buttonStyles: unthemedButtonStyles, + checkboxStyles: unthemedCheckboxStyles, + inputBoxStyles: unthemedInboxStyles, + dialogStyles: unthemedDialogStyles, + })); + let completed = false; + const result = dialog.show().then(value => { + completed = true; + return value; + }); + + primaryButton.element.click(); + await Promise.resolve(); + await Promise.resolve(); + const afterRejectedClosure = completed; + primaryButton.element.click(); + + assert.deepStrictEqual({ + afterRejectedClosure, + result: await result, + attempts, + }, { + afterRejectedClosure: false, + result: { button: 0, checkboxChecked: undefined, values: undefined }, + attempts: 2, + }); + }); + + test('routes the close action through a pending asynchronous button handler', async () => { + const container = append(document.body, $('.test-dialog-container')); + disposables.add(toDisposable(() => container.remove())); + let primaryButton!: IButton; + const saveStarted = new DeferredPromise(); + const releaseSave = new DeferredPromise(); + const dialog = disposables.add(new Dialog(container, 'Message', ['Save', 'Cancel'], { + cancelId: 1, + buttonHandler: async button => { + if (button === 0) { + saveStarted.complete(); + await releaseSave.p; + return false; + } + return true; + }, + buttonOptions: [{ + styleButton: button => primaryButton = button, + }], + buttonStyles: unthemedButtonStyles, + checkboxStyles: unthemedCheckboxStyles, + inputBoxStyles: unthemedInboxStyles, + dialogStyles: unthemedDialogStyles, + })); + const result = dialog.show(); + + primaryButton.element.click(); + await saveStarted.p; + const closeButton = container.querySelector('.dialog-toolbar .action-label'); + assert.ok(closeButton); + closeButton.click(); + const cancelled = await result; + releaseSave.complete(); + await Promise.resolve(); + + assert.deepStrictEqual(cancelled, { button: 1, checkboxChecked: undefined }); + }); + test('prefers a pre-rendered detailElement over plain detail text and makes its links keyboard-focusable', async () => { const container = append(document.body, $('.test-dialog-container')); disposables.add(toDisposable(() => container.remove())); diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index 55d4f98656a99b..c291bd91a15fbe 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -70,3 +70,38 @@ export function omitTransientSessionConfigValues(values: Record): delete result[SessionConfigKey.ShellInitScripts]; return result; } + +const automationDefinitionOwnedConfigKeys = [ + SessionConfigKey.Permissions, + SessionConfigKey.Isolation, + SessionConfigKey.Branch, + SessionConfigKey.WorktreeBranchPrefix, + SessionConfigKey.WorktreeIncludeFiles, + SessionConfigKey.WorktreeBranchTrack, + SessionConfigKey.WorktreeCreateNewBranch, + SessionConfigKey.AgentMerge, + SessionConfigKey.AgentMergeController, +] as const; + +/** Removes values owned by a concrete session or target rather than a reusable Automation template. */ +export function omitAutomationSessionTemplateConfigValues(values: Record): Record { + const result = omitTransientSessionConfigValues(values); + for (const key of automationDefinitionOwnedConfigKeys) { + delete result[key]; + } + return result; +} + +/** Retains definition-owned values while an editor-facing Automation template is written back. */ +export function pickAutomationDefinitionOwnedConfigValues(values: Readonly> | undefined): Record { + const result: Record = {}; + if (!values) { + return result; + } + for (const key of automationDefinitionOwnedConfigKeys) { + if (Object.hasOwn(values, key)) { + result[key] = values[key]; + } + } + return result; +} diff --git a/src/vs/sessions/AUTOMATIONS.md b/src/vs/sessions/AUTOMATIONS.md index 4e6f557ff24781..6f0df2fbb68240 100644 --- a/src/vs/sessions/AUTOMATIONS.md +++ b/src/vs/sessions/AUTOMATIONS.md @@ -136,7 +136,7 @@ Before Agent Host authority is activated, the legacy store owns: - session-resource linkage; - terminal lifecycle updates. -The browser runner passes the complete saved session template into provider draft creation. Provider-owned configuration is therefore applied before the first resolution and request, using the same template that host-owned execution consumes. Workspace isolation and branch remain target-owned and are configured separately. +The browser runner passes the complete saved session template into provider draft creation. Providers explicitly advertise support for restoring and capturing this configuration; execution fails rather than silently using defaults when a canonical template reaches an unsupported provider. Deprecated flat aliases also flow through ordinary model, mode, and permission operations for older providers. Workspace isolation and branch remain target-owned and are configured separately. Renderer-window leader election prevents duplicate scheduled execution across windows. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index ddb82e271b888d..d5e72662662533 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -139,7 +139,7 @@ A provider that supersedes sessions from another provider may implement `resolve `createNewSession` and `createQuickChat` return untitled drafts. A draft remains `Untitled` while its first request is prepared; `isNewSessionRequestInProgress` separately lets the UI present that activity without treating the session as committed. Draft preparation receives the first query so a provider can materialize query-dependent execution state before replacing the draft. A draft enters the committed catalog when its first request is sent. The management service owns the currently presented draft; the provider owns its backend resources. `deleteNewSession` disposes an abandoned draft. -Automation editing uses an independent draft so it cannot replace the ordinary New Session composer. `ISessionsProviderCreateSessionOptions.automationConfiguration` restores the saved provider template and temporary compatibility projections before the draft's first configuration resolution. Providers that expose editable Automation configuration implement `getAutomationSessionConfiguration` to capture the current template. The management service distinguishes an unsupported capture hook from a valid empty template and a draft that was replaced while capture was pending. +Automation editing uses an independent draft so it cannot replace the ordinary New Session composer. Providers advertise `supportsAutomationSessionConfiguration` when they restore `ISessionsProviderCreateSessionOptions.automationConfiguration` before the draft's first configuration resolution and implement `getAutomationSessionConfiguration` to capture the current template. The management service rejects canonical templates for providers without this capability, while deprecated flat aliases continue through ordinary model, mode, and permission operations. It distinguishes unsupported capture from a valid empty template, a replaced draft, and capture failure. Provider-specific configuration remains opaque to shared Sessions code. Scoped Automation and New Session surfaces consume the same provider menu contributions and `ISessionContext`; providers may advertise presentation capabilities such as a combined phone Mode/Model picker without exposing provider identity checks to shared UI. diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 25ff208010a86d..a8aff4762a1ef4 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../base/browser/dom.js'; -import { raceTimeout } from '../../../../base/common/async.js'; +import { raceCancellationError, raceTimeout } from '../../../../base/common/async.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IButton } from '../../../../base/browser/ui/button/button.js'; @@ -12,7 +12,7 @@ import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; import { ISelectOptionItem, SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; import { IAction } from '../../../../base/common/actions.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; @@ -213,9 +213,12 @@ export interface IValidationState { interface IRenderFormHandle { readonly getPrompt: () => string; - readonly getSessionConfiguration: () => Promise; + readonly getSessionConfiguration: (token: CancellationToken) => Promise; readonly getBranch: () => string | undefined; - readonly waitForAutomationSessionSync: () => Promise; + readonly waitForAutomationSessionSync: (token: CancellationToken) => Promise; + readonly setSaving: (saving: boolean) => void; + readonly showSessionConfigurationError: (message: string | undefined) => void; + readonly focusSessionConfigurationError: () => void; readonly getFocusableElements: () => readonly HTMLElement[]; readonly acceptPromptSuggestion: () => boolean; } @@ -226,12 +229,13 @@ export type AutomationSessionDraftTarget = type AutomationSessionDraftService = Pick< ISessionsManagementService, - 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' + 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' | 'supportsAutomationSessionConfiguration' >; export type AutomationSessionConfigurationCapture = | { readonly kind: 'captured'; readonly configuration: IAutomationSessionConfiguration } - | { readonly kind: 'preserved'; readonly configuration: IAutomationSessionConfiguration | undefined }; + | { readonly kind: 'preserved'; readonly configuration: IAutomationSessionConfiguration | undefined } + | { readonly kind: 'failed'; readonly error: unknown }; const AUTOMATION_CONFIGURATION_CAPTURE_TIMEOUT_MS = 5_000; const AUTOMATION_CONFIGURATION_RETARGET_CAPTURE_TIMEOUT_MS = 1_000; @@ -245,8 +249,10 @@ export class AutomationSessionDraftSynchronizer extends Disposable { private session: ISession | undefined; private generation = 0; private syncScheduled = false; + private syncInProgress = false; private syncPromise = Promise.resolve(); private disposed = false; + private synchronizationError: unknown | undefined; constructor( private readonly sessionsManagementService: AutomationSessionDraftService, @@ -260,7 +266,7 @@ export class AutomationSessionDraftSynchronizer extends Disposable { update(target: AutomationSessionDraftTarget | undefined): void { if (this.targetsEqual(this.requestedTarget, target) - && (!target || !!this.session && this.sessionsManagementService.automationSession.get()?.sessionId === this.session.sessionId)) { + && (!target || this.syncScheduled || this.syncInProgress || !!this.session && this.sessionsManagementService.automationSession.get()?.sessionId === this.session.sessionId)) { return; } if (target?.sessionConfiguration) { @@ -271,28 +277,40 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } this.requestedTarget = target; this.generation++; + this.synchronizationError = undefined; this.availability.set(target ? 'pending' : 'idle', undefined); this.scheduleSync(); } - async waitForSync(): Promise { + async waitForSync(token: CancellationToken = CancellationToken.None): Promise { let pendingSync: Promise; do { pendingSync = this.syncPromise; - await pendingSync; + await raceCancellationError(pendingSync, token); } while (pendingSync !== this.syncPromise); } - async getSessionConfiguration(): Promise { - for (let attempt = 0; attempt < 2 && !this.disposed; attempt++) { - await this.waitForSync(); + async getSessionConfiguration(token: CancellationToken = CancellationToken.None): Promise { + const deadline = Date.now() + this.configurationCaptureTimeoutMs; + while (!this.disposed) { + const synchronized = await this.waitForResultBeforeDeadline(this.waitForSync(token), deadline, token); + if (!synchronized) { + return this.captureFailed(new Error(`Timed out after ${this.configurationCaptureTimeoutMs}ms while synchronizing the Automation session configuration.`)); + } const generation = this.generation; const session = this.session; const target = this.requestedTarget; if (!session || !target) { + if (this.synchronizationError) { + return { kind: 'failed', error: this.synchronizationError }; + } return { kind: 'preserved', configuration: this.configurationForTarget(target) }; } - const captured = await this.captureSessionConfiguration(session, target, this.configurationCaptureTimeoutMs); + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return this.captureFailed(new Error(`Timed out after ${this.configurationCaptureTimeoutMs}ms while capturing the Automation session configuration.`)); + } + const captured = await this.captureSessionConfiguration(session, target, remaining, token); if (generation !== this.generation || session !== this.session) { continue; } @@ -302,17 +320,21 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } private scheduleSync(): void { - if (this.syncScheduled) { + this.syncScheduled = true; + if (this.syncInProgress) { return; } - this.syncScheduled = true; - this.syncPromise = Promise.resolve().then(() => { - this.syncScheduled = false; - if (!this.disposed) { - return this.sync(this.generation); + this.syncInProgress = true; + this.syncPromise = (async () => { + try { + while (this.syncScheduled && !this.disposed) { + this.syncScheduled = false; + await this.sync(this.generation); + } + } finally { + this.syncInProgress = false; } - return undefined; - }); + })(); } private async sync(generation: number): Promise { @@ -359,9 +381,10 @@ export class AutomationSessionDraftSynchronizer extends Disposable { }); this.appliedTarget = target; this.appliedConfiguration = sessionConfiguration; - this.availability.set('available', undefined); + this.availability.set(this.sessionsManagementService.supportsAutomationSessionConfiguration(this.session) ? 'available' : 'unavailable', undefined); } catch (error) { if (!this.disposed && generation === this.generation) { + this.synchronizationError = error; this.discardSession(); this.availability.set('unavailable', undefined); this.onError(error); @@ -392,26 +415,44 @@ export class AutomationSessionDraftSynchronizer extends Disposable { this.appliedConfiguration = undefined; } - private async captureSessionConfiguration(session: ISession, target: AutomationSessionDraftTarget, timeoutMs: number): Promise { + private async captureSessionConfiguration(session: ISession, target: AutomationSessionDraftTarget, timeoutMs: number, token: CancellationToken = CancellationToken.None): Promise { try { const result = await raceTimeout( - this.sessionsManagementService.getAutomationSessionConfiguration(session).then(configuration => ({ configuration })), + raceCancellationError(this.sessionsManagementService.getAutomationSessionConfiguration(session).then(configuration => ({ configuration })), token), timeoutMs, ); if (!result) { throw new Error(`Timed out after ${timeoutMs}ms while capturing Automation session configuration.`); } - if (result.configuration === null || result.configuration === undefined) { + if (result.configuration === null) { return { kind: 'preserved', configuration: this.configurationForTarget(target) }; } + if (result.configuration === undefined) { + throw new Error('The Automation session draft was replaced before its configuration could be captured.'); + } this.configurationsByTarget.set(this.targetKey(target), result.configuration); return { kind: 'captured', configuration: result.configuration }; } catch (error) { - this.onError(error); - return { kind: 'preserved', configuration: this.configurationForTarget(target) }; + if (token.isCancellationRequested) { + throw error; + } + return this.captureFailed(error); } } + private captureFailed(error: unknown): AutomationSessionConfigurationCapture { + this.onError(error); + return { kind: 'failed', error }; + } + + private async waitForResultBeforeDeadline(promise: Promise, deadline: number, token: CancellationToken): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return false; + } + return await raceTimeout(raceCancellationError(promise.then(() => true), token), remaining) ?? false; + } + private configurationForTarget(target: AutomationSessionDraftTarget | undefined): IAutomationSessionConfiguration | undefined { return target ? this.configurationsByTarget.get(this.targetKey(target)) ?? target.sessionConfiguration : undefined; } @@ -940,7 +981,8 @@ export function renderForm( initialTarget: AutomationTarget | undefined, initialSessionConfiguration: IAutomationSessionConfiguration | undefined, ): IRenderFormHandle { - const nameRow = DOM.append(form, $('.automation-form-row')); + const formContent = DOM.append(form, $('.automation-form-content')); + const nameRow = DOM.append(formContent, $('.automation-form-row')); DOM.append(nameRow, $('span.automation-form-label', undefined, localize('automation.form.name', "Name"))); const nameInputContainer = DOM.append(nameRow, $('.automation-form-input-host')); const nameInput = disposables.add(new InputBox(nameInputContainer, contextViewService, { @@ -954,7 +996,7 @@ export function renderForm( revalidate(); })); - const scheduleRow = DOM.append(form, $('.automation-form-row.automation-form-schedule-row')); + const scheduleRow = DOM.append(formContent, $('.automation-form-row.automation-form-schedule-row')); const useCustomDrawn = !hasNativeContextMenu(configurationService); const intervalGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group')); @@ -1141,7 +1183,7 @@ export function renderForm( revalidate(); })); - const promptSection = DOM.append(form, $('.automation-prompt-section')); + const promptSection = DOM.append(formContent, $('.automation-prompt-section')); const promptRow = DOM.append(promptSection, $('.automation-form-row')); DOM.append(promptRow, $('span.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); @@ -1296,13 +1338,13 @@ export function renderForm( sessionConfigContainer, scopedInstantiationService, compactModelPicker, - localize('automation.form.sessionModelAndAgent', "Session model and agent"), + localize('automation.form.sessionConfigurationOptions', "Session configuration options"), )); const sessionControlsContainer = DOM.append(sessionConfiguration, $('.automation-session-controls')); const sessionControlsToolbar = disposables.add(createNewSessionControlToolbar( sessionControlsContainer, scopedInstantiationService, - localize('automation.form.sessionModeAndApprovals', "Session mode and approvals"), + localize('automation.form.sessionControls', "Session controls"), )); const sessionConfigLayout = disposables.add(new ChatInputPickerResponsiveLayout('AutomationDialog.sessionConfig', sessionConfigContainer, { getItems: () => getAutomationSessionToolbarResponsiveItems(sessionConfigToolbar, compactModelPicker), @@ -1320,15 +1362,20 @@ export function renderForm( role: 'status', 'aria-atomic': 'true', })); + const sessionConfigurationError = DOM.append(sessionConfiguration, $('span.automation-session-configuration-error', { + role: 'alert', + tabindex: '-1', + })); + DOM.hide(sessionConfigurationError); disposables.add(autorun(reader => { const availability = automationSessionDraftSynchronizer.availability.read(reader); const pending = availability === 'pending'; const controlsUnavailable = availability !== 'available'; sessionConfiguration.classList.toggle('controls-unavailable', controlsUnavailable); - sessionConfiguration.setAttribute('aria-busy', String(pending)); for (const container of [sessionConfigContainer, sessionControlsContainer]) { container.toggleAttribute('inert', controlsUnavailable); container.setAttribute('aria-hidden', String(controlsUnavailable)); + container.setAttribute('aria-busy', String(pending)); } sessionConfigurationUnavailable.textContent = pending ? localize('automation.form.sessionConfigurationLoading', "Loading session configuration…") @@ -1364,7 +1411,7 @@ export function renderForm( }, DOM.getWindow(promptHost))); disposables.add(resizeObserver.observe(promptHost)); - const enabledRow = DOM.append(form, $('.automation-form-row.automation-form-checkbox-row')); + const enabledRow = DOM.append(formContent, $('.automation-form-row.automation-form-checkbox-row')); const enabledLabelText = localize('automation.form.enabled', "Enabled (the scheduler runs this automation when due)"); const enabledCheckbox = disposables.add(new Checkbox(enabledLabelText, state.enabled, defaultCheckboxStyles)); DOM.append(enabledRow, enabledCheckbox.domNode); @@ -1381,15 +1428,40 @@ export function renderForm( disposables.add(DOM.addStandardDisposableListener(enabledLabel, 'click', () => { setEnabled(!enabledCheckbox.checked); })); + const saveStatus = DOM.append(form, $('span.automation-form-save-status', { + role: 'status', + 'aria-atomic': 'true', + })); + DOM.hide(saveStatus); return { getPrompt: () => chatInput.inputEditor.getValue(), - getSessionConfiguration: () => automationSessionDraftSynchronizer.getSessionConfiguration(), + getSessionConfiguration: token => automationSessionDraftSynchronizer.getSessionConfiguration(token), getBranch: () => isolationModel.persistedBranch, - waitForAutomationSessionSync: () => { + waitForAutomationSessionSync: token => { updateAutomationSessionTarget(); - return automationSessionDraftSynchronizer.waitForSync(); + return automationSessionDraftSynchronizer.waitForSync(token); + }, + setSaving: saving => { + formContent.toggleAttribute('inert', saving); + formContent.setAttribute('aria-busy', String(saving)); + form.classList.toggle('saving', saving); + saveStatus.textContent = saving ? localize('automation.form.saving', "Saving automation…") : ''; + if (saving) { + DOM.show(saveStatus); + } else { + DOM.hide(saveStatus); + } + }, + showSessionConfigurationError: message => { + sessionConfigurationError.textContent = message ?? ''; + if (message) { + DOM.show(sessionConfigurationError); + } else { + DOM.hide(sessionConfigurationError); + } }, + focusSessionConfigurationError: () => sessionConfigurationError.focus(), getFocusableElements: () => { // eslint-disable-next-line no-restricted-syntax -- the dialog owns this form subtree and supplies its dynamic focus order. return Array.from(form.querySelectorAll('input, select, textarea, button, a[href], [tabindex]')); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 0ae970efddcf5e..dac9d5f1bd82a0 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -7,7 +7,9 @@ import './media/automationDialog.css'; import * as DOM from '../../../../base/browser/dom.js'; import { IButton } from '../../../../base/browser/ui/button/button.js'; import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -114,11 +116,17 @@ export class AutomationDialogService implements IAutomationDialogService { let cancelButton: IButton | undefined; let revalidate: () => void = () => { }; let getPrompt: () => string = () => initial?.prompt ?? ''; - let getSessionConfiguration = async (): Promise => ({ kind: 'preserved', configuration: initialSessionConfiguration }); + let getSessionConfiguration: (token: CancellationToken) => Promise = async () => ({ kind: 'preserved', configuration: initialSessionConfiguration }); let getBranch: () => string | undefined = () => initialWorkspaceTarget?.isolation.kind === 'worktree' ? initialWorkspaceTarget.isolation.branch : undefined; - let waitForAutomationSessionSync: () => Promise = async () => { }; + let waitForAutomationSessionSync: (token: CancellationToken) => Promise = async () => { }; + let setSaving: (saving: boolean) => void = () => { }; + let showSessionConfigurationError: (message: string | undefined) => void = () => { }; + let focusSessionConfigurationError: () => void = () => { }; let getFocusableElements: () => readonly HTMLElement[] = () => []; let focusFirst: () => void = () => { }; + let preparedResult: IAutomationDialogResult | undefined; + let saveInProgress = false; + const saveCancellation = disposables.add(new MutableDisposable()); const title = isEdit ? localize('automation.dialog.editTitle', "Edit automation") @@ -128,6 +136,52 @@ export class AutomationDialogService implements IAutomationDialogService { isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"), localize('automation.dialog.cancel', "Cancel"), ]; + const savingButtonLabel = localize('automation.dialog.saving', "Saving…"); + const captureErrorMessage = localize('automation.dialog.captureError', "The automation wasn't saved because its session configuration couldn't be captured. Check the provider connection and try again."); + + const buildResult = (sessionConfigurationCapture: Exclude): IAutomationDialogResult | undefined => { + const schedule: IAutomationSchedule = { + interval: state.interval, + scheduleHour: state.hour, + scheduleMinute: state.minute, + scheduleDay: state.day, + }; + const prompt = getPrompt(); + const sessionConfiguration = sessionConfigurationCapture.configuration; + const sessionTemplate = sessionConfiguration?.sessionTemplate; + const target = createAutomationTarget(state, getBranch()); + if (!target) { + return undefined; + } + if (existing) { + const patch: IUpdateAutomationOptions = { + name: state.name, + prompt, + schedule, + target, + ...(sessionConfigurationCapture.kind === 'captured' ? { + sessionTemplate: sessionTemplate ?? null, + } : {}), + enabled: state.enabled, + }; + return { kind: 'update', id: existing.id, value: patch }; + } + const create: ICreateAutomationOptions = { + name: state.name, + prompt, + schedule, + target, + ...(sessionTemplate + ? { sessionTemplate } + : sessionConfiguration ? { + ...(sessionConfiguration.modelId !== undefined ? { modelId: sessionConfiguration.modelId } : {}), + ...(sessionConfiguration.mode !== undefined ? { mode: sessionConfiguration.mode } : {}), + ...(sessionConfiguration.permissionLevel !== undefined ? { permissionLevel: sessionConfiguration.permissionLevel } : {}), + } : {}), + enabled: state.enabled, + }; + return { kind: 'create', value: create }; + }; const activeContainer = this.layoutService.activeContainer; const dialog = disposables.add(new Dialog( @@ -139,6 +193,68 @@ export class AutomationDialogService implements IAutomationDialogService { extraClasses: ['automation-dialog'], cancelId: 1, isExternalFocusAllowed: isAutomationDialogPopupTarget, + buttonHandler: async button => { + if (button !== 0) { + saveCancellation.value?.cancel(); + return true; + } + if (saveInProgress) { + return false; + } + revalidate(); + if (validation.nameError || validation.promptError || validation.folderError || validation.sessionTypeError || validation.branchError) { + return false; + } + if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) { + return false; + } + + saveInProgress = true; + showSessionConfigurationError(undefined); + setSaving(true); + if (saveButton) { + saveButton.enabled = false; + saveButton.label = savingButtonLabel; + } + const cancellation = new CancellationTokenSource(); + saveCancellation.value = cancellation; + let shouldClose = false; + let shouldFocusError = false; + try { + await waitForAutomationSessionSync(cancellation.token); + const sessionConfigurationCapture = await getSessionConfiguration(cancellation.token); + if (sessionConfigurationCapture.kind === 'failed') { + showSessionConfigurationError(captureErrorMessage); + shouldFocusError = true; + return false; + } + preparedResult = buildResult(sessionConfigurationCapture); + shouldClose = !!preparedResult; + return shouldClose; + } catch (error) { + if (!isCancellationError(error) && !cancellation.token.isCancellationRequested) { + this.logService.error('[AutomationDialog] Failed to save the automation session configuration.', error); + showSessionConfigurationError(captureErrorMessage); + shouldFocusError = true; + } + return false; + } finally { + if (saveCancellation.value === cancellation) { + saveCancellation.clear(); + } + saveInProgress = false; + if (!shouldClose) { + setSaving(false); + if (saveButton) { + saveButton.label = buttonLabels[0]; + } + revalidate(); + if (shouldFocusError) { + focusSessionConfigurationError(); + } + } + } + }, // textLinkForeground stamps inline styles onto chat input picker chips. dialogStyles: { ...defaultDialogStyles, textLinkForeground: undefined }, buttonOptions: [ @@ -173,6 +289,9 @@ export class AutomationDialogService implements IAutomationDialogService { getSessionConfiguration = handle.getSessionConfiguration; getBranch = handle.getBranch; waitForAutomationSessionSync = handle.waitForAutomationSessionSync; + setSaving = handle.setSaving; + showSessionConfigurationError = handle.showSessionConfigurationError; + focusSessionConfigurationError = handle.focusSessionConfigurationError; getFocusableElements = handle.getFocusableElements; const keyboardNavigation = disposables.add(registerAutomationDialogKeyboardNavigation( DOM.getWindow(container), @@ -202,62 +321,7 @@ export class AutomationDialogService implements IAutomationDialogService { if (result.button !== 0) { return undefined; } - // Guard against submit-with-Enter bypassing live validation. - revalidate(); - if (validation.nameError || validation.promptError || validation.folderError || validation.sessionTypeError || validation.branchError) { - return undefined; - } - if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) { - return undefined; - } - await waitForAutomationSessionSync(); - - const schedule: IAutomationSchedule = { - interval: state.interval, - scheduleHour: state.hour, - scheduleMinute: state.minute, - scheduleDay: state.day, - }; - - const prompt = getPrompt(); - const sessionConfigurationCapture = await getSessionConfiguration(); - const sessionConfiguration = sessionConfigurationCapture.configuration; - const sessionTemplate = sessionConfiguration?.sessionTemplate; - const branch = getBranch(); - const target = createAutomationTarget(state, branch); - if (!target) { - return undefined; - } - - if (existing) { - const patch: IUpdateAutomationOptions = { - name: state.name, - prompt, - schedule, - target, - ...(sessionConfigurationCapture.kind === 'captured' ? { - sessionTemplate: sessionTemplate ?? null, - } : {}), - enabled: state.enabled, - }; - return { kind: 'update', id: existing.id, value: patch }; - } - - const create: ICreateAutomationOptions = { - name: state.name, - prompt, - schedule, - target, - ...(sessionTemplate - ? { sessionTemplate } - : sessionConfiguration ? { - ...(sessionConfiguration.modelId !== undefined ? { modelId: sessionConfiguration.modelId } : {}), - ...(sessionConfiguration.mode !== undefined ? { mode: sessionConfiguration.mode } : {}), - ...(sessionConfiguration.permissionLevel !== undefined ? { permissionLevel: sessionConfiguration.permissionLevel } : {}), - } : {}), - enabled: state.enabled, - }; - return { kind: 'create', value: create }; + return preparedResult; } finally { disposables.dispose(); } diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index ca5bae016c9212..111ce80c0a7cfd 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -101,6 +101,9 @@ export class AutomationRunner implements IAutomationRunner { sessionTemplate: automation.sessionTemplate, automationConfiguration, } : {}), + ...((automation.sessionTemplate?.modelId ?? automation.modelId) ? { modelId: automation.sessionTemplate?.modelId ?? automation.modelId } : {}), + ...(!automation.sessionTemplate && automation.mode ? { modeId: automation.mode } : {}), + ...(!automation.sessionTemplate && automation.permissionLevel ? { permissionLevel: automation.permissionLevel } : {}), isolationMode, branch, } diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index 6b23e4ede40c8b..250e531bcc8def 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -21,6 +21,7 @@ import { } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { type AutomationMutationGuard, + assertAutomationSessionTemplateAuthority, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, @@ -725,14 +726,12 @@ function updateAutomation(current: IAutomationDescriptor, patch: IUpdateAutomati } function mergeAutomation(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): IAutomationDescriptor { + assertAutomationSessionTemplateAuthority(current, patch); const target = patch.target ? normalizeAutomationTarget(patch.target) : current.target; const targetAuthorityChanged = patch.target !== undefined && (target.providerId !== current.target.providerId || target.sessionTypeId !== current.target.sessionTypeId); const templatePatched = patch.sessionTemplate !== undefined; const legacyConfigurationPatched = patch.modelId !== undefined || patch.mode !== undefined || patch.permissionLevel !== undefined; - if (current.sessionTemplate && !templatePatched && !targetAuthorityChanged && legacyConfigurationPatched) { - throw new Error('A canonical Automation session template cannot be updated through legacy configuration aliases.'); - } const currentModelId = current.sessionTemplate ? undefined : current.modelId; const currentMode = current.sessionTemplate ? undefined : current.mode; const currentPermissionLevel = current.sessionTemplate ? undefined : current.permissionLevel; diff --git a/src/vs/sessions/contrib/automations/browser/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 51cff61161d976..8ec0bec523805c 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -17,7 +17,7 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { AutomationInterval, AutomationTarget, AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, IAutomationSchedule, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationRunDispatch, IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { type AutomationMutationGuard, ConfigureAutomationToolReferenceName, IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, serializeAutomationEditableState } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { type AutomationMutationGuard, AutomationSessionTemplateAuthorityError, ConfigureAutomationToolReferenceName, IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, serializeAutomationEditableState } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IChatAutomationConfiguredData } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; @@ -595,6 +595,9 @@ The change uses the current tool-approval policy. When approval is required, the if (error instanceof AutomationToolInputError) { return automationToolError(error.message); } + if (error instanceof AutomationSessionTemplateAuthorityError) { + return automationToolError(error.message); + } throw error; } } @@ -702,6 +705,9 @@ The change uses the current tool-approval policy. When approval is required, the if (sessionTemplate !== undefined && (modelId !== undefined || mode !== undefined || permissionLevel !== undefined)) { throw new AutomationToolInputError('"sessionTemplate" cannot be combined with legacy "modelId", "mode", or "permissionLevel" aliases.'); } + if (existing?.sessionTemplate && sessionTemplate === undefined && (modelId !== undefined || mode !== undefined || permissionLevel !== undefined)) { + throw new AutomationToolInputError('Legacy "modelId", "mode", and "permissionLevel" aliases cannot update an automation with a canonical session template. Pass the complete updated "sessionTemplate" returned by listAutomations.'); + } const enabled = readOptionalBoolean(input, 'enabled'); const proposedValues: IUpdateAutomationOptions = { diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index eb357799d3e823..9ae2f680335fdc 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -223,10 +223,16 @@ .automation-form { display: flex; flex-direction: column; - gap: 10px; + gap: var(--vscode-spacing-size100); padding: 2px 2px 4px; } +.automation-form-content { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); +} + .automation-prompt-section { display: flex; flex-direction: column; @@ -332,6 +338,17 @@ font-size: var(--vscode-fontSize-body2); } +.automation-session-configuration-error { + flex-basis: 100%; + color: var(--vscode-inputValidation-errorForeground); + font-size: var(--vscode-fontSize-body2); +} + +.automation-form-save-status { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + .automation-session-config .monaco-toolbar, .automation-session-controls .monaco-toolbar, .automation-session-config .monaco-action-bar, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index 7aeb5b33d861e9..a40db4b3cf0c6c 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -11,6 +11,7 @@ import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Action, IAction } from '../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { getErrorMessage } from '../../../../../base/common/errors.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; @@ -172,6 +173,7 @@ function createAutomationDraftService(captureSupported = true, captureError?: Er automationSession, createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri, options?.sessionTemplate), createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default', undefined, options?.sessionTemplate), + supportsAutomationSessionConfiguration: () => captureSupported, getAutomationSessionConfiguration: async session => { if (captureError) { throw captureError; @@ -310,7 +312,7 @@ suite('Automation session draft synchronization', () => { }); }); - test('preserves known configuration when capture fails', async () => { + test('reports capture failures instead of silently preserving configuration', async () => { const sessionConfiguration: IAutomationSessionConfiguration = { sessionTemplate: { config: { mode: 'plan' } }, }; @@ -325,16 +327,17 @@ suite('Automation session draft synchronization', () => { sessionConfiguration, }); + const capture = await synchronizer.getSessionConfiguration(); assert.deepStrictEqual({ - capture: await synchronizer.getSessionConfiguration(), + capture: capture.kind === 'failed' ? { kind: capture.kind, message: getErrorMessage(capture.error) } : capture, errorCount, }, { - capture: { kind: 'preserved', configuration: sessionConfiguration }, + capture: { kind: 'failed', message: 'capture failed' }, errorCount: 1, }); }); - test('bounds configuration capture and preserves known configuration on timeout', async () => { + test('bounds complete configuration capture and reports timeouts', async () => { const sessionConfiguration: IAutomationSessionConfiguration = { sessionTemplate: { config: { mode: 'plan' } }, }; @@ -349,15 +352,73 @@ suite('Automation session draft synchronization', () => { sessionConfiguration, }); + const capture = await synchronizer.getSessionConfiguration(); assert.deepStrictEqual({ - capture: await synchronizer.getSessionConfiguration(), + capture: capture.kind === 'failed' ? { kind: capture.kind, timedOut: getErrorMessage(capture.error).includes('Timed out') } : capture, errorCount, }, { - capture: { kind: 'preserved', configuration: sessionConfiguration }, + capture: { kind: 'failed', timedOut: true }, errorCount: 1, }); }); + test('coalesces an equal target while synchronization is pending', async () => { + const validation = new DeferredPromise(); + const { service, created } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, () => validation.p, () => { })); + const target = { + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + } as const; + + synchronizer.update(target); + await Promise.resolve(); + synchronizer.update(target); + validation.complete(true); + await synchronizer.waitForSync(); + + assert.deepStrictEqual(created, [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///workspace', + }]); + }); + + test('serializes synchronization when the target changes during validation', async () => { + const firstValidation = new DeferredPromise(); + const validated: string[] = []; + const { service, created } = createAutomationDraftService(); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async folderUri => { + validated.push(folderUri.path); + return folderUri.path === '/first' ? firstValidation.p : true; + }, () => { })); + + synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///first'), providerId: 'provider', sessionTypeId: 'type' }); + await Promise.resolve(); + synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///second'), providerId: 'provider', sessionTypeId: 'type' }); + const beforeFirstSettled = [...validated]; + firstValidation.complete(true); + await synchronizer.waitForSync(); + + assert.deepStrictEqual({ + beforeFirstSettled, + validated, + created, + }, { + beforeFirstSettled: ['/first'], + validated: ['/first', '/second'], + created: [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///second', + }], + }); + }); + test('carries captured configuration when returning to a previous target', async () => { const initialConfiguration: IAutomationSessionConfiguration = { sessionTemplate: { config: { mode: 'interactive' } }, @@ -410,9 +471,8 @@ suite('Automation session draft synchronization', () => { synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///first'), providerId: 'provider', sessionTypeId: 'type' }); await Promise.resolve(); synchronizer.update({ kind: 'workspace', folderUri: URI.parse('file:///second'), providerId: 'provider', sessionTypeId: 'type' }); - await synchronizer.waitForSync(); firstWorkspaceValidation.complete(true); - await Promise.resolve(); + await synchronizer.waitForSync(); assert.deepStrictEqual(created, [ { kind: 'workspace', providerId: 'provider', sessionTypeId: 'type', folderUri: 'file:///second' }, diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index e590712155a5fa..65a3527fe9a8f5 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -601,6 +601,8 @@ suite('AutomationRunner', () => { mode: 'agent', permissionLevel: 'autopilot', }, + modeId: 'agent', + permissionLevel: 'autopilot', isolationMode: undefined, branch: undefined, }); @@ -638,6 +640,7 @@ suite('AutomationRunner', () => { automationConfiguration: { sessionTemplate, }, + modelId: 'model', isolationMode: undefined, branch: undefined, }); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts index a136a3757131f2..fc438bb78d9ade 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationTools.test.ts @@ -18,7 +18,7 @@ import { NullTelemetryService } from '../../../../../platform/telemetry/common/t import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationRunDispatch, IAutomationRunner, IAutomationRunOperation } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; -import { IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationSessionTemplateAuthorityError, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IToolImpl, IToolInvocation, IToolResult, ToolProgress } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IChat, ISession, ISessionType, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; @@ -923,6 +923,55 @@ suite('AutomationTools', () => { }]); }); + test('configureAutomation reports legacy alias updates to a canonical template as input errors', async () => { + const existing = createAutomation({ + sessionTemplate: { + modelId: 'model', + config: { mode: 'interactive', autoApprove: 'default' }, + }, + }); + const automationService = new FakeAutomationService([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + + const result = await invoke(tool, { + automationId: existing.id, + permissionLevel: 'autoApprove', + }); + + assert.deepStrictEqual({ + error: result.toolResultError, + updates: automationService.updated, + }, { + error: 'Legacy "modelId", "mode", and "permissionLevel" aliases cannot update an automation with a canonical session template. Pass the complete updated "sessionTemplate" returned by listAutomations.', + updates: [], + }); + }); + + test('configureAutomation surfaces authority changes detected during the guarded update', async () => { + const existing = createAutomation(); + const automationService = new class extends FakeAutomationService { + override async updateAutomationIfUnchanged(): Promise { + throw new AutomationSessionTemplateAuthorityError(); + } + }([existing]); + const tool = new ConfigureAutomationTool( + automationService, + new FakeSessionsManagementService(undefined), + createConfigurationService(), + ); + + const result = await invoke(tool, { + automationId: existing.id, + permissionLevel: 'autoApprove', + }); + + assert.strictEqual(result.toolResultError, 'A canonical Automation session template cannot be updated through legacy configuration aliases.'); + }); + test('configureAutomation rejects editable changes made while awaiting approval', async () => { const existing = createAutomation(); const automationService = new FakeAutomationService([existing]); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 16068dec28e188..9318b75e26230b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -95,7 +95,7 @@ create draft The first send waits for tracked draft configuration. Cancellation disposes the draft. Later configuration changes are scoped to the committed session and do not recreate the entire facade. -Automation drafts use the same `NewSession` implementation but are tracked separately by the management service. They may receive an initial Automation configuration and can be captured asynchronously after pending configuration resolution. Capture rechecks draft identity, omits transient and target-owned values, preserves untouched opaque preferences, and rejects superseded drafts. +Automation drafts use the same `NewSession` implementation but are tracked separately by the management service. Agent Host providers advertise Automation configuration support, restore the initial template before configuration resolution, and capture it asynchronously after pending resolution. Capture rechecks draft identity, omits transient, permission-grant, target-owned, and host-owned values, preserves untouched opaque preferences, and rejects superseded drafts. Existing-session requests route by the provider resource and chat resource. Host notifications update adapters and catalog membership reactively. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index 008e087beb7ab0..a2c92ee36b2bb3 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts @@ -5,7 +5,7 @@ import { BaseActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, type IObservable } from '../../../../../base/common/observable.js'; import * as nls from '../../../../../nls.js'; import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; @@ -31,6 +31,7 @@ import { ISessionContext } from '../../../../services/sessions/browser/sessionCo import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAction } from '../../../../../base/common/actions.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import type { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; const IsActiveSessionAgentHost = ContextKeyExpr.or( ContextKeyExpr.equals(SessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID), @@ -82,6 +83,69 @@ class AgentHostModePickerActionViewItem extends BaseActionViewItem { } } +interface IModePickerModelEntry { + readonly model: ModePickerModel; + readonly store: DisposableStore; + references: number; + disposalGeneration: number; +} + +class ScopedModePickerModelCache extends Disposable { + private readonly entries = new Map, IModePickerModelEntry>(); + + constructor(private readonly sessionsProvidersService: ISessionsProvidersService) { + super(); + } + + acquire(session: IObservable, instantiationService: IInstantiationService): IDisposable & { readonly model: ModePickerModel } { + let entry = this.entries.get(session); + if (!entry) { + const store = new DisposableStore(); + const model = store.add(instantiationService.createInstance(ModePickerModel)); + store.add(autorun(reader => { + const scopedSession = session.read(reader); + const provider = scopedSession ? this.sessionsProvidersService.getProvider(scopedSession.providerId) : undefined; + model.setSession(provider && isAgentHostProvider(provider) ? scopedSession : undefined, scopedSession?.mode.read(reader)?.id); + })); + entry = { model, store, references: 0, disposalGeneration: 0 }; + this.entries.set(session, entry); + } + + entry.references++; + entry.disposalGeneration++; + let disposed = false; + return { + model: entry.model, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + this.release(session, entry); + }, + }; + } + + private release(session: IObservable, entry: IModePickerModelEntry): void { + entry.references--; + const disposalGeneration = ++entry.disposalGeneration; + queueMicrotask(() => { + if (entry.references === 0 && entry.disposalGeneration === disposalGeneration && this.entries.get(session) === entry) { + this.entries.delete(session); + entry.store.dispose(); + } + }); + } + + override dispose(): void { + for (const entry of this.entries.values()) { + entry.store.dispose(); + } + this.entries.clear(); + super.dispose(); + } +} + class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.agentHostAgentPicker'; @@ -97,6 +161,7 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC ) { super(); let settingAgentInternally = false; + const modePickerModels = this._register(new ScopedModePickerModelCache(sessionsProvidersService)); const initAgentFromActiveSession = () => { const session = sessionsService.activeSession.get(); @@ -136,15 +201,9 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC const factory = (_action: IAction, _options: IActionViewItemOptions, scopedInstantiationService: IInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); - const modePickerModel = scopedInstantiationService.createInstance(ModePickerModel); - const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel, session); const disposableStore = new DisposableStore(); - disposableStore.add(modePickerModel); - disposableStore.add(autorun(reader => { - const scopedSession = session.read(reader); - const provider = this._getProvider(scopedSession, sessionsProvidersService); - modePickerModel.setSession(provider ? scopedSession : undefined, scopedSession?.mode.read(reader)?.id); - })); + const modePickerModel = disposableStore.add(modePickerModels.acquire(session, scopedInstantiationService)); + const picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel.model, session); disposableStore.add(picker.onDidSelect(mode => { this._selectMode(mode, session.get(), sessionsProvidersService); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 4ce10ef8e96982..a070aba992302c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -14,7 +14,7 @@ import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY, AGENT_HOST_LEGACY_AUTOMATION_IMPORT_PENDING_META_KEY, applyLegacyAutomationSessionConfig, migrateLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { isAgentHostAutomationCatalogMigrated, isAgentHostLegacyAutomationImport, isAgentHostLegacyAutomationImportPending } from '../../../../../platform/agentHost/common/meta/automationMeta.js'; -import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { omitAutomationSessionTemplateConfigValues, pickAutomationDefinitionOwnedConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { type IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AutomationMisfirePolicy, AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, AutomationTriggerKind, MessageKind, type AutomationDefinition, type AutomationEntry, type AutomationRunSummary, type AutomationState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -23,7 +23,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule, IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; -import { AutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { AutomationActiveRunError, assertAutomationSessionTemplateAuthority, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { publishAutomationMigration } from '../../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; import type { IAutomation, IAutomationSnapshotImportResult, IGuardedAutomationSnapshotRemovalResult, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { IAutomationStorageService } from '../../../automations/common/automationStorageService.js'; @@ -834,7 +834,10 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(modelId); const existingSession = existing && existing.session.provider === provider ? existing.session : undefined; const projectedConfig = sessionTemplate - ? { ...sessionTemplate.config } + ? { + ...pickAutomationDefinitionOwnedConfigValues(existingSession?.config), + ...sessionTemplate.config, + } : applyLegacyAutomationSessionConfig( provider, existingSession?.config, @@ -909,6 +912,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } private _applyPatch(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): IAutomationDescriptor { + assertAutomationSessionTemplateAuthority(current, patch); const now = new Date(); const schedule = patch.schedule ?? current.schedule; const enabled = patch.enabled ?? current.enabled; @@ -1249,9 +1253,7 @@ function readString(value: unknown): string | undefined { } function projectAutomationSessionTemplate(definition: AutomationDefinition, modelId: string | undefined): IAutomationSessionTemplate | undefined { - const config = { ...definition.session.config }; - delete config[SessionConfigKey.Isolation]; - delete config[SessionConfigKey.Branch]; + const config = omitAutomationSessionTemplateConfigValues({ ...definition.session.config }); return createAutomationSessionTemplate(modelId, definition.session.agent, config); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index d51196d5cda5f2..dcdb4a3b511887 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,7 +29,7 @@ import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/an import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; -import { KNOWN_MODE_VALUES, omitTransientSessionConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; +import { KNOWN_MODE_VALUES, omitAutomationSessionTemplateConfigValues, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata, type IAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; @@ -2716,6 +2716,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement abstract readonly icon: ThemeIcon; abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly usesCombinedNewSessionConfigPicker = true; + readonly supportsAutomationSessionConfiguration = true; get order(): number { return 0; } @@ -3906,34 +3907,25 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (this._getNewSession(sessionId) !== newSession) { return undefined; } - const config = omitTransientSessionConfigValues({ - ...newSession.getConfigValues(), - }); + const config = { ...newSession.getConfigValues() }; const initialConfig = newSession.getInitialSessionTemplate()?.config ?? {}; for (const [key, value] of Object.entries(initialConfig)) { if (!newSession.wasConfigValueExplicitlySet(key)) { config[key] = value; } } - delete config[SessionConfigKey.Isolation]; - delete config[SessionConfigKey.Branch]; - delete config[SessionConfigKey.WorktreeBranchPrefix]; - delete config[SessionConfigKey.WorktreeIncludeFiles]; - delete config[SessionConfigKey.WorktreeBranchTrack]; - delete config[SessionConfigKey.WorktreeCreateNewBranch]; - delete config[SessionConfigKey.AgentMerge]; - delete config[SessionConfigKey.AgentMergeController]; + const templateConfig = omitAutomationSessionTemplateConfigValues(config); const modelId = newSession.getSelectedModelId(); const agent = newSession.getSelectedAgent(); - const sessionTemplate = !modelId && !agent && Object.keys(config).length === 0 + const sessionTemplate = !modelId && !agent && Object.keys(templateConfig).length === 0 ? undefined : { ...(modelId ? { modelId } : {}), ...(agent ? { agent: { uri: agent.uri } } : {}), - ...(Object.keys(config).length > 0 ? { config } : {}), + ...(Object.keys(templateConfig).length > 0 ? { config: templateConfig } : {}), }; - const mode = config[SessionConfigKey.Mode]; - const permissionLevel = config[SessionConfigKey.AutoApprove]; + const mode = templateConfig[SessionConfigKey.Mode]; + const permissionLevel = templateConfig[SessionConfigKey.AutoApprove]; return { sessionTemplate, modelId, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 3e76a77267a02c..775324a5c3a304 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -524,7 +524,7 @@ suite('AgentHostAutomationStore', () => { ); }); - test('preserves Agent Host config when a generic dialog mode has no representation', async () => { + test('preserves Agent Host config on an unrelated canonical update', async () => { const connection = disposables.add(new TestAutomationConnection(true)); const storage = disposables.add(new InMemoryStorageService()); const store = disposables.add(new AgentHostAutomationStore( @@ -546,10 +546,10 @@ suite('AgentHostAutomationStore', () => { permissionLevel: 'assisted', }); + const current = store.getAutomation(automation.id); await store.updateAutomation(automation.id, { name: 'Review renamed changes', - mode: 'agent', - permissionLevel: 'assisted', + sessionTemplate: current?.sessionTemplate, }); const update = connection.dispatched.at(-1)?.action; @@ -595,7 +595,7 @@ suite('AgentHostAutomationStore', () => { ); }); - test('clears stale platform approval config for another session type', async () => { + test('clears stale platform approval config with an explicit template reset', async () => { const connection = disposables.add(new TestAutomationConnection(true)); const storage = disposables.add(new InMemoryStorageService()); const store = disposables.add(new AgentHostAutomationStore( @@ -616,7 +616,7 @@ suite('AgentHostAutomationStore', () => { }); connection.setFirstAutomationSessionConfig({ [SessionConfigKey.AutoApprove]: 'autoApprove' }); - await store.updateAutomation(automation.id, { permissionLevel: 'default' }); + await store.updateAutomation(automation.id, { sessionTemplate: null }); const update = connection.dispatched.at(-1)?.action; assert.deepStrictEqual( @@ -695,11 +695,22 @@ suite('AgentHostAutomationStore', () => { }, sessionTemplate, }); + await assert.rejects( + store.updateAutomation(automation.id, { permissionLevel: 'autoApprove' }), + /cannot be updated through legacy configuration aliases/, + ); + const updatedTemplate = { + ...sessionTemplate, + modelId: 'agent-host-copilotcli:gpt-5', + config: { + ...sessionTemplate.config, + mode: 'interactive', + autoApprove: 'autoApprove', + }, + }; await store.updateAutomation(automation.id, { name: 'Review renamed changes', - modelId: 'agent-host-copilotcli:gpt-5', - mode: 'agent', - permissionLevel: 'autoApprove', + sessionTemplate: updatedTemplate, }); const update = connection.dispatched.at(-1)?.action; @@ -707,22 +718,14 @@ suite('AgentHostAutomationStore', () => { projected: store.getAutomation(automation.id)?.sessionTemplate, updatedSession: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, }, { - projected: { - modelId: 'agent-host-copilotcli:gpt-5', - agent: { uri: 'file:///agents/reviewer.agent.md' }, - config: { - mode: 'plan', - autoApprove: 'autoApprove', - providerOption: { enabled: true }, - }, - }, + projected: updatedTemplate, updatedSession: { provider: 'copilotcli', model: { id: 'gpt-5' }, agent: { uri: 'file:///agents/reviewer.agent.md' }, workingDirectories: ['file:///workspace'], config: { - mode: 'plan', + mode: 'interactive', autoApprove: 'autoApprove', providerOption: { enabled: true }, isolation: 'folder', @@ -983,7 +986,7 @@ suite('AgentHostAutomationStore', () => { }); }); - test('qualifies host-authored models without retargeting historical run sessions', () => { + test('qualifies host-authored models and preserves definition-owned configuration on update', async () => { const connection = new TestAutomationConnection(true); disposables.add(connection); const storage = disposables.add(new InMemoryStorageService()); @@ -1000,7 +1003,18 @@ suite('AgentHostAutomationStore', () => { definition: { title: 'Host-authored', message: { text: 'Say hi.', origin: { kind: MessageKind.Automation } }, - session: { provider: 'codex', model: { id: 'auto' } }, + session: { + provider: 'codex', + model: { id: 'auto' }, + config: { + mode: 'plan', + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.WorktreeBranchPrefix]: 'host-prefix/', + [SessionConfigKey.WorktreeIncludeFiles]: ['host.json'], + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + [SessionConfigKey.AgentMerge]: true, + }, + }, enabled: true, triggers: [], }, @@ -1021,13 +1035,27 @@ suite('AgentHostAutomationStore', () => { createdAt: timestamp, modifiedAt: timestamp, }); + const projected = store.getAutomation('host-authored'); + await store.updateAutomation('host-authored', { enabled: false }); + const update = connection.dispatched.at(-1)?.action; assert.deepStrictEqual({ - modelId: store.getAutomation('host-authored')?.sessionTemplate?.modelId, + sessionTemplate: projected?.sessionTemplate, sessionResource: store.runs.get()[0].sessionResource?.toString(), + updatedConfig: update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, }, { - modelId: 'agent-host-codex:auto', + sessionTemplate: { + modelId: 'agent-host-codex:auto', + config: { mode: 'plan' }, + }, sessionResource: 'agent-host-copilotcli:/host-authored-session', + updatedConfig: { + mode: 'plan', + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.WorktreeBranchPrefix]: 'host-prefix/', + [SessionConfigKey.WorktreeIncludeFiles]: ['host.json'], + [SessionConfigKey.AgentMerge]: true, + }, }); }); 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 5fb588ec1552f0..a8a9fe9c2739e1 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 @@ -4514,6 +4514,11 @@ suite('LocalAgentHostSessionsProvider', () => { autoApprove: 'assisted', providerOption: { enabled: true }, clearedOption: true, + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.WorktreeBranchPrefix]: 'template-prefix/', + [SessionConfigKey.WorktreeIncludeFiles]: ['template.json'], + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + [SessionConfigKey.AgentMerge]: true, }, }; agentHost.resolveSessionConfigResult = { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 6bdae1b56506d5..8de97db6124097 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1434,6 +1434,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; readonly order = 0; + readonly supportsAutomationSessionConfiguration = true; get sessionTypes(): readonly ISessionType[] { const types: ISessionType[] = []; diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 4628cd92778dae..8c5ccba2d0275a 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -24,7 +24,7 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { getSessionReferenceResource } from './sessionReference.js'; import { ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IDeferredNewSessionRequestOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, NewSessionRequestOptions, WorkspaceNotTrustedError } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; -import { IDeleteChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; +import { IDeleteChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISideChatSelection, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -495,11 +495,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousNewSession = this._newSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId, { - metadata: options?.metadata, - sessionTemplate: options?.sessionTemplate, - ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), - }); + const session = provider.createNewSession(folderUri, sessionTypeId, this._providerCreateSessionOptions(provider, options)); // Providers no longer dispose the previous new session implicitly, so // dispose the one this composer just replaced. Use its own provider @@ -518,11 +514,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createNewSession(folderUri, sessionTypeId, { - metadata: options?.metadata, - sessionTemplate: options?.sessionTemplate, - ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), - }); + const session = provider.createNewSession(folderUri, sessionTypeId, this._providerCreateSessionOptions(provider, options)); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -590,11 +582,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousNewSession = this._newSession.get(); - const session = provider.createQuickChat(sessionTypeId, { - metadata: options?.metadata, - sessionTemplate: options?.sessionTemplate, - ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), - }); + const session = provider.createQuickChat(sessionTypeId, this._providerCreateSessionOptions(provider, options)); this._newSession.set(session, undefined); this.storageService.store(LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY, sessionTypeId, StorageScope.PROFILE, StorageTarget.USER); @@ -610,11 +598,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createAutomationQuickChat(options?: ICreateNewSessionOptions): ISession { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); const previousAutomationSession = this._automationSession.get(); - const session = provider.createQuickChat(sessionTypeId, { - metadata: options?.metadata, - sessionTemplate: options?.sessionTemplate, - ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), - }); + const session = provider.createQuickChat(sessionTypeId, this._providerCreateSessionOptions(provider, options)); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -622,13 +606,29 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } + private _providerCreateSessionOptions(provider: ISessionsProvider, options: ICreateNewSessionOptions | undefined): ISessionsProviderCreateSessionOptions { + const sessionTemplate = options?.sessionTemplate ?? options?.automationConfiguration?.sessionTemplate; + if (sessionTemplate && provider.supportsAutomationSessionConfiguration !== true) { + throw new Error(`Sessions provider '${provider.id}' does not support Automation session templates.`); + } + return { + metadata: options?.metadata, + sessionTemplate: options?.sessionTemplate, + ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), + }; + } + async getAutomationSessionConfiguration(session: ISession) { const provider = this._getProvider(session); - return provider?.getAutomationSessionConfiguration + return provider?.supportsAutomationSessionConfiguration === true && provider.getAutomationSessionConfiguration ? provider.getAutomationSessionConfiguration(session.sessionId) : null; } + supportsAutomationSessionConfiguration(session: ISession): boolean { + return this._getProvider(session)?.supportsAutomationSessionConfiguration === true; + } + usesCombinedNewSessionConfigPicker(session: ISession): boolean { return this._getProvider(session)?.usesCombinedNewSessionConfigPicker === true; } @@ -877,11 +877,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa throw new WorkspaceNotTrustedError(); } } - const session = provider.createNewSession(folderUri, sessionTypeId, { - metadata: createOptions?.metadata, - sessionTemplate: createOptions?.sessionTemplate, - ...(createOptions?.automationConfiguration ? { automationConfiguration: createOptions.automationConfiguration } : {}), - }); + const session = provider.createNewSession(folderUri, sessionTypeId, this._providerCreateSessionOptions(provider, createOptions)); this._unlistedNewSessions.set(session.resource, session); const requestActivity = new MutableDisposable(); try { @@ -905,11 +901,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async createAndSendQuickChatRequest(options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise { const { provider, sessionTypeId } = this._resolveProviderForQuickChat(createOptions); - const session = provider.createQuickChat(sessionTypeId, { - metadata: createOptions?.metadata, - sessionTemplate: createOptions?.sessionTemplate, - ...(createOptions?.automationConfiguration ? { automationConfiguration: createOptions.automationConfiguration } : {}), - }); + const session = provider.createQuickChat(sessionTypeId, this._providerCreateSessionOptions(provider, createOptions)); return this._configureAndSendNewSession(provider, session, options, createOptions, false, token); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index bef533dfa28c29..516487685bde2d 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -403,6 +403,9 @@ export interface ISessionsManagementService { */ getAutomationSessionConfiguration(session: ISession): Promise; + /** Whether the session's provider can restore and capture Automation configuration. */ + supportsAutomationSessionConfiguration(session: ISession): boolean; + /** Whether the session's provider combines Mode and Model controls on phone layouts. */ usesCombinedNewSessionConfigPicker(session: ISession): boolean; diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index b69bc62fe19729..9d81ace54ec11d 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -255,6 +255,8 @@ export interface ISessionsProvider { /** Whether phone layouts replace separate Mode and Model controls with one picker. */ readonly usesCombinedNewSessionConfigPicker?: boolean; + /** Whether Automation configuration can be restored at draft creation and captured through `getAutomationSessionConfiguration`. */ + readonly supportsAutomationSessionConfiguration?: boolean; /** * Optional. Fires when a capability flag that consumers gate UI on (e.g. @@ -322,7 +324,7 @@ export interface ISessionsProvider { */ deleteNewSession(sessionId: string): void; - /** Capture the provider-owned values currently selected on an Automation draft. */ + /** Capture Automation draft values; implementing this also declares support for restoring `automationConfiguration` at draft creation. */ getAutomationSessionConfiguration?(sessionId: string): Promise; /** diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 1b5ce9e2cf287e..4c27736b266fd7 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -217,6 +217,7 @@ class MockSessionStore implements ISessionsManagementService { createAutomationSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createAutomationQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } getAutomationSessionConfiguration(): Promise { return Promise.resolve(undefined); } + supportsAutomationSessionConfiguration(): boolean { return false; } usesCombinedNewSessionConfigPicker(): boolean { return false; } createQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 5e428969eb96e3..b5db207c108c4a 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -1850,11 +1850,13 @@ suite('SessionsManagementService', () => { }); let providerOptions: ISessionsProviderCreateSessionOptions | undefined; const provider = new class extends TestSessionsProvider { + override readonly supportsAutomationSessionConfiguration = true; override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } override createNewSession(_folderUri?: URI, _sessionTypeId?: string, options?: ISessionsProviderCreateSessionOptions): ISession { providerOptions = options; return session; } + override async getAutomationSessionConfiguration() { return automationConfiguration; } }(session); const { service } = createSessionsManagementService(session, disposables, provider); const sessionTemplate = { @@ -1880,6 +1882,35 @@ suite('SessionsManagementService', () => { }); }); + test('createAndSendNewChatRequest rejects canonical Automation templates for providers without restoration support', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + }); + let createCount = 0; + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override createNewSession(): ISession { + createCount++; + return session; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const sessionTemplate = { modelId: 'model', config: { mode: 'plan' } }; + + await Promise.all([ + assert.rejects( + service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { sessionTemplate }), + /does not support Automation session templates/, + ), + assert.rejects( + service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { automationConfiguration: { sessionTemplate } }), + /does not support Automation session templates/, + ), + ]); + assert.strictEqual(createCount, 0); + }); + test('createAndSendNewChatRequest prepares request options while configuring the provisional session', async () => { const session = stubSession({ sessionId: 's1', @@ -2980,6 +3011,7 @@ suite('SessionsManagementService', () => { let createIndex = 0; const provider = new class extends TestSessionsProvider { override readonly supportsQuickChats = true; + override readonly supportsAutomationSessionConfiguration = true; override resolveWorkspace(folderUri: URI): ISessionWorkspace { return { uri: folderUri, diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts index 1bcf54e09e6435..c82a41098c5ba0 100644 --- a/src/vs/workbench/contrib/chat/common/automations/automationService.ts +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -31,6 +31,22 @@ export function isAutomationActiveRunError(error: unknown): boolean { || (error instanceof AggregateError && error.errors.length > 0 && error.errors.every(isAutomationActiveRunError)); } +/** Signals that deprecated configuration aliases cannot modify an explicit provider template. */ +export class AutomationSessionTemplateAuthorityError extends Error { + constructor() { + super('A canonical Automation session template cannot be updated through legacy configuration aliases.'); + } +} + +export function assertAutomationSessionTemplateAuthority(current: IAutomationDescriptor, patch: IUpdateAutomationOptions): void { + const targetAuthorityChanged = patch.target !== undefined + && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); + const legacyConfigurationPatched = patch.modelId !== undefined || patch.mode !== undefined || patch.permissionLevel !== undefined; + if (current.sessionTemplate && patch.sessionTemplate === undefined && !targetAuthorityChanged && legacyConfigurationPatched) { + throw new AutomationSessionTemplateAuthorityError(); + } +} + /** * Input for `createAutomation`. The service fills in `id`, timestamps, and * `nextRunAt`. From ef2760266afaaf8e5bfa39dcdf56e6ace096d14f Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 18:20:43 +0200 Subject: [PATCH 11/15] automations: fix: normalize provider handoff and saving focus Send providers one canonical Automation configuration object instead of overlapping template channels. Keep keyboard focus on the cancellable action while form content is inert, and make saving and error live regions visible before their announcements change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../contrib/automations/browser/automationDialog.ts | 8 +++++--- .../automations/browser/automationDialogService.ts | 1 + .../automations/test/browser/automationDialog.test.ts | 6 +++++- .../browser/baseAgentHostSessionsProvider.ts | 4 ++-- .../browser/localAgentHostSessionsProvider.test.ts | 4 ++-- .../browser/copilotChatSessionsProvider.ts | 3 +-- .../sessions/browser/sessionsManagementService.ts | 6 ++++-- .../services/sessions/common/sessionsProvider.ts | 2 -- .../test/browser/sessionsManagementService.test.ts | 11 +++++------ 9 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index a8aff4762a1ef4..9e6810f25eba29 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -126,7 +126,7 @@ export function registerAutomationDialogKeyboardNavigation( return false; } for (let current: HTMLElement | null = element; current; current = current.parentElement) { - if (current.hidden || current.getAttribute('aria-hidden') === 'true') { + if (current.hidden || current.hasAttribute('inert') || current.getAttribute('aria-hidden') === 'true') { return false; } const style = targetWindow.getComputedStyle(current); @@ -1446,19 +1446,21 @@ export function renderForm( formContent.toggleAttribute('inert', saving); formContent.setAttribute('aria-busy', String(saving)); form.classList.toggle('saving', saving); - saveStatus.textContent = saving ? localize('automation.form.saving', "Saving automation…") : ''; if (saving) { DOM.show(saveStatus); + saveStatus.textContent = localize('automation.form.saving', "Saving automation…"); } else { DOM.hide(saveStatus); + saveStatus.textContent = ''; } }, showSessionConfigurationError: message => { - sessionConfigurationError.textContent = message ?? ''; if (message) { DOM.show(sessionConfigurationError); + sessionConfigurationError.textContent = message; } else { DOM.hide(sessionConfigurationError); + sessionConfigurationError.textContent = ''; } }, focusSessionConfigurationError: () => sessionConfigurationError.focus(), diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index dac9d5f1bd82a0..8a73f91de06799 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -216,6 +216,7 @@ export class AutomationDialogService implements IAutomationDialogService { saveButton.enabled = false; saveButton.label = savingButtonLabel; } + cancelButton?.focus(); const cancellation = new CancellationTokenSource(); saveCancellation.value = cancellation; let shouldClose = false; diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index a40db4b3cf0c6c..62742f7e42da67 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -510,6 +510,7 @@ suite('Automation session draft synchronization', () => { let errorCount = 0; const service = upcastPartial({ automationSession, + supportsAutomationSessionConfiguration: () => true, createAutomationSession: (_folderUri, options) => { if (createCount++ === 0) { throw new Error('provider unavailable'); @@ -1206,10 +1207,13 @@ suite('Automation dialog keyboard navigation', () => { const wrapper = container.appendChild(document.createElement('div')); wrapper.tabIndex = 0; const second = wrapper.appendChild(document.createElement('button')); + const inertContainer = container.appendChild(document.createElement('div')); + inertContainer.setAttribute('inert', ''); + const inert = inertContainer.appendChild(document.createElement('button')); const third = container.appendChild(document.createElement('button')); const navigation = disposables.add(registerAutomationDialogKeyboardNavigation( targetWindow, - () => [first, hidden, wrapper, second, third], + () => [first, hidden, wrapper, second, inert, third], () => false, )); let downstreamKeyDowns = 0; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index dcdb4a3b511887..4baf406bf6de96 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -3538,7 +3538,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement workspace, false, options?.metadata, - options?.automationConfiguration ?? (options?.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : undefined), + options?.automationConfiguration, ); } @@ -3567,7 +3567,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement undefined, true, options?.metadata, - options?.automationConfiguration ?? (options?.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : undefined), + options?.automationConfiguration, ); } 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 a8a9fe9c2739e1..23a965e0891bf3 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 @@ -4539,7 +4539,7 @@ suite('LocalAgentHostSessionsProvider', () => { const session = provider.createNewSession( URI.parse('file:///home/user/project'), provider.sessionTypes[0].id, - { sessionTemplate }, + { automationConfiguration: { sessionTemplate } }, ); await provider.getAutomationSessionConfiguration(session.sessionId); await provider.setSessionConfigValue(session.sessionId, 'clearedOption', false); @@ -4631,7 +4631,7 @@ suite('LocalAgentHostSessionsProvider', () => { const session = provider.createNewSession( URI.parse('file:///home/user/project'), provider.sessionTypes[0].id, - { sessionTemplate }, + { automationConfiguration: { sessionTemplate } }, ); const capturedWithoutEdit = await provider.getAutomationSessionConfiguration(session.sessionId); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 8de97db6124097..16e7913d32d8b0 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1662,8 +1662,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (!workspace) { throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } - const automationConfiguration = options?.automationConfiguration - ?? (options?.sessionTemplate ? { sessionTemplate: options.sessionTemplate } : undefined); + const automationConfiguration = options?.automationConfiguration; if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { if (sessionTypeId !== CopilotCloudSessionType.id) { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 8c5ccba2d0275a..564d88d45b5bb9 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -611,10 +611,12 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (sessionTemplate && provider.supportsAutomationSessionConfiguration !== true) { throw new Error(`Sessions provider '${provider.id}' does not support Automation session templates.`); } + const automationConfiguration = sessionTemplate + ? { sessionTemplate } + : options?.automationConfiguration; return { metadata: options?.metadata, - sessionTemplate: options?.sessionTemplate, - ...(options?.automationConfiguration ? { automationConfiguration: options.automationConfiguration } : {}), + ...(automationConfiguration ? { automationConfiguration } : {}), }; } diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 9d81ace54ec11d..d9dfe3c851fa9c 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -51,8 +51,6 @@ export interface ISendRequestOptions { export interface ISessionsProviderCreateSessionOptions { /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; - /** Provider-owned values restored into the draft before its first configuration resolution. */ - readonly sessionTemplate?: IAutomationSessionTemplate; /** Complete Automation state for providers that also own compatibility projections. */ readonly automationConfiguration?: IAutomationSessionConfiguration; } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index b5db207c108c4a..de90014ff2fd69 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -1877,8 +1877,7 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual(providerOptions, { metadata: undefined, - sessionTemplate, - automationConfiguration, + automationConfiguration: { sessionTemplate }, }); }); @@ -3055,10 +3054,10 @@ suite('SessionsManagementService', () => { automationSession: undefined, capturedConfiguration: { sessionTemplate }, createOptions: [ - { metadata: undefined, sessionTemplate }, - { metadata: undefined, sessionTemplate: undefined }, - { metadata: undefined, sessionTemplate }, - { metadata: undefined, sessionTemplate: undefined }, + { metadata: undefined, automationConfiguration: { sessionTemplate } }, + { metadata: undefined }, + { metadata: undefined, automationConfiguration: { sessionTemplate } }, + { metadata: undefined }, ], deleted: ['automation-workspace', 'automation-quick-chat', 'automation-replacement'], }); From a203b2e19ed0ba40b9b8cda1228122f602851770 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 23:06:51 +0200 Subject: [PATCH 12/15] automations: test: assert settled restart configuration Refresh the created run session inside the completion retry so the restart E2E validates Mode and Approvals on the settled session rather than an earlier catalog snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../agentHost/test/node/e2e/suites/automationsSuite.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts index d4ecd6f6a1573c..6f3ba93db86c6c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/automationsSuite.ts @@ -438,10 +438,10 @@ export function defineAutomationsTests(context: IAgentHostE2ETestContext): void } assert.ok(primarySession); context.createdSessions.push(primarySession); - const createdSession = await fetchSessionWithChat(context.client, primarySession); + let createdSession = await fetchSessionWithChat(context.client, primarySession); await retry(async () => { - const current = await fetchSessionWithChat(context.client, primarySession); - assert.strictEqual(current.turns.at(-1)?.state, 'complete'); + createdSession = await fetchSessionWithChat(context.client, primarySession); + assert.strictEqual(createdSession.turns.at(-1)?.state, 'complete'); }, 100, 300); assert.deepStrictEqual({ From e7f57cf0c3911fdc85563c53a2b22fda61498f80 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 3 Sep 2026 23:16:24 +0200 Subject: [PATCH 13/15] automations: fix: skip disabled actions in dialog focus Exclude aria-disabled controls from the Automation dialog's custom focus ring so Saving keeps keyboard focus on the cancellable action instead of moving onto the disabled primary button. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- .../sessions/contrib/automations/browser/automationDialog.ts | 2 +- .../contrib/automations/test/browser/automationDialog.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index 9e6810f25eba29..ffeea9cb0a2c56 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -122,7 +122,7 @@ export function registerAutomationDialogKeyboardNavigation( let suppressPopupEscapeKeyUp = false; const visibleFocusableElements = (): readonly HTMLElement[] => getFocusableElements().filter(element => { - if (!element.isConnected || element.tabIndex < 0 || element.hasAttribute('disabled')) { + if (!element.isConnected || element.tabIndex < 0 || element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') { return false; } for (let current: HTMLElement | null = element; current; current = current.parentElement) { diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index 62742f7e42da67..b10c9c0b17ae4b 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -1210,10 +1210,12 @@ suite('Automation dialog keyboard navigation', () => { const inertContainer = container.appendChild(document.createElement('div')); inertContainer.setAttribute('inert', ''); const inert = inertContainer.appendChild(document.createElement('button')); + const ariaDisabled = container.appendChild(document.createElement('button')); + ariaDisabled.setAttribute('aria-disabled', 'true'); const third = container.appendChild(document.createElement('button')); const navigation = disposables.add(registerAutomationDialogKeyboardNavigation( targetWindow, - () => [first, hidden, wrapper, second, inert, third], + () => [first, hidden, wrapper, second, inert, ariaDisabled, third], () => false, )); let downstreamKeyDowns = 0; From 96e06254fb9285c3abe5c9a9e0c86b168e0af875 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 4 Sep 2026 13:34:41 +0200 Subject: [PATCH 14/15] automations: refactor: keep async dialog lifecycle local Avoid extending the widely shared Dialog widget for the Automation editor's provider capture flow. Keep Save failure handling, cancellation, and completion in the Automation dialog while reusing the standard button styles and platform order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- src/vs/base/browser/ui/dialog/dialog.ts | 65 +++--- .../test/browser/ui/dialog/dialog.test.ts | 83 +------- .../browser/automationDialogService.ts | 201 ++++++++++-------- .../browser/media/automationDialog.css | 12 +- 4 files changed, 153 insertions(+), 208 deletions(-) diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 486d0e086be26a..34d22b5d162357 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -13,7 +13,6 @@ import { ICheckboxStyles, Checkbox } from '../toggle/toggle.js'; import { IInputBoxStyles, InputBox } from '../inputbox/inputBox.js'; import { Action, toAction } from '../../../common/actions.js'; import { Codicon } from '../../../common/codicons.js'; -import { onUnexpectedError } from '../../../common/errors.js'; import { ThemeIcon } from '../../../common/themables.js'; import { KeyCode, KeyMod } from '../../../common/keyCodes.js'; import { mnemonicButtonLabel } from '../../../common/labels.js'; @@ -70,8 +69,6 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; - /** Invoked before a button closes the dialog; return `false` to keep it open. */ - readonly buttonHandler?: (button: number) => boolean | Promise; /** * Temporary escape hatch for dialogs that embed widgets whose popups mount * at window root (outside the dialog DOM). Needed because the focus trap @@ -291,37 +288,25 @@ export class Dialog extends Disposable { return new Promise(resolve => { clearNode(this.buttonsContainer); + const close = () => { + resolve({ + button: this.options.cancelId || 0, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined + }); + return; + }; + this._register(toDisposable(close)); + const buttonBar = this.buttonBar = this._register(new ButtonBar(this.buttonsContainer, { alignment: this.options?.alignment === DialogContentsAlignment.Vertical ? ButtonBarAlignment.Vertical : ButtonBarAlignment.Horizontal })); const buttonMap = this.rearrangeButtons(this.buttons, this.options.cancelId); - let settled = false; - const complete = (button: number, includeValues: boolean) => { - if (settled) { - return; - } - settled = true; + + const onButtonClick = (index: number) => { resolve({ - button, + button: buttonMap[index].index, checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, - ...(includeValues ? { values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined } : {}), + values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined }); }; - const tryComplete = async (button: number, includeValues: boolean) => { - if (settled) { - return; - } - try { - if (this.options.buttonHandler && !await this.options.buttonHandler(button)) { - return; - } - complete(button, includeValues); - } catch (error) { - onUnexpectedError(error); - } - }; - const close = () => void tryComplete(this.options.cancelId ?? 0, false); - this._register(toDisposable(() => complete(this.options.cancelId ?? 0, false))); - - const onButtonClick = (index: number) => tryComplete(buttonMap[index].index, true); // Buttons buttonMap.forEach((_, index) => { @@ -340,7 +325,7 @@ export class Dialog extends Disposable { run: async () => { await action.run(); - await onButtonClick(index); + onButtonClick(index); } })) })); @@ -365,7 +350,7 @@ export class Dialog extends Disposable { EventHelper.stop(e); } - void onButtonClick(index); + onButtonClick(index); })); }); @@ -388,7 +373,12 @@ export class Dialog extends Disposable { // Enter in input field should OK the dialog if (this.inputs.some(input => input.hasFocus())) { EventHelper.stop(e); - void tryComplete(buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0, true); + + resolve({ + button: buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, + values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined + }); } return; // leave default handling @@ -400,7 +390,11 @@ export class Dialog extends Disposable { const noButton = buttonMap.find(button => button.index === 1 && button.index !== this.options.cancelId); if (noButton) { - void tryComplete(noButton.index, true); + resolve({ + button: noButton.index, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, + values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined + }); } return; // leave default handling @@ -566,7 +560,12 @@ export class Dialog extends Disposable { if (!this.options.disableCloseAction && !this.options.disableCloseButton) { const actionBar = this._register(new ActionBar(this.toolbarContainer, {})); - const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => close())); + const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => { + resolve({ + button: this.options.cancelId || 0, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined + }); + })); actionBar.push(action, { icon: true, label: false }); } diff --git a/src/vs/base/test/browser/ui/dialog/dialog.test.ts b/src/vs/base/test/browser/ui/dialog/dialog.test.ts index 6dcd4dff39c5b9..5bd29321018bda 100644 --- a/src/vs/base/test/browser/ui/dialog/dialog.test.ts +++ b/src/vs/base/test/browser/ui/dialog/dialog.test.ts @@ -5,11 +5,10 @@ import assert from 'assert'; import { $, append, getWindow } from '../../../../browser/dom.js'; -import { Button, IButton, unthemedButtonStyles } from '../../../../browser/ui/button/button.js'; +import { Button, unthemedButtonStyles } from '../../../../browser/ui/button/button.js'; import { Dialog, IDialogStyles } from '../../../../browser/ui/dialog/dialog.js'; import { unthemedInboxStyles } from '../../../../browser/ui/inputbox/inputBox.js'; import { ICheckboxStyles } from '../../../../browser/ui/toggle/toggle.js'; -import { DeferredPromise } from '../../../../common/async.js'; import { toDisposable } from '../../../../common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; @@ -113,86 +112,6 @@ suite('Dialog', () => { await result; }); - test('keeps the dialog open when an asynchronous button handler rejects closure', async () => { - const container = append(document.body, $('.test-dialog-container')); - disposables.add(toDisposable(() => container.remove())); - let primaryButton!: IButton; - let attempts = 0; - const dialog = disposables.add(new Dialog(container, 'Message', ['Save', 'Cancel'], { - cancelId: 1, - buttonHandler: async button => { - await Promise.resolve(); - return button !== 0 || ++attempts > 1; - }, - buttonOptions: [{ - styleButton: button => primaryButton = button, - }], - buttonStyles: unthemedButtonStyles, - checkboxStyles: unthemedCheckboxStyles, - inputBoxStyles: unthemedInboxStyles, - dialogStyles: unthemedDialogStyles, - })); - let completed = false; - const result = dialog.show().then(value => { - completed = true; - return value; - }); - - primaryButton.element.click(); - await Promise.resolve(); - await Promise.resolve(); - const afterRejectedClosure = completed; - primaryButton.element.click(); - - assert.deepStrictEqual({ - afterRejectedClosure, - result: await result, - attempts, - }, { - afterRejectedClosure: false, - result: { button: 0, checkboxChecked: undefined, values: undefined }, - attempts: 2, - }); - }); - - test('routes the close action through a pending asynchronous button handler', async () => { - const container = append(document.body, $('.test-dialog-container')); - disposables.add(toDisposable(() => container.remove())); - let primaryButton!: IButton; - const saveStarted = new DeferredPromise(); - const releaseSave = new DeferredPromise(); - const dialog = disposables.add(new Dialog(container, 'Message', ['Save', 'Cancel'], { - cancelId: 1, - buttonHandler: async button => { - if (button === 0) { - saveStarted.complete(); - await releaseSave.p; - return false; - } - return true; - }, - buttonOptions: [{ - styleButton: button => primaryButton = button, - }], - buttonStyles: unthemedButtonStyles, - checkboxStyles: unthemedCheckboxStyles, - inputBoxStyles: unthemedInboxStyles, - dialogStyles: unthemedDialogStyles, - })); - const result = dialog.show(); - - primaryButton.element.click(); - await saveStarted.p; - const closeButton = container.querySelector('.dialog-toolbar .action-label'); - assert.ok(closeButton); - closeButton.click(); - const cancelled = await result; - releaseSave.complete(); - await Promise.resolve(); - - assert.deepStrictEqual(cancelled, { button: 1, checkboxChecked: undefined }); - }); - test('prefers a pre-rendered detailElement over plain detail text and makes its links keyboard-focusable', async () => { const container = append(document.body, $('.test-dialog-container')); disposables.add(toDisposable(() => container.remove())); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index 8a73f91de06799..bcd4947c6a8780 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -5,11 +5,13 @@ import './media/automationDialog.css'; import * as DOM from '../../../../base/browser/dom.js'; -import { IButton } from '../../../../base/browser/ui/button/button.js'; +import { ButtonBar, IButton } from '../../../../base/browser/ui/button/button.js'; import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { isWindows } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -18,7 +20,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; -import { defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles, defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { createWorkbenchDialogOptions } from '../../../../workbench/browser/parts/dialogs/dialog.js'; import { AutomationTarget, IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; @@ -124,18 +126,16 @@ export class AutomationDialogService implements IAutomationDialogService { let focusSessionConfigurationError: () => void = () => { }; let getFocusableElements: () => readonly HTMLElement[] = () => []; let focusFirst: () => void = () => { }; - let preparedResult: IAutomationDialogResult | undefined; let saveInProgress = false; const saveCancellation = disposables.add(new MutableDisposable()); + const completion = new DeferredPromise(); const title = isEdit ? localize('automation.dialog.editTitle', "Edit automation") : localize('automation.dialog.createTitle', "New automation"); - const buttonLabels = [ - isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"), - localize('automation.dialog.cancel', "Cancel"), - ]; + const saveButtonLabel = isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"); + const cancelButtonLabel = localize('automation.dialog.cancel', "Cancel"); const savingButtonLabel = localize('automation.dialog.saving', "Saving…"); const captureErrorMessage = localize('automation.dialog.captureError', "The automation wasn't saved because its session configuration couldn't be captured. Check the provider connection and try again."); @@ -183,94 +183,110 @@ export class AutomationDialogService implements IAutomationDialogService { return { kind: 'create', value: create }; }; + const closeDialog = (result: IAutomationDialogResult | undefined) => { + if (completion.isSettled) { + return; + } + saveCancellation.value?.cancel(); + void completion.complete(result); + dialog.dispose(); + }; + + const save = async () => { + if (saveInProgress) { + return; + } + revalidate(); + if (validation.nameError || validation.promptError || validation.folderError || validation.sessionTypeError || validation.branchError) { + return; + } + if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) { + return; + } + + saveInProgress = true; + showSessionConfigurationError(undefined); + setSaving(true); + if (saveButton) { + saveButton.enabled = false; + saveButton.label = savingButtonLabel; + } + cancelButton?.focus(); + const cancellation = new CancellationTokenSource(); + saveCancellation.value = cancellation; + let shouldClose = false; + let shouldFocusError = false; + try { + await waitForAutomationSessionSync(cancellation.token); + const sessionConfigurationCapture = await getSessionConfiguration(cancellation.token); + if (sessionConfigurationCapture.kind === 'failed') { + showSessionConfigurationError(captureErrorMessage); + shouldFocusError = true; + return; + } + const result = buildResult(sessionConfigurationCapture); + if (result) { + shouldClose = true; + closeDialog(result); + } + } catch (error) { + if (!isCancellationError(error) && !cancellation.token.isCancellationRequested) { + this.logService.error('[AutomationDialog] Failed to save the automation session configuration.', error); + showSessionConfigurationError(captureErrorMessage); + shouldFocusError = true; + } + } finally { + if (saveCancellation.value === cancellation) { + saveCancellation.clear(); + } + saveInProgress = false; + if (!shouldClose && !completion.isSettled) { + setSaving(false); + if (saveButton) { + saveButton.label = saveButtonLabel; + } + revalidate(); + if (shouldFocusError) { + focusSessionConfigurationError(); + } + } + } + }; + const activeContainer = this.layoutService.activeContainer; const dialog = disposables.add(new Dialog( activeContainer, title, - buttonLabels, + [], createWorkbenchDialogOptions({ type: 'none', extraClasses: ['automation-dialog'], - cancelId: 1, + disableDefaultAction: true, isExternalFocusAllowed: isAutomationDialogPopupTarget, - buttonHandler: async button => { - if (button !== 0) { - saveCancellation.value?.cancel(); - return true; - } - if (saveInProgress) { - return false; - } - revalidate(); - if (validation.nameError || validation.promptError || validation.folderError || validation.sessionTypeError || validation.branchError) { - return false; - } - if ((!state.isQuickChat && !state.folderUri) || !state.sessionTypeId || (state.isQuickChat && !state.providerId)) { - return false; - } - - saveInProgress = true; - showSessionConfigurationError(undefined); - setSaving(true); - if (saveButton) { - saveButton.enabled = false; - saveButton.label = savingButtonLabel; - } - cancelButton?.focus(); - const cancellation = new CancellationTokenSource(); - saveCancellation.value = cancellation; - let shouldClose = false; - let shouldFocusError = false; - try { - await waitForAutomationSessionSync(cancellation.token); - const sessionConfigurationCapture = await getSessionConfiguration(cancellation.token); - if (sessionConfigurationCapture.kind === 'failed') { - showSessionConfigurationError(captureErrorMessage); - shouldFocusError = true; - return false; - } - preparedResult = buildResult(sessionConfigurationCapture); - shouldClose = !!preparedResult; - return shouldClose; - } catch (error) { - if (!isCancellationError(error) && !cancellation.token.isCancellationRequested) { - this.logService.error('[AutomationDialog] Failed to save the automation session configuration.', error); - showSessionConfigurationError(captureErrorMessage); - shouldFocusError = true; - } - return false; - } finally { - if (saveCancellation.value === cancellation) { - saveCancellation.clear(); - } - saveInProgress = false; - if (!shouldClose) { - setSaving(false); - if (saveButton) { - saveButton.label = buttonLabels[0]; - } - revalidate(); - if (shouldFocusError) { - focusSessionConfigurationError(); - } - } - } - }, // textLinkForeground stamps inline styles onto chat input picker chips. dialogStyles: { ...defaultDialogStyles, textLinkForeground: undefined }, - buttonOptions: [ - { - styleButton: button => { - saveButton = button; - revalidate(); - }, - }, - { - styleButton: button => { - cancelButton = button; - }, - }, - ], + renderFooter: container => { + container.classList.add('dialog-buttons', 'automation-dialog-footer-actions'); + container.parentElement?.classList.add('dialog-buttons-row', 'automation-dialog-footer-row'); + const buttonBar = disposables.add(new ButtonBar(container)); + const createSaveButton = () => { + saveButton = buttonBar.addButton(defaultButtonStyles); + saveButton.label = saveButtonLabel; + disposables.add(saveButton.onDidClick(() => void save())); + }; + const createCancelButton = () => { + cancelButton = buttonBar.addButton({ ...defaultButtonStyles, secondary: true }); + cancelButton.label = cancelButtonLabel; + disposables.add(cancelButton.onDidClick(() => closeDialog(undefined))); + }; + if (isWindows) { + createSaveButton(); + createCancelButton(); + } else { + createCancelButton(); + createSaveButton(); + } + }, renderBody: container => { container.classList.add('automation-dialog-body'); @@ -305,7 +321,12 @@ export class AutomationDialogService implements IAutomationDialogService { handle.acceptPromptSuggestion, )); focusFirst = keyboardNavigation.focusFirst; - revalidate = () => updateSaveButtonState(saveButton, state, validation, form, getPrompt, getBranch); + revalidate = () => { + updateSaveButtonState(saveButton, state, validation, form, getPrompt, getBranch); + if (saveInProgress && saveButton) { + saveButton.enabled = false; + } + }; revalidate(); }, }, this.keybindingService, this.layoutService, this.hostService, automationDialogAllowableCommands, @@ -316,13 +337,9 @@ export class AutomationDialogService implements IAutomationDialogService { disposables.add(toDisposable(() => activeContainer.classList.remove('automation-dialog-open'))); try { - const resultPromise = dialog.show(); + void dialog.show().then(() => closeDialog(undefined)); focusFirst(); - const result = await resultPromise; - if (result.button !== 0) { - return undefined; - } - return preparedResult; + return await completion.p; } finally { disposables.dispose(); } diff --git a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index 9ae2f680335fdc..c5d0e78d6e1bc5 100644 --- a/src/vs/sessions/contrib/automations/browser/media/automationDialog.css +++ b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css @@ -88,10 +88,20 @@ min-height: 0; } -.monaco-dialog-box.automation-dialog > .dialog-buttons-row { +.monaco-dialog-box.automation-dialog > .dialog-buttons-row:not(.automation-dialog-footer-row) { + display: none; +} + +.monaco-dialog-box.automation-dialog > .automation-dialog-footer-row { + flex-grow: 0; + margin-top: 0; padding: 8px 10px 10px; } +.monaco-dialog-box.automation-dialog:not(.align-vertical) > .automation-dialog-footer-row > .automation-dialog-footer-actions { + margin-left: 0; +} + /* * Strip the base dialog padding on the message row and container so * our titlebar can full-bleed. The form pane below re-adds horizontal From e20b2e8b093a1abe2b282225d49140e1f268f5f4 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 4 Sep 2026 18:11:56 +0200 Subject: [PATCH 15/15] automations: fix: address canonical template review feedback Filter reserved session state at both Agent Host projection and fallback restoration, preserve configuration for unavailable targets, honor explicit template reset semantics, and keep the Automation prompt menu owned by Sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 09e55c0c-bf80-42ea-b8e2-fad38a12d986 --- src/vs/platform/actions/common/actions.ts | 1 - src/vs/sessions/browser/menus.ts | 2 + .../automations/browser/automationDialog.ts | 19 +++++- .../test/browser/automationDialog.test.ts | 54 +++++++++++++++- .../browser/agentHostAutomationStore.ts | 25 +++++--- .../browser/baseAgentHostSessionsProvider.ts | 13 +++- .../browser/agentHostAutomationStore.test.ts | 62 +++++++++++++++++-- .../localAgentHostSessionsProvider.test.ts | 7 ++- 8 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index ae974f4b58aa7e..68080fa1ac4ef3 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -273,7 +273,6 @@ export class MenuId { static readonly ChatInputStatus = new MenuId('ChatInputStatus'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput'); - static readonly AutomationsDialogInputToolbar = new MenuId('AutomationsDialogInputToolbar'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 49de5a94e17d68..7736a997f6708f 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -47,6 +47,8 @@ export const Menus = { AutomationsHistoryItem: new MenuId('SessionsAutomationsHistoryItem'), /** Context menu for session-backed Automation history rows. */ AutomationsHistoryItemContext: new MenuId('SessionsAutomationsHistoryItemContext'), + /** Input toolbar actions in the Automation dialog prompt editor. */ + AutomationsDialogInputToolbar: new MenuId('AutomationsDialogInputToolbar'), NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index ffeea9cb0a2c56..1cfb4711dd680d 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -66,6 +66,7 @@ import { ISessionContext, SessionContext } from '../../../services/sessions/brow import { VisibleSession } from '../../../services/sessions/browser/visibleSessions.js'; import { setActiveSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; import { SessionUsesCombinedConfigPickerContext } from '../../../common/contextkeys.js'; +import { Menus } from '../../../browser/menus.js'; const $ = DOM.$; @@ -229,7 +230,7 @@ export type AutomationSessionDraftTarget = type AutomationSessionDraftService = Pick< ISessionsManagementService, - 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' | 'supportsAutomationSessionConfiguration' + 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' | 'supportsAutomationSessionConfiguration' | 'isNewSessionTargetAvailable' | 'isQuickChatTargetAvailable' >; export type AutomationSessionConfigurationCapture = @@ -365,6 +366,20 @@ export class AutomationSessionDraftSynchronizer extends Disposable { return; } } + const targetAvailable = target.kind === 'quickChat' + ? this.sessionsManagementService.isQuickChatTargetAvailable({ + providerId: target.providerId, + sessionTypeId: target.sessionTypeId, + }) + : this.sessionsManagementService.isNewSessionTargetAvailable(target.folderUri, { + providerId: target.providerId, + sessionTypeId: target.sessionTypeId, + }); + if (!targetAvailable) { + this.discardSession(); + this.availability.set('unavailable', undefined); + return; + } const sessionConfiguration = this.configurationForTarget(target); this.session = target.kind === 'quickChat' ? this.sessionsManagementService.createAutomationQuickChat({ @@ -1233,7 +1248,7 @@ export function renderForm( suppressModelPersistence: true, menus: { executeToolbar: MenuId.AutomationsDialogInput, - inputToolbar: MenuId.AutomationsDialogInputToolbar, + inputToolbar: Menus.AutomationsDialogInputToolbar, telemetrySource: 'automations.dialog', }, widgetViewKindTag: 'automations-dialog', diff --git a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index b10c9c0b17ae4b..6c7c3e3773d22d 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts @@ -148,7 +148,12 @@ function createWorkspace(requiresWorkspaceTrust: boolean): ISessionWorkspace { }; } -function createAutomationDraftService(captureSupported = true, captureError?: Error, capturePromise?: Promise) { +function createAutomationDraftService( + captureSupported = true, + captureError?: Error, + capturePromise?: Promise, + targetAvailability: { readonly workspace?: boolean; readonly quickChat?: boolean } = {}, +) { const automationSession = observableValue('automationSession', undefined); const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string; sessionTemplate?: IAutomationSessionTemplate }> = []; const discarded: string[] = []; @@ -173,6 +178,8 @@ function createAutomationDraftService(captureSupported = true, captureError?: Er automationSession, createAutomationSession: (folderUri, options) => createDraft('workspace', options?.providerId, options?.sessionTypeId ?? 'default', folderUri, options?.sessionTemplate), createAutomationQuickChat: options => createDraft('quickChat', options?.providerId, options?.sessionTypeId ?? 'default', undefined, options?.sessionTemplate), + isNewSessionTargetAvailable: () => targetAvailability.workspace !== false, + isQuickChatTargetAvailable: () => targetAvailability.quickChat !== false, supportsAutomationSessionConfiguration: () => captureSupported, getAutomationSessionConfiguration: async session => { if (captureError) { @@ -271,6 +278,49 @@ suite('Automation session draft synchronization', () => { }); }); + test('preserves saved configuration when workspace and quick-chat targets are unavailable', async () => { + const workspaceConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'plan' } }, + }; + const quickChatConfiguration: IAutomationSessionConfiguration = { + sessionTemplate: { config: { mode: 'autopilot', autoApprove: 'assisted' } }, + }; + const { service, created } = createAutomationDraftService(true, undefined, undefined, { workspace: false, quickChat: false }); + const synchronizer = disposables.add(new AutomationSessionDraftSynchronizer(service, async () => true, () => { })); + + synchronizer.update({ + kind: 'workspace', + folderUri: URI.parse('file:///workspace'), + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: workspaceConfiguration, + }); + const workspaceCapture = await synchronizer.getSessionConfiguration(); + const workspaceAvailability = synchronizer.availability.get(); + + synchronizer.update({ + kind: 'quickChat', + providerId: 'provider', + sessionTypeId: 'type', + sessionConfiguration: quickChatConfiguration, + }); + const quickChatCapture = await synchronizer.getSessionConfiguration(); + + assert.deepStrictEqual({ + created, + workspaceCapture, + workspaceAvailability, + quickChatCapture, + quickChatAvailability: synchronizer.availability.get(), + }, { + created: [], + workspaceCapture: { kind: 'preserved', configuration: workspaceConfiguration }, + workspaceAvailability: 'unavailable', + quickChatCapture: { kind: 'preserved', configuration: quickChatConfiguration }, + quickChatAvailability: 'unavailable', + }); + }); + test('distinguishes a valid empty capture from unsupported capture', async () => { const sessionConfiguration: IAutomationSessionConfiguration = { sessionTemplate: { @@ -510,6 +560,8 @@ suite('Automation session draft synchronization', () => { let errorCount = 0; const service = upcastPartial({ automationSession, + isNewSessionTargetAvailable: () => true, + isQuickChatTargetAvailable: () => true, supportsAutomationSessionConfiguration: () => true, createAutomationSession: (_folderUri, options) => { if (createCount++ === 0) { diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index a070aba992302c..feb8c80906e791 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -224,7 +224,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro this._requireOperation(id, AutomationOperation.Update); const current = this._requireAutomation(id); const updated = this._applyPatch(current, patch); - const state = await this._replaceDescriptor(updated); + const state = await this._replaceDescriptor(updated, false, undefined, patch.sessionTemplate === null); return this._requireProjectedAutomation(state); } @@ -781,13 +781,13 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return state; } - private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean): Promise { + private async _replaceDescriptor(descriptor: IAutomationDescriptor, imported = false, importPending?: boolean, resetSessionTemplate = false): Promise { const resource = automationResource(descriptor.id); const current = this._findAutomationEntry(descriptor.id); if (!current) { throw new Error(`Automation does not exist: ${descriptor.id}`); } - const definition = this._definitionFromDescriptor(descriptor, current.definition, imported, importPending); + const definition = this._definitionFromDescriptor(descriptor, current.definition, imported, importPending, resetSessionTemplate); const expected = this._requireProjectedAutomation({ ...current, definition }); const state = await this._dispatchAndWait( { @@ -828,22 +828,27 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return state; } - private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean): AutomationDefinition { + private _definitionFromDescriptor(descriptor: IAutomationDescriptor, existing?: AutomationDefinition, imported = false, importPending?: boolean, resetSessionTemplate = false): AutomationDefinition { const sessionTemplate = descriptor.sessionTemplate; const modelId = sessionTemplate ? sessionTemplate.modelId : descriptor.modelId; const provider = descriptor.target.sessionTypeId ?? this._providerFromModelId(modelId); const existingSession = existing && existing.session.provider === provider ? existing.session : undefined; - const projectedConfig = sessionTemplate - ? { + let projectedConfig: Record; + if (sessionTemplate) { + projectedConfig = { ...pickAutomationDefinitionOwnedConfigValues(existingSession?.config), - ...sessionTemplate.config, - } - : applyLegacyAutomationSessionConfig( + ...omitAutomationSessionTemplateConfigValues({ ...sessionTemplate.config }), + }; + } else if (resetSessionTemplate) { + projectedConfig = pickAutomationDefinitionOwnedConfigValues(existingSession?.config); + } else { + projectedConfig = applyLegacyAutomationSessionConfig( provider, existingSession?.config, descriptor.mode, descriptor.permissionLevel, ); + } const config = imported ? migrateLegacyAutomationSessionConfig(provider, projectedConfig) : projectedConfig; if (descriptor.target.kind === 'workspace') { setOptional(config, SessionConfigKey.Isolation, descriptor.target.isolation.kind === 'default' ? undefined : descriptor.target.isolation.kind); @@ -867,7 +872,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro session: { provider, model: modelId ? { id: this._toHostModelId(modelId, provider) } : undefined, - agent: sessionTemplate ? sessionTemplate.agent : existingSession?.agent, + agent: resetSessionTemplate ? undefined : sessionTemplate ? sessionTemplate.agent : existingSession?.agent, workingDirectories: descriptor.target.kind === 'workspace' ? [(this._boundaryMapper?.toHost(descriptor.target.folderUri) ?? descriptor.target.folderUri).toString()] : undefined, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 4baf406bf6de96..2a0948e7f8a43f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -3653,8 +3653,17 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _resolveAutomationSessionTemplate(sessionTypeId: string, configuration: IAutomationSessionConfiguration | undefined): IAutomationSessionTemplate | undefined { - if (!configuration || configuration.sessionTemplate) { - return configuration?.sessionTemplate; + if (!configuration) { + return undefined; + } + if (configuration.sessionTemplate) { + const template = configuration.sessionTemplate; + const config = omitAutomationSessionTemplateConfigValues({ ...template.config }); + return { + ...(template.modelId ? { modelId: template.modelId } : {}), + ...(template.agent ? { agent: template.agent } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; } const config = applyLegacyAutomationSessionConfig(sessionTypeId, undefined, configuration.mode, configuration.permissionLevel); if (!configuration.modelId && Object.keys(config).length === 0) { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index 775324a5c3a304..dbcdc45c3eeda2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts @@ -595,7 +595,43 @@ suite('AgentHostAutomationStore', () => { ); }); - test('clears stale platform approval config with an explicit template reset', async () => { + test('filters session-owned values from canonical templates', async () => { + const connection = disposables.add(new TestAutomationConnection(true)); + const storage = disposables.add(new InMemoryStorageService()); + const store = disposables.add(new AgentHostAutomationStore( + 'local-agent-host', + connection, + undefined, + undefined, + new NullLogService(), + storage, + NullTelemetryService, + new TestAutomationStorageService(storage), + )); + + await store.createAutomation({ + name: 'Review changes', + prompt: 'Review the current changes.', + schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + sessionTemplate: { + config: { + mode: 'plan', + providerOption: true, + [SessionConfigKey.Permissions]: { allow: ['Shell(echo *)'], deny: [] }, + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'source ~/.bashrc' }], + }, + }, + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { mode: 'plan', providerOption: true }, + ); + }); + + test('clears provider configuration and agent with an explicit template reset', async () => { const connection = disposables.add(new TestAutomationConnection(true)); const storage = disposables.add(new InMemoryStorageService()); const store = disposables.add(new AgentHostAutomationStore( @@ -613,15 +649,33 @@ suite('AgentHostAutomationStore', () => { prompt: 'Review the current changes.', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }, target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'claude' }, + sessionTemplate: { + agent: { uri: 'file:///agents/reviewer.agent.md' }, + config: { mode: 'plan', providerOption: true }, + }, + }); + const permissions = { + allow: ['Shell(echo *)'], + deny: [], + }; + connection.setFirstAutomationSessionConfig({ + [SessionConfigKey.Permissions]: permissions, + [SessionConfigKey.Mode]: 'plan', + providerOption: true, }); - connection.setFirstAutomationSessionConfig({ [SessionConfigKey.AutoApprove]: 'autoApprove' }); await store.updateAutomation(automation.id, { sessionTemplate: null }); const update = connection.dispatched.at(-1)?.action; assert.deepStrictEqual( - update?.type === ActionType.AutomationUpdateRequested ? update.changes.session?.config : undefined, - undefined, + update?.type === ActionType.AutomationUpdateRequested ? update.changes.session : undefined, + { + provider: 'claude', + model: undefined, + agent: undefined, + workingDirectories: undefined, + config: { [SessionConfigKey.Permissions]: permissions }, + }, ); }); 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 23a965e0891bf3..e7ebc5df8a2cf5 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 @@ -4564,7 +4564,12 @@ suite('LocalAgentHostSessionsProvider', () => { mode: 'plan', permissionLevel: 'assisted', }, - initialConfig: sessionTemplate.config, + initialConfig: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + clearedOption: true, + }, modelId: sessionTemplate.modelId, agentUri: sessionTemplate.agent.uri, });