feat(plan): add "Yes, restore previous mode" option when exiting plan mode (#3008)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(plan): add "Yes, restore previous mode" option when exiting plan mode

When exiting plan mode, users previously had no way to restore their
original approval mode (e.g. YOLO). Add a new default option that
restores the pre-plan approval mode, with a dynamic label showing
which mode will be restored.

Closes #3002

* test: add fallback test for RestorePrevious when no prePlanMode recorded

* fix: handle RestorePrevious in telemetry and ACP mode notification

- Add RestorePrevious to telemetry decision mapping as ACCEPT
- Fix sendCurrentModeUpdateNotification to read actual mode for
  RestorePrevious instead of defaulting to 'default'

* test: add plan confirmation tests for RestorePrevious in permissionUtils
This commit is contained in:
zhangxy-zju 2026-04-09 14:25:38 +08:00 committed by GitHub
parent 1356c05e3f
commit 9c0bbfba6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 142 additions and 0 deletions

View file

@ -697,6 +697,10 @@ export class Session implements SessionContext {
case ToolConfirmationOutcome.ProceedAlways:
newModeId = 'auto-edit';
break;
case ToolConfirmationOutcome.RestorePrevious:
// onConfirm has already restored the mode; read the actual current mode
newModeId = this.config.getApprovalMode() as ApprovalModeValue;
break;
case ToolConfirmationOutcome.ProceedOnce:
default:
newModeId = 'default';
@ -1045,6 +1049,7 @@ export class Session implements SessionContext {
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
case ToolConfirmationOutcome.RestorePrevious:
break;
default: {
const resultOutcome: never = outcome;

View file

@ -34,6 +34,49 @@ describe('permissionUtils', () => {
);
});
it('returns plan options with RestorePrevious including prePlanMode', () => {
const options = toPermissionOptions({
type: 'plan',
title: 'Would you like to proceed?',
plan: 'Test plan',
prePlanMode: 'yolo',
onConfirm: async () => undefined,
});
expect(options).toHaveLength(4);
expect(options[0]).toMatchObject({
optionId: ToolConfirmationOutcome.RestorePrevious,
name: 'Yes, restore previous mode (yolo)',
kind: 'allow_once',
});
expect(options[1]).toMatchObject({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Yes, and auto-accept edits',
});
expect(options[2]).toMatchObject({
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Yes, and manually approve edits',
});
expect(options[3]).toMatchObject({
optionId: ToolConfirmationOutcome.Cancel,
name: 'No, keep planning (esc)',
});
});
it('defaults prePlanMode to "default" when not provided in plan options', () => {
const options = toPermissionOptions({
type: 'plan',
title: 'Would you like to proceed?',
plan: 'Test plan',
onConfirm: async () => undefined,
});
expect(options[0]).toMatchObject({
optionId: ToolConfirmationOutcome.RestorePrevious,
name: 'Yes, restore previous mode (default)',
});
});
it('falls back to rootCommand when exec permissionRules are unavailable', () => {
const options = toPermissionOptions({
type: 'exec',

View file

@ -171,6 +171,11 @@ export function toPermissionOptions(
);
case 'plan':
return [
{
optionId: ToolConfirmationOutcome.RestorePrevious,
name: `Yes, restore previous mode (${confirmation.prePlanMode ?? 'default'})`,
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Yes, and auto-accept edits',

View file

@ -1184,6 +1184,8 @@ export default {
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
'Always allow {{action}} for this user':
'{{action}} für diesen Benutzer immer erlauben',
'Yes, restore previous mode ({{mode}})':
'Ja, vorherigen Modus wiederherstellen ({{mode}})',
'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren',
'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen',
'No, keep planning (esc)': 'Nein, weiter planen (Esc)',

View file

@ -1236,6 +1236,8 @@ export default {
'Always allow for this user': 'Always allow for this user',
'Always allow {{action}} for this user':
'Always allow {{action}} for this user',
'Yes, restore previous mode ({{mode}})':
'Yes, restore previous mode ({{mode}})',
'Yes, and auto-accept edits': 'Yes, and auto-accept edits',
'Yes, and manually approve edits': 'Yes, and manually approve edits',
'No, keep planning (esc)': 'No, keep planning (esc)',

View file

@ -918,6 +918,8 @@ export default {
'このプロジェクトで{{action}}を常に許可',
'Always allow for this user': 'このユーザーに常に許可',
'Always allow {{action}} for this user': 'このユーザーに{{action}}を常に許可',
'Yes, restore previous mode ({{mode}})':
'はい、以前のモードに戻す ({{mode}})',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',

View file

@ -1190,6 +1190,8 @@ export default {
'Always allow for this user': 'Sempre permitir para este usuário',
'Always allow {{action}} for this user':
'Sempre permitir {{action}} para este usuário',
'Yes, restore previous mode ({{mode}})':
'Sim, restaurar modo anterior ({{mode}})',
'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente',
'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente',
'No, keep planning (esc)': 'Não, continuar planejando (esc)',

View file

@ -1114,6 +1114,8 @@ export default {
'Always allow for this user': 'Всегда разрешать для этого пользователя',
'Always allow {{action}} for this user':
'Всегда разрешать {{action}} для этого пользователя',
'Yes, restore previous mode ({{mode}})':
'Да, восстановить предыдущий режим ({{mode}})',
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',

View file

@ -1170,6 +1170,7 @@ export default {
'Always allow {{action}} in this project': '在本项目中总是允许{{action}}',
'Always allow for this user': '对该用户总是允许',
'Always allow {{action}} for this user': '对该用户总是允许{{action}}',
'Yes, restore previous mode ({{mode}})': '是,恢复之前的模式 ({{mode}})',
'Yes, and auto-accept edits': '是,并自动接受编辑',
'Yes, and manually approve edits': '是,并手动批准编辑',
'No, keep planning (esc)': '否,继续规划 (esc)',

View file

@ -302,6 +302,13 @@ export const ToolConfirmationMessage: React.FC<
const planProps = confirmationDetails;
question = planProps.title;
options.push({
key: 'restore-previous',
label: t('Yes, restore previous mode ({{mode}})', {
mode: planProps.prePlanMode ?? 'default',
}),
value: ToolConfirmationOutcome.RestorePrevious,
});
options.push({
key: 'proceed-always',
label: t('Yes, and auto-accept edits'),

View file

@ -18,6 +18,7 @@ export function getDecisionFromOutcome(
): ToolCallDecision {
switch (outcome) {
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.RestorePrevious:
return ToolCallDecision.ACCEPT;
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysServer:

View file

@ -201,6 +201,66 @@ describe('ExitPlanModeTool', () => {
expect(approvalMode).toBe(ApprovalMode.DEFAULT);
});
it('should restore pre-plan mode on RestorePrevious', async () => {
(mockConfig.getPrePlanMode as ReturnType<typeof vi.fn>).mockReturnValue(
ApprovalMode.YOLO,
);
const params: ExitPlanModeParams = { plan: 'Restore previous test' };
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.getConfirmationDetails(signal);
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.RestorePrevious);
}
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(approvalMode).toBe(ApprovalMode.YOLO);
});
it('should include prePlanMode in confirmation details', async () => {
(mockConfig.getPrePlanMode as ReturnType<typeof vi.fn>).mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
const params: ExitPlanModeParams = { plan: 'Test plan' };
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.getConfirmationDetails(signal);
expect(confirmation).toMatchObject({
type: 'plan',
prePlanMode: ApprovalMode.AUTO_EDIT,
});
});
it('should fall back to DEFAULT on RestorePrevious when no prePlanMode recorded', async () => {
// getPrePlanMode() defaults to DEFAULT when prePlanMode is undefined
(mockConfig.getPrePlanMode as ReturnType<typeof vi.fn>).mockReturnValue(
ApprovalMode.DEFAULT,
);
const params: ExitPlanModeParams = { plan: 'Fallback test' };
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.getConfirmationDetails(signal);
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.RestorePrevious);
}
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(approvalMode).toBe(ApprovalMode.DEFAULT);
});
it('should remain in plan mode when confirmation is rejected', async () => {
const params: ExitPlanModeParams = {
plan: 'Remain in planning',

View file

@ -87,12 +87,18 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolPlanConfirmationDetails> {
const prePlanMode = this.config.getPrePlanMode();
const details: ToolPlanConfirmationDetails = {
type: 'plan',
title: 'Would you like to proceed?',
plan: this.params.plan,
prePlanMode,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
switch (outcome) {
case ToolConfirmationOutcome.RestorePrevious:
this.wasApproved = true;
this.setApprovalModeSafely(prePlanMode);
break;
case ToolConfirmationOutcome.ProceedAlways:
this.wasApproved = true;
this.setApprovalModeSafely(ApprovalMode.AUTO_EDIT);

View file

@ -667,6 +667,8 @@ export interface ToolPlanConfirmationDetails {
/** @see ToolEditConfirmationDetails.hideAlwaysAllow */
hideAlwaysAllow?: boolean;
plan: string;
/** The approval mode that was active before entering plan mode (for display in the UI). */
prePlanMode?: string;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
@ -711,6 +713,8 @@ export enum ToolConfirmationOutcome {
/** Persist the permission rule to the user settings (user scope). */
ProceedAlwaysUser = 'proceed_always_user',
ModifyWithEditor = 'modify_with_editor',
/** Restore the approval mode that was active before entering plan mode. */
RestorePrevious = 'restore_previous',
Cancel = 'cancel',
}