Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/vs/platform/agentHost/common/automationMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
* 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';
export const AGENT_HOST_LEGACY_AUTOMATION_IMPORT_META_KEY = 'vscode.legacyAutomationImport';
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';
Expand All @@ -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<string, unknown>): Record<string, unknown>;
export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
export function migrateLegacyAutomationSessionConfig(provider: string | undefined, config: Record<string, unknown> | undefined): Record<string, unknown> | 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<string, unknown>): Record<string, unknown> {
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<Record<string, unknown>> | undefined, mode: string | undefined, permissionLevel: string | undefined): Record<string, unknown> {
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);
}
35 changes: 35 additions & 0 deletions src/vs/platform/agentHost/common/sessionConfigKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,38 @@ export function omitTransientSessionConfigValues<T>(values: Record<string, T>):
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<T>(values: Record<string, T>): Record<string, T> {
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<T>(values: Readonly<Record<string, T>> | undefined): Record<string, T> {
const result: Record<string, T> = {};
if (!values) {
return result;
}
for (const key of automationDefinitionOwnedConfigKeys) {
if (Object.hasOwn(values, key)) {
result[key] = values[key];
}
}
return result;
}
19 changes: 17 additions & 2 deletions src/vs/platform/agentHost/node/agentHostAutomationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -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';
}

Expand Down
5 changes: 4 additions & 1 deletion src/vs/platform/agentHost/node/sessionPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
}

Expand Down
32 changes: 32 additions & 0 deletions src/vs/platform/agentHost/test/common/automationMigration.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 22 additions & 1 deletion src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down
23 changes: 23 additions & 0 deletions src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading