diff --git a/src/vs/platform/agentHost/common/automationMigration.ts b/src/vs/platform/agentHost/common/automationMigration.ts index 16b4683b825852..3808594887c0d3 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,55 @@ 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' }; + } + return migrateCombinedAutopilotConfig(config); +} + +function migrateCombinedAutopilotConfig(config: Record): Record { + if (config[SessionConfigKey.AutoApprove] !== 'autopilot') { + return config; + } + const migrated = migrateLegacyAutopilotConfig(config); + 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 migrateCombinedAutopilotConfig(result); +} 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/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/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/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/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/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/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..6f3ba93db86c6c 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); + let createdSession = await fetchSessionWithChat(context.client, primarySession); + await retry(async () => { + createdSession = await fetchSessionWithChat(context.client, primarySession); + assert.strictEqual(createdSession.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/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/AUTOMATIONS.md b/src/vs/sessions/AUTOMATIONS.md index 3291f09c69a0b7..6f0df2fbb68240 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. 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. ### 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..d5e72662662533 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. 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. + ### 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/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 18d8feeec04428..1cfb4711dd680d 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 { 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'; @@ -11,12 +12,12 @@ 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'; 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, 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'; @@ -34,7 +35,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 +45,28 @@ 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 } 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'; +import { Menus } from '../../../browser/menus.js'; const $ = DOM.$; @@ -114,11 +123,11 @@ 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) { - 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); @@ -205,101 +214,194 @@ export interface IValidationState { interface IRenderFormHandle { readonly getPrompt: () => string; - readonly getMode: () => string | undefined; - readonly getPermissionLevel: () => string | undefined; - readonly getModelId: () => string | undefined; + 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; } 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 sessionConfiguration?: IAutomationSessionConfiguration } + | { readonly kind: 'quickChat'; readonly providerId: string; readonly sessionTypeId: string; readonly sessionConfiguration?: IAutomationSessionConfiguration }; type AutomationSessionDraftService = Pick< ISessionsManagementService, - 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' + 'automationSession' | 'createAutomationSession' | 'createAutomationQuickChat' | 'discardAutomationSession' | 'getAutomationSessionConfiguration' | 'supportsAutomationSessionConfiguration' | 'isNewSessionTargetAvailable' | 'isQuickChatTargetAvailable' >; +export type AutomationSessionConfigurationCapture = + | { readonly kind: 'captured'; readonly configuration: IAutomationSessionConfiguration } + | { 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; + 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; + private syncInProgress = false; private syncPromise = Promise.resolve(); private disposed = false; + private synchronizationError: unknown | undefined; constructor( 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.syncScheduled || this.syncInProgress || !!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.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(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 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; + } + return captured; + } + return { kind: 'preserved', configuration: this.configurationForTarget(this.requestedTarget) }; + } + 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 { 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; } 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 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({ providerId: target.providerId, sessionTypeId: target.sessionTypeId, + sessionTemplate: sessionConfiguration?.sessionTemplate, + automationConfiguration: sessionConfiguration, }) : this.sessionsManagementService.createAutomationSession(target.folderUri, { providerId: target.providerId, sessionTypeId: target.sessionTypeId, + sessionTemplate: sessionConfiguration?.sessionTemplate, + automationConfiguration: sessionConfiguration, }); this.appliedTarget = target; + this.appliedConfiguration = sessionConfiguration; + 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); } } @@ -311,7 +413,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.appliedConfiguration !== this.configurationForTarget(target)) { return false; } return target.kind === 'quickChat' @@ -324,6 +427,65 @@ export class AutomationSessionDraftSynchronizer extends Disposable { } this.session = undefined; this.appliedTarget = undefined; + this.appliedConfiguration = undefined; + } + + private async captureSessionConfiguration(session: ISession, target: AutomationSessionDraftTarget, timeoutMs: number, token: CancellationToken = CancellationToken.None): Promise { + try { + const result = await raceTimeout( + 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) { + 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) { + 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; + } + + 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 { @@ -334,25 +496,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'; @@ -368,6 +511,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()); @@ -822,18 +988,16 @@ export function renderForm( contextKeyService: IContextKeyService, contextViewService: IContextViewService, configurationService: IConfigurationService, - languageModelsService: ILanguageModelsService, layoutService: IWorkbenchLayoutService, logService: ILogService, - productService: IProductService, sessionsManagementService: ISessionsManagementService, workspaceTrustRequestService: IWorkspaceTrustRequestService, initialPrompt: string, - initialMode: string | undefined, - initialPermissionLevel: string | undefined, - initialModelId: string | undefined, + 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, { @@ -847,7 +1011,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')); @@ -962,6 +1126,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 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))) { + return undefined; + } + resolvedInitialProviderId ??= providerId; + resolvedInitialSessionTypeId ??= sessionTypeId; + if (resolvedInitialProviderId !== providerId || resolvedInitialSessionTypeId !== sessionTypeId) { + return undefined; + } + return initialSessionConfiguration; + }; const updateAutomationSessionTarget = () => { const folderUri = isolationModel.folderUriObs.get(); const pick = sessionTypePicker.selectedPick; @@ -973,10 +1153,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, + sessionConfiguration: getInitialSessionConfiguration(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, + sessionConfiguration: getInitialSessionConfiguration(folderUri, pick.providerId, pick.sessionTypeId, false), + }); } }; disposables.add(sessionTypePicker.onDidChangeSelectedPick(() => { @@ -1007,11 +1198,37 @@ export function renderForm( revalidate(); })); - const promptRow = DOM.append(form, $('.automation-form-row')); + 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')); 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(promptSection)); + 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); + 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)); + })); const chatInputStyles: IChatInputStyles = { overlayBackground: 'var(--vscode-input-background)', @@ -1026,12 +1243,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: Menus.AutomationsDialogInputToolbar, telemetrySource: 'automations.dialog', }, widgetViewKindTag: 'automations-dialog', @@ -1116,105 +1333,86 @@ 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(); - }); + 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, + 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.sessionControls', "Session controls"), + )); + 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', + })); + 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); + for (const container of [sessionConfigContainer, sessionControlsContainer]) { + container.toggleAttribute('inert', controlsUnavailable); + container.setAttribute('aria-hidden', String(controlsUnavailable)); + container.setAttribute('aria-busy', String(pending)); } - } - 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(); - } - }); - } + sessionConfigurationUnavailable.textContent = pending + ? localize('automation.form.sessionConfigurationLoading', "Loading session configuration…") + : availability === '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(); } }); @@ -1228,7 +1426,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); @@ -1245,17 +1443,42 @@ 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(), - getMode: () => chatInput.currentModeObs.get().id, - getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(), - getModelId: () => chatInput.selectedLanguageModel.get()?.identifier, + 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); + if (saving) { + DOM.show(saveStatus); + saveStatus.textContent = localize('automation.form.saving', "Saving automation…"); + } else { + DOM.hide(saveStatus); + saveStatus.textContent = ''; + } + }, + showSessionConfigurationError: message => { + if (message) { + DOM.show(sessionConfigurationError); + sessionConfigurationError.textContent = message; + } else { + DOM.hide(sessionConfigurationError); + sessionConfigurationError.textContent = ''; + } }, + 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 38e8f2c7f16ba0..bcd4947c6a8780 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -5,9 +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 { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.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'; @@ -15,18 +19,17 @@ 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 { 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'; import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.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 { IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; +import { IAutomationSessionConfiguration } from '../../../services/sessions/common/sessionsProvider.js'; +import { AutomationSessionConfigurationCapture, IFormState, IValidationState, isAutomationDialogPopupTarget, registerAutomationDialogKeyboardNavigation, renderForm, shouldPassThroughAutomationDialogCommand, updateSaveButtonState } from './automationDialog.js'; const $ = DOM.$; @@ -69,11 +72,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, @@ -87,6 +88,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 ?? '', @@ -111,48 +118,175 @@ 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: (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 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."); + + 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 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, // 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'); @@ -167,13 +301,14 @@ 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.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; + setSaving = handle.setSaving; + showSessionConfigurationError = handle.showSessionConfigurationError; + focusSessionConfigurationError = handle.focusSessionConfigurationError; getFocusableElements = handle.getFocusableElements; const keyboardNavigation = disposables.add(registerAutomationDialogKeyboardNavigation( DOM.getWindow(container), @@ -186,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, @@ -197,64 +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; - } - // 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 mode = getMode(); - const permissionLevel = getPermissionLevel(); - const modelId = getModelId(); - const branch = getBranch(); - const target = createAutomationTarget(state, branch); - if (!target) { - return undefined; - } - - if (existing) { - const patch: IUpdateAutomationOptions = { - name: state.name, - prompt, - schedule, - target, - modelId: modelId ?? null, - mode: mode ?? null, - permissionLevel: permissionLevel ?? null, - enabled: state.enabled, - }; - return { kind: 'update', id: existing.id, value: patch }; - } - - const create: ICreateAutomationOptions = { - name: state.name, - prompt, - schedule, - target, - modelId, - mode, - permissionLevel, - enabled: state.enabled, - }; - return { kind: 'create', value: create }; + return await completion.p; } finally { disposables.dispose(); } diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index 883e428c911d92..111ce80c0a7cfd 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,27 @@ 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 + ? { 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 || 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, + } : {}), + ...((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, } @@ -162,7 +176,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/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts index e0242910477256..250e531bcc8def 100644 --- a/src/vs/sessions/contrib/automations/browser/automationService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -17,9 +17,11 @@ import { AutomationWorkspaceIsolation, IAutomationDescriptor, IAutomationRun, + IAutomationSessionTemplate, } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { type AutomationMutationGuard, + assertAutomationSessionTemplateAuthority, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, @@ -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,9 +188,13 @@ export class AutomationStore extends Disposable implements IAutomationStore { prompt: options.prompt, schedule: options.schedule, target: normalizeAutomationTarget(options.target), - 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, @@ -533,19 +541,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 +625,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, @@ -679,10 +688,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; + : sessionTemplate ? undefined : ChatPermissionLevel.Default; return Object.freeze({ id: s.id, @@ -690,8 +700,9 @@ function createAutomationFromSerialized(s: ISerializedAutomationBase, target: Au prompt: s.prompt, schedule: s.schedule, target, - modelId: s.modelId, - mode: s.mode, + ...(sessionTemplate ? { sessionTemplate } : {}), + modelId: sessionTemplate ? undefined : s.modelId, + mode: sessionTemplate ? undefined : s.mode, permissionLevel, enabled: s.enabled, createdAt: s.createdAt, @@ -715,15 +726,37 @@ 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; + 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 + ? undefined + : patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) + ? patch.permissionLevel + : targetAuthorityChanged ? ChatPermissionLevel.Default : currentPermissionLevel; + const sessionTemplate = patch.sessionTemplate === null + ? undefined + : patch.sessionTemplate ?? (targetAuthorityChanged || legacyConfigurationPatched + ? undefined + : current.sessionTemplate); 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 +779,40 @@ 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 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/automationTools.ts b/src/vs/sessions/contrib/automations/browser/automationTools.ts index 5719db5c811129..8ec0bec523805c 100644 --- a/src/vs/sessions/contrib/automations/browser/automationTools.ts +++ b/src/vs/sessions/contrib/automations/browser/automationTools.ts @@ -15,12 +15,12 @@ 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 { 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 { 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,8 +35,10 @@ 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]; +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; @@ -56,9 +58,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; @@ -152,7 +155,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, @@ -375,6 +378,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, @@ -460,15 +465,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: { - enum: [...chatModes, null], - description: 'Chat mode, or null to use the provider default.', + type: ['string', 'null'], + 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', @@ -565,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; } } @@ -645,7 +678,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; @@ -666,8 +699,15 @@ 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 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.'); + } + 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 = { @@ -678,6 +718,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 @@ -705,6 +746,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, @@ -846,6 +888,86 @@ 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.'); + } + 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 } : {}), + ...(agent ? { agent } : {}), + ...(config ? { config } : {}), + }; +} + +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}`, state, depth + 1)])); +} + +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; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (Array.isArray(value)) { + return value.map((entry, index) => cloneJsonValue(entry, `${field}[${index}]`, state, depth + 1)); + } + if (isRecord(value)) { + 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); @@ -878,9 +1000,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/media/automationDialog.css b/src/vs/sessions/contrib/automations/browser/media/automationDialog.css index 668f808f9e3b05..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 @@ -223,10 +233,22 @@ .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; + gap: var(--vscode-spacing-size100); +} + /* * Host for the embedded ChatInputPart. The composer brings its own * background, border, and rounded corners (see chat.css @@ -298,38 +320,77 @@ 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-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; + max-width: 100%; + overflow: hidden; +} + +.automation-session-configuration-unavailable { + color: var(--vscode-descriptionForeground); + 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, +.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/browser/providerAutomationService.ts b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts index 9a83474304b422..bb6c094fee45c9 100644 --- a/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts +++ b/src/vs/sessions/contrib/automations/browser/providerAutomationService.ts @@ -298,9 +298,13 @@ export class ProviderAutomationService extends Disposable implements IAutomation prompt: previous.prompt, schedule: previous.schedule, target: previous.target, - 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/automationDialog.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationDialog.test.ts index f074e1abadddae..6c7c3e3773d22d 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'; @@ -31,12 +32,13 @@ 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 { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.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'); @@ -146,12 +148,18 @@ function createWorkspace(requiresWorkspaceTrust: boolean): ISessionWorkspace { }; } -function createAutomationDraftService() { +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 }> = []; + const created: Array<{ kind: 'workspace' | 'quickChat'; providerId: string | undefined; sessionTypeId: string; folderUri?: string; sessionTemplate?: IAutomationSessionTemplate }> = []; const discarded: string[] = []; + const sessionConfigurations = 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 +169,27 @@ 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 } : {}) }); + sessionConfigurations.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), + isNewSessionTargetAvailable: () => targetAvailability.workspace !== false, + isQuickChatTargetAvailable: () => targetAvailability.quickChat !== false, + supportsAutomationSessionConfiguration: () => captureSupported, + 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)) { @@ -178,7 +199,7 @@ function createAutomationDraftService() { automationSession.set(undefined, undefined); }, }); - return { service, created, discarded }; + return { service, created, discarded, sessionConfigurations }; } suite('Automation session draft synchronization', () => { @@ -208,6 +229,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' }, @@ -218,9 +240,275 @@ suite('Automation session draft synchronization', () => { discarded: ['automation-1', 'automation-2', 'automation-3', 'automation-4'], currentSession: undefined, errorCount: 0, + availability: 'idle', + }); + }); + + 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', + sessionConfiguration: { sessionTemplate }, + }); + + const captured = await synchronizer.getSessionConfiguration(); + + assert.deepStrictEqual({ + created, + captured, + }, { + created: [{ + kind: 'workspace', + providerId: 'provider', + sessionTypeId: 'type', + folderUri: 'file:///workspace', + sessionTemplate, + }], + captured: { kind: 'captured', configuration: { sessionTemplate } }, + }); + }); + + 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: { + 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('reports capture failures instead of silently preserving configuration', 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, + }); + + const capture = await synchronizer.getSessionConfiguration(); + assert.deepStrictEqual({ + capture: capture.kind === 'failed' ? { kind: capture.kind, message: getErrorMessage(capture.error) } : capture, + errorCount, + }, { + capture: { kind: 'failed', message: 'capture failed' }, + errorCount: 1, + }); + }); + + test('bounds complete configuration capture and reports timeouts', 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, + }); + + const capture = await synchronizer.getSessionConfiguration(); + assert.deepStrictEqual({ + capture: capture.kind === 'failed' ? { kind: capture.kind, timedOut: getErrorMessage(capture.error).includes('Timed out') } : capture, + errorCount, + }, { + 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' } }, + }; + 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(); @@ -233,9 +521,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' }, @@ -258,10 +545,12 @@ suite('Automation session draft synchronization', () => { created, currentSession: service.automationSession.get()?.sessionId, errorCount, + availability: synchronizer.availability.get(), }, { created: [], currentSession: undefined, errorCount: 1, + availability: 'unavailable', }); }); @@ -271,6 +560,9 @@ suite('Automation session draft synchronization', () => { let errorCount = 0; const service = upcastPartial({ automationSession, + isNewSessionTargetAvailable: () => true, + isQuickChatTargetAvailable: () => true, + supportsAutomationSessionConfiguration: () => true, createAutomationSession: (_folderUri, options) => { if (createCount++ === 0) { throw new Error('provider unavailable'); @@ -297,10 +589,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', }); }); }); @@ -907,36 +1201,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', () => { @@ -995,10 +1259,15 @@ 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 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, third], + () => [first, hidden, wrapper, second, inert, ariaDisabled, third], () => false, )); let downstreamKeyDowns = 0; 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..65a3527fe9a8f5 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,7 +595,12 @@ suite('AutomationRunner', () => { assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { providerId: undefined, sessionTypeId: undefined, - modelId: undefined, + sessionTemplate: undefined, + automationConfiguration: { + modelId: undefined, + mode: 'agent', + permissionLevel: 'autopilot', + }, modeId: 'agent', permissionLevel: 'autopilot', isolationMode: undefined, @@ -609,6 +608,44 @@ suite('AutomationRunner', () => { }); }); + 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, + modelId: 'stale-model', + mode: 'interactive', + permissionLevel: 'default', + }); + + await runner.runOnce(automation, 'schedule', 1).whenCompleted; + + assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { + providerId: 'local-agent-host', + sessionTypeId: 'copilotcli', + sessionTemplate, + automationConfiguration: { + sessionTemplate, + }, + modelId: 'model', + isolationMode: undefined, + branch: undefined, + }); + }); + test('passes a branch only for Worktree isolation', async () => { const { service, sessionsMgmt, runner } = setup(); sessionsMgmt.nextSession = fakeSession('s1'); @@ -633,18 +670,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/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts index 34aae1239c3bc2..b9101c6870848e 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,132 @@ 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, + 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('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(), providerId: 'default-copilot', sessionTypeId: 'copilotcli' }, + sessionTemplate, + modelId: 'stale-model', + mode: 'interactive', + permissionLevel: 'default', + }); + + 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, + }); + }); + + 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 () => { + 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({ @@ -208,16 +334,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 +360,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 +826,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 +954,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 +962,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 +990,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 faff57245539c6..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'; @@ -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, @@ -230,6 +231,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 +241,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, @@ -405,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, {}); @@ -431,9 +446,7 @@ suite('AutomationTools', () => { sessionTypeId: 'copilot', isolation: { kind: 'default' }, }, - modelId: 'gpt-test', - mode: 'agent', - permissionLevel: 'default', + sessionTemplate, enabled: true, createdAt: NOW, updatedAt: NOW, @@ -443,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]); @@ -830,6 +863,115 @@ 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 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 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]); @@ -1133,13 +1275,76 @@ 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.', + }); + }); + + 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.', }); }); 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..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'], }); @@ -560,7 +563,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/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..258d5c6daf5661 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newSessionConfigToolbars.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * 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, ariaLabel?: string): MenuWorkbenchToolBar { + return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionConfig, { + ariaLabel, + 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, ariaLabel?: string): MenuWorkbenchToolBar { + return instantiationService.createInstance(MenuWorkbenchToolBar, container, Menus.NewSessionControl, { + ariaLabel, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + }); +} 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..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 @@ -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. 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. ## Persistence and discovery diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAgentPicker.ts index 2f01f89da9aaa9..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,13 +83,75 @@ 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'; constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IChatService private readonly chatService: IChatService, @@ -97,8 +160,8 @@ class AgentHostAgentPickerContribution extends Disposable implements IWorkbenchC @ILogService private readonly logService: ILogService, ) { super(); - const modePickerModel = this._register(instantiationService.createInstance(ModePickerModel)); let settingAgentInternally = false; + const modePickerModels = this._register(new ScopedModePickerModelCache(sessionsProvidersService)); const initAgentFromActiveSession = () => { const session = sessionsService.activeSession.get(); @@ -112,11 +175,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 +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 picker = scopedInstantiationService.createInstance(ModePicker, modePickerModel, session); const disposableStore = new DisposableStore(); + 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 9358174e3acbc6..feb8c80906e791 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 } 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 { 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'; @@ -22,10 +22,9 @@ 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 { AutomationActiveRunError, type AutomationMutationGuard, type IAutomationRunClaim, type ICreateAutomationOptions, type IGuardedAutomationUpdateResult, isAutomationActiveRunError, serializeAutomationEditableState, type IUpdateAutomationOptions, type IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; +import type { AutomationRunTrigger, AutomationTarget, IAutomationDescriptor, IAutomationRun, IAutomationSchedule, IAutomationSessionTemplate } from '../../../../../workbench/contrib/chat/common/automations/automation.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 { 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'; @@ -205,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, @@ -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); } @@ -664,7 +664,7 @@ 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 { id: automationId(state.resource), @@ -672,9 +672,7 @@ 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), - mode: readString(config?.[SessionConfigKey.Mode]), - permissionLevel: readString(config?.[SessionConfigKey.AutoApprove]), + sessionTemplate: projectAutomationSessionTemplate(state.definition, modelId), enabled: state.definition.enabled, createdAt: state.createdAt, updatedAt: state.modifiedAt, @@ -783,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( { @@ -830,11 +828,28 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro return state; } - 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); + 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; + let projectedConfig: Record; + if (sessionTemplate) { + projectedConfig = { + ...pickAutomationDefinitionOwnedConfigValues(existingSession?.config), + ...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); setOptional(config, SessionConfigKey.Branch, descriptor.target.isolation.kind === 'worktree' ? descriptor.target.isolation.branch : undefined); @@ -856,7 +871,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, + model: modelId ? { id: this._toHostModelId(modelId, provider) } : undefined, + agent: resetSessionTemplate ? undefined : sessionTemplate ? sessionTemplate.agent : existingSession?.agent, workingDirectories: descriptor.target.kind === 'workspace' ? [(this._boundaryMapper?.toHost(descriptor.target.folderUri) ?? descriptor.target.folderUri).toString()] : undefined, @@ -901,23 +917,38 @@ 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; const target = patch.target ?? current.target; const targetAuthorityChanged = patch.target !== undefined && (patch.target.providerId !== current.target.providerId || patch.target.sessionTypeId !== current.target.sessionTypeId); + 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 : 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 + : 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 ?? current.mode, - permissionLevel: patch.permissionLevel === null ? undefined : patch.permissionLevel ?? current.permissionLevel, + sessionTemplate, + modelId, + mode, + permissionLevel, enabled, updatedAt: now.toISOString(), }; @@ -1226,6 +1257,30 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +function projectAutomationSessionTemplate(definition: AutomationDefinition, modelId: string | undefined): IAutomationSessionTemplate | undefined { + const config = omitAutomationSessionTemplateConfigValues({ ...definition.session.config }); + 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; + } + 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; + } + return { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(config).length > 0 ? { config } : {}), + }; +} + function setOptional(target: Record, key: string, value: unknown): void { if (value === undefined) { delete target[key]; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 1944b6b622f501..2a0948e7f8a43f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -29,7 +29,8 @@ 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, 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'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -50,6 +51,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'; @@ -60,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'; @@ -1958,6 +1960,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 @@ -2062,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 @@ -2106,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; @@ -2132,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)); @@ -2145,11 +2152,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); @@ -2239,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); @@ -2368,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) { @@ -2380,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}. @@ -2700,6 +2715,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement abstract readonly label: string; abstract readonly icon: ThemeIcon; abstract readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; + readonly usesCombinedNewSessionConfigPicker = true; + readonly supportsAutomationSessionConfiguration = true; get order(): number { return 0; } @@ -3516,7 +3533,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); + return this._createDraftSession( + sessionType, + workspace, + false, + options?.metadata, + options?.automationConfiguration, + ); } startNewSessionRequest(sessionId: string, activity?: string): IDisposable { @@ -3527,7 +3550,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 +3562,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); + return this._createDraftSession( + sessionType, + undefined, + true, + options?.metadata, + options?.automationConfiguration, + ); } /** @@ -3547,7 +3576,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, 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 @@ -3556,6 +3585,13 @@ 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._derivedNewSessionConfig(workspace), + ...this._normalizeAutomationSessionConfig(initialSessionTemplate?.config), + } + : this._initialNewSessionConfig(workspace); let newSession: NewSession; try { newSession = this._instantiationService.createInstance(NewSession, { @@ -3568,7 +3604,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, initialConfigSchema: this._seededConfigSchema(), initialMetadata, instantiationService: this._instantiationService, @@ -3615,6 +3652,37 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return newSession.session; } + private _resolveAutomationSessionTemplate(sessionTypeId: string, configuration: IAutomationSessionConfiguration | undefined): IAutomationSessionTemplate | undefined { + 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) { + 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) { @@ -3817,22 +3885,64 @@ 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 ---------------------------------------------- + async getAutomationSessionConfiguration(sessionId: string): Promise { + const newSession = this._getNewSession(sessionId); + if (!newSession) { + return undefined; + } + await newSession.waitForConfigResolution(); + if (this._getNewSession(sessionId) !== newSession) { + return undefined; + } + const config = { ...newSession.getConfigValues() }; + const initialConfig = newSession.getInitialSessionTemplate()?.config ?? {}; + for (const [key, value] of Object.entries(initialConfig)) { + if (!newSession.wasConfigValueExplicitlySet(key)) { + config[key] = value; + } + } + const templateConfig = omitAutomationSessionTemplateConfigValues(config); + const modelId = newSession.getSelectedModelId(); + const agent = newSession.getSelectedAgent(); + const sessionTemplate = !modelId && !agent && Object.keys(templateConfig).length === 0 + ? undefined + : { + ...(modelId ? { modelId } : {}), + ...(agent ? { agent: { uri: agent.uri } } : {}), + ...(Object.keys(templateConfig).length > 0 ? { config: templateConfig } : {}), + }; + const mode = templateConfig[SessionConfigKey.Mode]; + const permissionLevel = templateConfig[SessionConfigKey.AutoApprove]; + return { + sessionTemplate, + modelId, + mode: typeof mode === 'string' ? mode : undefined, + permissionLevel: typeof permissionLevel === 'string' ? permissionLevel : undefined, + }; + } + 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 @@ -3891,7 +4001,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..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'; @@ -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(); @@ -381,9 +382,11 @@ 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 && 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/agentHostAutomationStore.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAutomationStore.test.ts index ef15dc40a8eb5d..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 @@ -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,8 @@ 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', }); const create = connection.dispatched[0].action; @@ -427,18 +446,377 @@ 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', 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, }, }); }); + 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: 'copilotcli' }, + mode: 'agent', + permissionLevel: 'default', + }); + + const create = connection.dispatched[0].action; + assert.deepStrictEqual( + create.type === ActionType.AutomationCreateRequested ? create.definition.session.config : undefined, + { autoApprove: 'default' }, + ); + }); + + 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 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( + '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', + }); + + const current = store.getAutomation(automation.id); + await store.updateAutomation(automation.id, { + name: 'Review renamed changes', + sessionTemplate: current?.sessionTemplate, + }); + + 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('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( + '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' }, + 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, + }); + + await store.updateAutomation(automation.id, { sessionTemplate: null }); + + 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: { [SessionConfigKey.Permissions]: permissions }, + }, + ); + }); + + 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('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 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', + sessionTemplate: updatedTemplate, + }); + + 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: updatedTemplate, + updatedSession: { + provider: 'copilotcli', + model: { id: 'gpt-5' }, + agent: { uri: 'file:///agents/reviewer.agent.md' }, + workingDirectories: ['file:///workspace'], + config: { + mode: 'interactive', + 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); @@ -546,7 +924,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'], @@ -580,7 +958,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', @@ -613,7 +991,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, @@ -654,7 +1032,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'], @@ -662,7 +1040,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()); @@ -679,7 +1057,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: [], }, @@ -700,13 +1089,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')?.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 046178e6b0006f..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 @@ -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({ @@ -4466,6 +4505,157 @@ 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', + 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 = { + schema: { + type: 'object', + properties: { + clearedOption: { type: 'boolean', title: 'Cleared option' }, + }, + }, + values: { + mode: 'plan', + autoApprove: 'assisted', + [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, + { automationConfiguration: { sessionTemplate } }, + ); + 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(-2)?.config, + modelId: session.modelId.get(), + agentUri: session.mode.get()?.id, + }, { + captured: { + sessionTemplate: { + ...sessionTemplate, + config: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + }, + }, + modelId: sessionTemplate.modelId, + mode: 'plan', + permissionLevel: 'assisted', + }, + initialConfig: { + mode: 'plan', + autoApprove: 'assisted', + providerOption: { enabled: true }, + clearedOption: true, + }, + modelId: sessionTemplate.modelId, + agentUri: sessionTemplate.agent.uri, + }); + }); + + 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: { + 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, + { automationConfiguration: { 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 4357c9a561d250..16e7913d32d8b0 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 } 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, @@ -1429,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[] = []; @@ -1651,18 +1657,20 @@ 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; 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,13 +1679,44 @@ 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); } - createQuickChat(_sessionTypeId: string): ISession { + 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. throw new Error('CopilotChatSessionsProvider does not support quick chats'); @@ -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 = template?.modelId ?? configuration.modelId; + if (modelId) { + session.setModelId(modelId, ChatModelSource.Chosen); + } + const mode = template?.config?.[SessionConfigKey.Mode] ?? configuration.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 = template?.config?.[SessionConfigKey.AutoApprove] ?? configuration.permissionLevel; + 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/contrib/sessions/browser/views/automationsView.ts b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts index b0c4944b46abb1..4483d44d7261d4 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/automationsView.ts @@ -1358,9 +1358,13 @@ registerAction2(class DuplicateAutomationAction extends Action2 { prompt: automation.prompt, schedule: automation.schedule, target: automation.target, - modelId: automation.modelId, - mode: automation.mode, - permissionLevel: automation.permissionLevel, + ...(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 58d477c0ce981e..faf810fec4b0a0 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, }, }, @@ -946,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/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 1c0de89b12b0bb..564d88d45b5bb9 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,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, 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 @@ -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, this._providerCreateSessionOptions(provider, options)); 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, 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); @@ -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, this._providerCreateSessionOptions(provider, options)); if (previousAutomationSession && previousAutomationSession.sessionId !== session.sessionId) { this._getProvider(previousAutomationSession)?.deleteNewSession(previousAutomationSession.sessionId); } @@ -606,6 +606,35 @@ 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.`); + } + const automationConfiguration = sessionTemplate + ? { sessionTemplate } + : options?.automationConfiguration; + return { + metadata: options?.metadata, + ...(automationConfiguration ? { automationConfiguration } : {}), + }; + } + + async getAutomationSessionConfiguration(session: ISession) { + const provider = this._getProvider(session); + 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; + } + async createNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise { const provider = this._getProvider(session); if (!provider) { @@ -850,7 +879,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, this._providerCreateSessionOptions(provider, createOptions)); this._unlistedNewSessions.set(session.resource, session); const requestActivity = new MutableDisposable(); try { @@ -874,7 +903,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, 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 3488b625c13ea8..516487685bde2d 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -8,8 +8,9 @@ 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'; +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 { @@ -93,6 +94,10 @@ export interface ICreateNewSessionOptions { * does not implement the setter. */ 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 @@ -392,6 +397,18 @@ export interface ISessionsManagementService { */ discardAutomationSession(session?: ISession): void; + /** + * 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 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; + /** * 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..d9dfe3c851fa9c 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,16 @@ export interface ISendRequestOptions { export interface ISessionsProviderCreateSessionOptions { /** Initial provider metadata to associate with the session. */ readonly metadata?: Record; + /** 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. */ @@ -241,6 +251,11 @@ export interface ISessionsProvider { */ readonly supportsQuickChats?: boolean; + /** 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. * {@link supportsQuickChats}) changes at runtime, so they can re-evaluate. @@ -296,7 +311,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 +322,9 @@ export interface ISessionsProvider { */ deleteNewSession(sessionId: string): void; + /** Capture Automation draft values; implementing this also declares support for restoring `automationConfiguration` at draft creation. */ + getAutomationSessionConfiguration?(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..4c27736b266fd7 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,9 @@ 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'); } + 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'); } 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..de90014ff2fd69 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'; @@ -1842,6 +1843,73 @@ 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 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 = { + 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, + automationConfiguration: { sessionTemplate }, + }); + }); + + 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', @@ -2925,7 +2993,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,9 +3001,16 @@ 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; + override readonly supportsAutomationSessionConfiguration = true; override resolveWorkspace(folderUri: URI): ISessionWorkspace { return { uri: folderUri, @@ -2946,16 +3021,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 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); + const firstAutomationSession = service.createAutomationSession(folderUri, { sessionTemplate }); + const capturedConfiguration = await service.getAutomationSessionConfiguration(firstAutomationSession); service.createNewSession(folderUri); - service.createAutomationQuickChat(); + service.createAutomationQuickChat({ sessionTemplate }); service.discardAutomationSession(firstAutomationSession); service.createAutomationSession(folderUri); service.discardAutomationSession(); @@ -2963,10 +3046,19 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual({ newSession: service.newSession.get()?.sessionId, automationSession: service.automationSession.get()?.sessionId, + capturedConfiguration, + createOptions, deleted, }, { newSession: 'new-session', automationSession: undefined, + capturedConfiguration: { sessionTemplate }, + createOptions: [ + { metadata: undefined, automationConfiguration: { sessionTemplate } }, + { metadata: undefined }, + { metadata: undefined, automationConfiguration: { sessionTemplate } }, + { metadata: undefined }, + ], deleted: ['automation-workspace', 'automation-quick-chat', 'automation-replacement'], }); }); 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 diff --git a/src/vs/workbench/contrib/chat/common/automations/automation.ts b/src/vs/workbench/contrib/chat/common/automations/automation.ts index e042db5a32cb82..2c00079065abbd 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,13 +75,16 @@ export interface IAutomationDescriptor { /** Explicit workspace-backed or workspace-less execution target. */ readonly target: AutomationTarget; - /** Optional language model identifier to seed the new session with. */ + /** Complete provider-owned session template. */ + readonly sessionTemplate?: IAutomationSessionTemplate; + + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly modelId?: string; - /** Optional chat mode (`agent`/`ask`/`edit`). Defaults to provider's default; custom modes unsupported. */ + /** @deprecated Legacy decode alias. New Automations store this in {@link sessionTemplate}. */ readonly mode?: string; - /** Optional permission level (`default`/`autoApprove`/`autopilot`). Overrides only for scheduled runs; defaults to provider's default. */ + /** @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 f92f21f536e57c..c82a41098c5ba0 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'; @@ -30,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`. @@ -39,8 +56,12 @@ export interface ICreateAutomationOptions { readonly prompt: string; 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; } @@ -54,8 +75,12 @@ export interface IUpdateAutomationOptions { readonly prompt?: string; 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; } @@ -89,7 +114,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 +124,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, diff --git a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts index 60e2508221d682..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'; @@ -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.';