feat: Implement Plan Mode for Safe Code Planning (#658)

This commit is contained in:
tanzhenxin
2025-09-24 14:26:17 +08:00
committed by GitHub
parent 8379bc4d81
commit 4e7a7e2656
43 changed files with 2895 additions and 281 deletions

View File

@@ -0,0 +1,292 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExitPlanModeTool, type ExitPlanModeParams } from './exitPlanMode.js';
import { ApprovalMode, type Config } from '../config/config.js';
import { ToolConfirmationOutcome } from './tools.js';
describe('ExitPlanModeTool', () => {
let tool: ExitPlanModeTool;
let mockConfig: Config;
let approvalMode: ApprovalMode;
beforeEach(() => {
approvalMode = ApprovalMode.PLAN;
mockConfig = {
getApprovalMode: vi.fn(() => approvalMode),
setApprovalMode: vi.fn((mode: ApprovalMode) => {
approvalMode = mode;
}),
} as unknown as Config;
tool = new ExitPlanModeTool(mockConfig);
});
describe('constructor and metadata', () => {
it('should have correct tool name', () => {
expect(tool.name).toBe('exit_plan_mode');
expect(ExitPlanModeTool.Name).toBe('exit_plan_mode');
});
it('should have correct display name', () => {
expect(tool.displayName).toBe('ExitPlanMode');
});
it('should have correct kind', () => {
expect(tool.kind).toBe('think');
});
it('should have correct schema', () => {
expect(tool.schema).toEqual({
name: 'exit_plan_mode',
description: expect.stringContaining(
'Use this tool when you are in plan mode',
),
parametersJsonSchema: {
type: 'object',
properties: {
plan: {
type: 'string',
description: expect.stringContaining('The plan you came up with'),
},
},
required: ['plan'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
});
});
});
describe('validateToolParams', () => {
it('should accept valid parameters', () => {
const params: ExitPlanModeParams = {
plan: 'This is a comprehensive plan for the implementation.',
};
const result = tool.validateToolParams(params);
expect(result).toBeNull();
});
it('should reject missing plan parameter', () => {
const params = {} as ExitPlanModeParams;
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
it('should reject empty plan parameter', () => {
const params: ExitPlanModeParams = {
plan: '',
};
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
it('should reject whitespace-only plan parameter', () => {
const params: ExitPlanModeParams = {
plan: ' \n\t ',
};
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
it('should reject non-string plan parameter', () => {
const params = {
plan: 123,
} as unknown as ExitPlanModeParams;
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
});
describe('tool execution', () => {
it('should execute successfully through tool interface after approval', async () => {
const params: ExitPlanModeParams = {
plan: 'This is my implementation plan:\n1. Step 1\n2. Step 2\n3. Step 3',
};
const signal = new AbortController().signal;
// Use the tool's public build method
const invocation = tool.build(params);
expect(invocation).toBeDefined();
expect(invocation.params).toEqual(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
expect(confirmation).toMatchObject({
type: 'plan',
title: 'Would you like to proceed?',
plan: params.plan,
});
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
const result = await invocation.execute(signal);
const expectedLlmMessage =
'User has approved your plan. You can now start coding. Start with updating your todo list if applicable.';
expect(result).toEqual({
llmContent: expectedLlmMessage,
returnDisplay: {
type: 'plan_summary',
message: 'User approved the plan.',
plan: params.plan,
},
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(approvalMode).toBe(ApprovalMode.DEFAULT);
});
it('should request confirmation with plan details', async () => {
const params: ExitPlanModeParams = {
plan: 'Simple plan',
};
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
if (confirmation) {
expect(confirmation.type).toBe('plan');
if (confirmation.type === 'plan') {
expect(confirmation.plan).toBe(params.plan);
}
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlways);
}
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(approvalMode).toBe(ApprovalMode.AUTO_EDIT);
});
it('should remain in plan mode when confirmation is rejected', async () => {
const params: ExitPlanModeParams = {
plan: 'Remain in planning',
};
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
}
const result = await invocation.execute(signal);
expect(result).toEqual({
llmContent: JSON.stringify({
success: false,
plan: params.plan,
error: 'Plan execution was not approved. Remaining in plan mode.',
}),
returnDisplay:
'Plan execution was not approved. Remaining in plan mode.',
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(approvalMode).toBe(ApprovalMode.PLAN);
});
it('should have correct description', () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
expect(invocation.getDescription()).toBe(
'Present implementation plan for user approval',
);
});
it('should handle execution errors gracefully', async () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (confirmation) {
// Don't approve the plan so we go through the rejection path
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
}
// Create a spy to simulate an error during the execution
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Mock JSON.stringify to throw an error in the rejection path
const originalStringify = JSON.stringify;
vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
throw new Error('JSON stringify error');
});
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({
llmContent: JSON.stringify({
success: false,
error: 'Failed to present plan. Detail: JSON stringify error',
}),
returnDisplay: 'Error presenting plan: JSON stringify error',
});
expect(consoleSpy).toHaveBeenCalledWith(
'[ExitPlanModeTool] Error executing exit_plan_mode: JSON stringify error',
);
// Restore original JSON.stringify
JSON.stringify = originalStringify;
consoleSpy.mockRestore();
});
it('should return empty tool locations', () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
expect(invocation.toolLocations()).toEqual([]);
});
});
describe('tool description', () => {
it('should contain usage guidelines', () => {
expect(tool.description).toContain(
'Only use this tool when the task requires planning',
);
expect(tool.description).toContain(
'Do not use the exit plan mode tool because you are not planning',
);
expect(tool.description).toContain(
'Use the exit plan mode tool after you have finished planning',
);
});
it('should contain examples', () => {
expect(tool.description).toContain(
'Search for and understand the implementation of vim mode',
);
expect(tool.description).toContain('Help me implement yank mode for vim');
});
});
});