Merge pull request #2921 from QwenLM/feat/plan-mode

feat(cli): implement /plan command for plan mode
This commit is contained in:
zhangxy-zju 2026-04-08 15:23:27 +08:00 committed by GitHub
commit db7488f3a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 553 additions and 11 deletions

View file

@ -1,6 +1,6 @@
# Approval Mode
Qwen Code offers three distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level.
Qwen Code offers four distinct permission modes that allow you to flexibly control how AI interacts with your code and system based on task complexity and risk level.
## Permission Modes Comparison
@ -40,6 +40,18 @@ You can switch into Plan Mode during a session using **Shift+Tab** (or **Tab** o
If you are in Normal Mode, **Shift+Tab** (or **Tab** on Windows) first switches into `auto-edits` Mode, indicated by `⏵⏵ accept edits on` at the bottom of the terminal. A subsequent **Shift+Tab** (or **Tab** on Windows) will switch into Plan Mode, indicated by `⏸ plan mode`.
**Use the `/plan` command**
The `/plan` command provides a quick shortcut for entering and exiting Plan Mode:
```bash
/plan # Enter plan mode
/plan refactor the auth module # Enter plan mode and start planning
/plan exit # Exit plan mode, restore previous mode
```
When you exit Plan Mode with `/plan exit`, your previous approval mode is automatically restored (e.g., if you were in Auto-Edit before entering Plan Mode, you'll return to Auto-Edit).
**Start a new session in Plan Mode**
To start a new session in Plan Mode, use the `/approval-mode` then select `plan`
@ -59,14 +71,10 @@ qwen --prompt "What is machine learning?"
### Example: Planning a complex refactor
```bash
/approval-mode plan
/plan I need to refactor our authentication system to use OAuth2. Create a detailed migration plan.
```
```
I need to refactor our authentication system to use OAuth2. Create a detailed migration plan.
```
Qwen Code analyzes the current implementation and create a comprehensive plan. Refine with follow-ups:
Qwen Code enters Plan Mode and analyzes the current implementation to create a comprehensive plan. Refine with follow-ups:
```
What about backward compatibility?
@ -235,7 +243,7 @@ qwen --prompt "Run the test suite, fix all failing tests, then commit changes"
### Keyboard Shortcut Switching
During a Qwen Code session, use **Shift+Tab** (or **Tab** on Windows) to quickly cycle through the three modes:
During a Qwen Code session, use **Shift+Tab** (or **Tab** on Windows) to quickly cycle through the four modes:
```
Default Mode → Auto-Edit Mode → YOLO Mode → Plan Mode → Default Mode

View file

@ -61,6 +61,7 @@ Commands for managing AI tools and models.
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc` |
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
| `/skills` | List and run available skills | `/skills`, `/skills <name>` |
| `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan <task>`, `/plan exit` |
| `/approval-mode` | Change approval mode for tool usage | `/approval-mode <mode (auto-edit)> --project` |
| →`plan` | Analysis only, no execution | Secure review |
| →`default` | Require approval for edits | Daily use |

View file

@ -1973,4 +1973,15 @@ export default {
'Vollständige Tool-Ausgabe und Denkprozess im ausführlichen Modus anzeigen (mit Strg+O umschalten).',
'Press Ctrl+O to show full tool output':
'Strg+O für vollständige Tool-Ausgabe drücken',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Exited plan mode. Previous approval mode restored.':
'Exited plan mode. Previous approval mode restored.',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
'Enabled plan mode. The agent will analyze and plan without executing tools.',
'Already in plan mode. Use "/plan exit" to exit plan mode.':
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
};

View file

@ -2013,4 +2013,15 @@ export default {
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Press Ctrl+O to show full tool output',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Exited plan mode. Previous approval mode restored.':
'Exited plan mode. Previous approval mode restored.',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
'Enabled plan mode. The agent will analyze and plan without executing tools.',
'Already in plan mode. Use "/plan exit" to exit plan mode.':
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
};

View file

@ -1464,4 +1464,15 @@ export default {
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'詳細モードで完全なツール出力と思考を表示しますCtrl+O で切り替え)。',
'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Exited plan mode. Previous approval mode restored.':
'Exited plan mode. Previous approval mode restored.',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
'Enabled plan mode. The agent will analyze and plan without executing tools.',
'Already in plan mode. Use "/plan exit" to exit plan mode.':
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
};

View file

@ -1963,4 +1963,15 @@ export default {
'Mostrar saída completa da ferramenta e raciocínio no modo detalhado (alternar com Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Pressione Ctrl+O para exibir a saída completa da ferramenta',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Exited plan mode. Previous approval mode restored.':
'Exited plan mode. Previous approval mode restored.',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
'Enabled plan mode. The agent will analyze and plan without executing tools.',
'Already in plan mode. Use "/plan exit" to exit plan mode.':
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
};

View file

@ -1970,4 +1970,15 @@ export default {
'Показывать полный вывод инструментов и процесс рассуждений в подробном режиме (переключить с помощью Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Нажмите Ctrl+O для показа полного вывода инструментов',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Exited plan mode. Previous approval mode restored.':
'Exited plan mode. Previous approval mode restored.',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
'Enabled plan mode. The agent will analyze and plan without executing tools.',
'Already in plan mode. Use "/plan exit" to exit plan mode.':
'Already in plan mode. Use "/plan exit" to exit plan mode.',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'Not in plan mode. Use "/plan" to enter plan mode first.',
};

View file

@ -1817,4 +1817,14 @@ export default {
'Show full tool output and thinking in verbose mode (toggle with Ctrl+O).':
'详细模式下显示完整工具输出和思考过程Ctrl+O 切换)。',
'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果',
'Switch to plan mode or exit plan mode': '切换到计划模式或退出计划模式',
'Exited plan mode. Previous approval mode restored.':
'已退出计划模式,已恢复之前的审批模式。',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
'启用计划模式。智能体将只分析和规划,而不执行工具。',
'Already in plan mode. Use "/plan exit" to exit plan mode.':
'已处于计划模式。使用 "/plan exit" 退出计划模式。',
'Not in plan mode. Use "/plan" to enter plan mode first.':
'未处于计划模式。请先使用 "/plan" 进入计划模式。',
};

View file

@ -32,6 +32,7 @@ import { languageCommand } from '../ui/commands/languageCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { planCommand } from '../ui/commands/planCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { trustCommand } from '../ui/commands/trustCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
@ -103,6 +104,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
mcpCommand,
memoryCommand,
modelCommand,
planCommand,
permissionsCommand,
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,

View file

@ -0,0 +1,159 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { planCommand } from './planCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
describe('planCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getPrePlanMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
} as unknown as import('@qwen-code/qwen-code-core').Config,
},
});
});
it('should switch to plan mode if not in plan mode', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
const result = await planCommand.action(mockContext, '');
expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'Enabled plan mode. The agent will analyze and plan without executing tools.',
});
});
it('should return submit prompt if arguments are provided when switching to plan mode', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
const result = await planCommand.action(mockContext, 'refactor the code');
expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'refactor the code' }],
});
});
it('should return already in plan mode if mode is already plan', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
(mockContext.services.config?.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.PLAN,
);
const result = await planCommand.action(mockContext, '');
expect(mockContext.services.config?.setApprovalMode).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Already in plan mode. Use "/plan exit" to exit plan mode.',
});
});
it('should return submit prompt if arguments are provided and already in plan mode', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
(mockContext.services.config?.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.PLAN,
);
const result = await planCommand.action(mockContext, 'keep planning');
expect(mockContext.services.config?.setApprovalMode).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'keep planning' }],
});
});
it('should exit plan mode when exit argument is passed', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
(mockContext.services.config?.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.PLAN,
);
const result = await planCommand.action(mockContext, 'exit');
expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Exited plan mode. Previous approval mode restored.',
});
});
it('should restore pre-plan mode when executing from plan mode', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
(mockContext.services.config?.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.PLAN,
);
(mockContext.services.config?.getPrePlanMode as Mock).mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
const result = await planCommand.action(mockContext, 'exit');
expect(mockContext.services.config?.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Exited plan mode. Previous approval mode restored.',
});
});
it('should return error when execute is used but not in plan mode', async () => {
if (!planCommand.action) {
throw new Error('The plan command must have an action.');
}
// Default mock returns ApprovalMode.DEFAULT (not PLAN)
const result = await planCommand.action(mockContext, 'exit');
expect(mockContext.services.config?.setApprovalMode).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Not in plan mode. Use "/plan" to enter plan mode first.',
});
});
});

View file

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
CommandKind,
type SlashCommand,
type MessageActionReturn,
type SubmitPromptActionReturn,
} from './types.js';
import { t } from '../../i18n/index.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
export const planCommand: SlashCommand = {
name: 'plan',
get description() {
return t('Switch to plan mode or exit plan mode');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn | SubmitPromptActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Configuration is not available.'),
};
}
const trimmedArgs = args.trim();
const currentMode = config.getApprovalMode();
if (trimmedArgs === 'exit') {
if (currentMode !== ApprovalMode.PLAN) {
return {
type: 'message',
messageType: 'error',
content: t('Not in plan mode. Use "/plan" to enter plan mode first.'),
};
}
try {
config.setApprovalMode(config.getPrePlanMode());
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: (e as Error).message,
};
}
return {
type: 'message',
messageType: 'info',
content: t('Exited plan mode. Previous approval mode restored.'),
};
}
if (currentMode !== ApprovalMode.PLAN) {
try {
config.setApprovalMode(ApprovalMode.PLAN);
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: (e as Error).message,
};
}
if (trimmedArgs) {
return {
type: 'submit_prompt',
content: [{ text: trimmedArgs }],
};
}
return {
type: 'message',
messageType: 'info',
content: t(
'Enabled plan mode. The agent will analyze and plan without executing tools.',
),
};
}
// Already in plan mode
if (trimmedArgs) {
return {
type: 'submit_prompt',
content: [{ text: trimmedArgs }],
};
}
return {
type: 'message',
messageType: 'info',
content: t('Already in plan mode. Use "/plan exit" to exit plan mode.'),
};
},
};

View file

@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import type { ConfigParameters, SandboxConfig } from './config.js';
import { Config, ApprovalMode } from './config.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
import {
@ -57,6 +58,9 @@ vi.mock('node:fs', async (importOriginal) => {
isDirectory: vi.fn().mockReturnValue(true),
}),
realpathSync: vi.fn((path) => path),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn(),
};
return {
...mocked,
@ -1203,6 +1207,103 @@ describe('setApprovalMode with folder trust', () => {
expect(() => config.setApprovalMode(ApprovalMode.PLAN)).not.toThrow();
});
describe('prePlanMode tracking', () => {
it('should save pre-plan mode when entering plan mode', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
config.setApprovalMode(ApprovalMode.AUTO_EDIT);
config.setApprovalMode(ApprovalMode.PLAN);
expect(config.getPrePlanMode()).toBe(ApprovalMode.AUTO_EDIT);
});
it('should clear pre-plan mode when leaving plan mode', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
config.setApprovalMode(ApprovalMode.AUTO_EDIT);
config.setApprovalMode(ApprovalMode.PLAN);
config.setApprovalMode(ApprovalMode.DEFAULT);
expect(config.getPrePlanMode()).toBe(ApprovalMode.DEFAULT);
});
it('should default to DEFAULT when no pre-plan mode was recorded', () => {
const config = new Config(baseParams);
expect(config.getPrePlanMode()).toBe(ApprovalMode.DEFAULT);
});
it('should not update pre-plan mode when already in plan mode', () => {
const config = new Config(baseParams);
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
config.setApprovalMode(ApprovalMode.YOLO);
config.setApprovalMode(ApprovalMode.PLAN);
// Setting PLAN again should not overwrite prePlanMode
config.setApprovalMode(ApprovalMode.PLAN);
expect(config.getPrePlanMode()).toBe(ApprovalMode.YOLO);
});
});
describe('plan file persistence', () => {
it('should save plan to disk', () => {
const config = new Config(baseParams);
config.savePlan('# My Plan\n1. Step one\n2. Step two');
expect(fs.mkdirSync).toHaveBeenCalledWith(
expect.stringContaining('plans'),
{ recursive: true },
);
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('.md'),
'# My Plan\n1. Step one\n2. Step two',
'utf-8',
);
});
it('should load plan from disk', () => {
const config = new Config(baseParams);
(fs.readFileSync as Mock).mockReturnValue('# Saved Plan');
const plan = config.loadPlan();
expect(plan).toBe('# Saved Plan');
});
it('should return undefined when no plan file exists', () => {
const config = new Config(baseParams);
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';
(fs.readFileSync as Mock).mockImplementation(() => {
throw enoentError;
});
const plan = config.loadPlan();
expect(plan).toBeUndefined();
});
it('should rethrow non-ENOENT errors from loadPlan', () => {
const config = new Config(baseParams);
const permError = new Error('EACCES') as NodeJS.ErrnoException;
permError.code = 'EACCES';
(fs.readFileSync as Mock).mockImplementation(() => {
throw permError;
});
expect(() => config.loadPlan()).toThrow('EACCES');
});
it('should use session ID in plan file path', () => {
const config = new Config({
...baseParams,
sessionId: 'test-session-123',
});
const filePath = config.getPlanFilePath();
expect(filePath).toContain('test-session-123');
expect(filePath).toMatch(/\.md$/);
});
});
describe('registerCoreTools', () => {
beforeEach(() => {
vi.clearAllMocks();

View file

@ -6,6 +6,7 @@
// Node built-ins
import type { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as path from 'node:path';
import process from 'node:process';
@ -529,6 +530,7 @@ export class Config {
private sdkMode: boolean;
private geminiMdFileCount: number;
private approvalMode: ApprovalMode;
private prePlanMode?: ApprovalMode;
private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings;
private readonly gitCoAuthor: GitCoAuthorSettings;
@ -1634,6 +1636,14 @@ export class Config {
return this.approvalMode;
}
/**
* Returns the approval mode that was active before entering plan mode.
* Falls back to DEFAULT if no pre-plan mode was recorded.
*/
getPrePlanMode(): ApprovalMode {
return this.prePlanMode ?? ApprovalMode.DEFAULT;
}
setApprovalMode(mode: ApprovalMode): void {
if (
!this.isTrustedFolder() &&
@ -1644,9 +1654,55 @@ export class Config {
'Cannot enable privileged approval modes in an untrusted folder.',
);
}
// Track the mode before entering plan mode so it can be restored later
if (mode === ApprovalMode.PLAN && this.approvalMode !== ApprovalMode.PLAN) {
this.prePlanMode = this.approvalMode;
} else if (
mode !== ApprovalMode.PLAN &&
this.approvalMode === ApprovalMode.PLAN
) {
this.prePlanMode = undefined;
}
this.approvalMode = mode;
}
/**
* Returns the file path for this session's plan file.
*/
getPlanFilePath(): string {
return Storage.getPlanFilePath(this.sessionId);
}
/**
* Saves a plan to disk for the current session.
*/
savePlan(plan: string): void {
const filePath = this.getPlanFilePath();
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, plan, 'utf-8');
}
/**
* Loads the plan for the current session, or returns undefined if none exists.
*/
loadPlan(): string | undefined {
const filePath = this.getPlanFilePath();
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
return undefined;
}
throw error;
}
}
getInputFormat(): 'text' | 'stream-json' {
return this.inputFormat;
}

View file

@ -18,6 +18,7 @@ const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects';
const IDE_DIR_NAME = 'ide';
const PLANS_DIR_NAME = 'plans';
const DEBUG_DIR_NAME = 'debug';
const ARENA_DIR_NAME = 'arena';
@ -165,6 +166,14 @@ export class Storage {
return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME);
}
static getPlansDir(): string {
return path.join(Storage.getGlobalQwenDir(), PLANS_DIR_NAME);
}
static getPlanFilePath(sessionId: string): string {
return path.join(Storage.getPlansDir(), `${sessionId}.md`);
}
static getGlobalBinDir(): string {
return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME);
}

View file

@ -18,9 +18,11 @@ describe('ExitPlanModeTool', () => {
approvalMode = ApprovalMode.PLAN;
mockConfig = {
getApprovalMode: vi.fn(() => approvalMode),
getPrePlanMode: vi.fn(() => ApprovalMode.DEFAULT),
setApprovalMode: vi.fn((mode: ApprovalMode) => {
approvalMode = mode;
}),
savePlan: vi.fn(),
} as unknown as Config;
tool = new ExitPlanModeTool(mockConfig);
@ -147,6 +149,9 @@ describe('ExitPlanModeTool', () => {
ApprovalMode.DEFAULT,
);
expect(approvalMode).toBe(ApprovalMode.DEFAULT);
// Plan should be saved to disk
expect(mockConfig.savePlan).toHaveBeenCalledWith(params.plan);
});
it('should request confirmation with plan details', async () => {
@ -173,6 +178,29 @@ describe('ExitPlanModeTool', () => {
expect(approvalMode).toBe(ApprovalMode.AUTO_EDIT);
});
it('should set DEFAULT mode on ProceedOnce regardless of pre-plan mode', async () => {
// Even if pre-plan mode was AUTO_EDIT, ProceedOnce ("manually approve
// edits") should always set DEFAULT to match the option label semantics.
(mockConfig.getPrePlanMode as ReturnType<typeof vi.fn>).mockReturnValue(
ApprovalMode.AUTO_EDIT,
);
const params: ExitPlanModeParams = { plan: 'Restore test' };
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.getConfirmationDetails(signal);
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
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',
@ -199,6 +227,9 @@ describe('ExitPlanModeTool', () => {
ApprovalMode.PLAN,
);
expect(approvalMode).toBe(ApprovalMode.PLAN);
// Plan should NOT be saved when rejected
expect(mockConfig.savePlan).not.toHaveBeenCalled();
});
it('should have correct description', () => {

View file

@ -147,6 +147,15 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
};
}
// Persist the approved plan to disk
try {
this.config.savePlan(plan);
} catch (error) {
debugLogger.warn(
`[ExitPlanModeTool] Failed to save plan to disk: ${error instanceof Error ? error.message : String(error)}`,
);
}
const llmMessage = `User has approved your plan. You can now start coding. Start with updating your todo list if applicable.`;
const displayMessage = 'User approved the plan.';

View file

@ -112,7 +112,6 @@ function createFollowupController(
suggestion_length: text.length,
});
} catch (e: unknown) {
console.error('[followup] onOutcome callback threw:', e);
}
@ -122,7 +121,6 @@ function createFollowupController(
try {
getOnAccept?.()?.(text);
} catch (error: unknown) {
console.error('[followup] onAccept callback threw:', error);
} finally {
if (acceptTimeoutId) {
@ -154,7 +152,6 @@ function createFollowupController(
suggestion_length: currentState.suggestion.length,
});
} catch (e: unknown) {
console.error('[followup] onOutcome callback threw:', e);
}
}