mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
Merge pull request #2921 from QwenLM/feat/plan-mode
feat(cli): implement /plan command for plan mode
This commit is contained in:
commit
db7488f3a2
17 changed files with 553 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" 进入计划模式。',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
159
packages/cli/src/ui/commands/planCommand.test.ts
Normal file
159
packages/cli/src/ui/commands/planCommand.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
104
packages/cli/src/ui/commands/planCommand.ts
Normal file
104
packages/cli/src/ui/commands/planCommand.ts
Normal 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.'),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue