feat(plan): add core logic and exit_plan_mode tool definition (#18110)

This commit is contained in:
Jerop Kipruto
2026-02-02 22:30:03 -05:00
committed by GitHub
parent 01e33465bd
commit ed26ea49e9
13 changed files with 981 additions and 2 deletions

View File

@@ -0,0 +1,421 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExitPlanModeTool, ExitPlanModeInvocation } from './exit-plan-mode.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ApprovalMode } from '../policy/types.js';
import * as fs from 'node:fs';
import os from 'node:os';
import { validatePlanPath } from '../utils/planUtils.js';
describe('ExitPlanModeTool', () => {
let tool: ExitPlanModeTool;
let mockMessageBus: ReturnType<typeof createMockMessageBus>;
let mockConfig: Partial<Config>;
let tempRootDir: string;
let mockPlansDir: string;
beforeEach(() => {
vi.useFakeTimers();
mockMessageBus = createMockMessageBus();
vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined);
tempRootDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'exit-plan-test-')),
);
const plansDirRaw = path.join(tempRootDir, 'plans');
fs.mkdirSync(plansDirRaw, { recursive: true });
mockPlansDir = fs.realpathSync(plansDirRaw);
mockConfig = {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
setApprovalMode: vi.fn(),
storage: {
getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir),
} as unknown as Config['storage'],
};
tool = new ExitPlanModeTool(
mockConfig as Config,
mockMessageBus as unknown as MessageBus,
);
// Mock getMessageBusDecision on the invocation prototype
vi.spyOn(
ExitPlanModeInvocation.prototype as unknown as {
getMessageBusDecision: () => Promise<string>;
},
'getMessageBusDecision',
).mockResolvedValue('ASK_USER');
});
afterEach(() => {
if (fs.existsSync(tempRootDir)) {
fs.rmSync(tempRootDir, { recursive: true, force: true });
}
vi.useRealTimers();
vi.restoreAllMocks();
});
const createPlanFile = (name: string, content: string) => {
const filePath = path.join(mockPlansDir, name);
fs.writeFileSync(filePath, content);
return path.join('plans', name);
};
describe('shouldConfirmExecute', () => {
it('should return plan approval confirmation details when plan has content', async () => {
const planRelativePath = createPlanFile('test-plan.md', '# My Plan');
const invocation = tool.build({ plan_path: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).not.toBe(false);
if (result === false) return;
expect(result.type).toBe('exit_plan_mode');
expect(result.title).toBe('Plan Approval');
if (result.type === 'exit_plan_mode') {
expect(result.planPath).toBe(path.join(mockPlansDir, 'test-plan.md'));
}
expect(typeof result.onConfirm).toBe('function');
});
it('should return false when plan file is empty', async () => {
const planRelativePath = createPlanFile('empty.md', ' ');
const invocation = tool.build({ plan_path: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).toBe(false);
});
it('should return false when plan file cannot be read', async () => {
const planRelativePath = path.join('plans', 'non-existent.md');
const invocation = tool.build({ plan_path: planRelativePath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).toBe(false);
});
it('should auto-approve when policy decision is ALLOW', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
vi.spyOn(
invocation as unknown as {
getMessageBusDecision: () => Promise<string>;
},
'getMessageBusDecision',
).mockResolvedValue('ALLOW');
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).toBe(false);
// Verify it auto-approved internally
const executeResult = await invocation.execute(
new AbortController().signal,
);
expect(executeResult.llmContent).toContain('Plan approved');
});
it('should throw error when policy decision is DENY', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
vi.spyOn(
invocation as unknown as {
getMessageBusDecision: () => Promise<string>;
},
'getMessageBusDecision',
).mockResolvedValue('DENY');
await expect(
invocation.shouldConfirmExecute(new AbortController().signal),
).rejects.toThrow(/denied by policy/);
});
});
describe('execute with invalid plan', () => {
it('should return error when plan file is empty', async () => {
const planRelativePath = createPlanFile('empty.md', '');
const invocation = tool.build({ plan_path: planRelativePath });
await invocation.shouldConfirmExecute(new AbortController().signal);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Plan file is empty');
expect(result.llmContent).toContain('write content to the plan');
});
it('should return error when plan file cannot be read', async () => {
const planRelativePath = 'plans/ghost.md';
const invocation = tool.build({ plan_path: planRelativePath });
await invocation.shouldConfirmExecute(new AbortController().signal);
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Plan file does not exist');
});
});
describe('execute', () => {
it('should return approval message when plan is approved with DEFAULT mode', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode: ApprovalMode.DEFAULT,
});
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.join(mockPlansDir, 'test.md');
expect(result).toEqual({
llmContent: `Plan approved. Switching to Default mode (edits will require confirmation).
The approved implementation plan is stored at: ${expectedPath}
Read and follow the plan strictly during implementation.`,
returnDisplay: `Plan approved: ${expectedPath}`,
});
});
it('should return approval message when plan is approved with AUTO_EDIT mode', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode: ApprovalMode.AUTO_EDIT,
});
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.join(mockPlansDir, 'test.md');
expect(result).toEqual({
llmContent: `Plan approved. Switching to Auto-Edit mode (edits will be applied automatically).
The approved implementation plan is stored at: ${expectedPath}
Read and follow the plan strictly during implementation.`,
returnDisplay: `Plan approved: ${expectedPath}`,
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
});
it('should return feedback message when plan is rejected with feedback', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
feedback: 'Please add more details.',
});
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.join(mockPlansDir, 'test.md');
expect(result).toEqual({
llmContent: `Plan rejected. User feedback: Please add more details.
The plan is stored at: ${expectedPath}
Revise the plan based on the feedback.`,
returnDisplay: 'Feedback: Please add more details.',
});
});
it('should handle rejection without feedback gracefully', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: false,
});
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.join(mockPlansDir, 'test.md');
expect(result).toEqual({
llmContent: `Plan rejected. No feedback provided.
The plan is stored at: ${expectedPath}
Ask the user for specific feedback on how to improve the plan.`,
returnDisplay: 'Rejected (no feedback)',
});
});
it('should return cancellation message when cancelled', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.Cancel);
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({
llmContent:
'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.',
returnDisplay: 'Cancelled',
});
});
});
describe('getApprovalModeDescription (internal)', () => {
it('should handle all valid approval modes', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const testMode = async (mode: ApprovalMode, expected: string) => {
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode: mode,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain(expected);
};
await testMode(
ApprovalMode.AUTO_EDIT,
'Auto-Edit mode (edits will be applied automatically)',
);
await testMode(
ApprovalMode.DEFAULT,
'Default mode (edits will require confirmation)',
);
});
it('should throw for invalid post-planning modes', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const testInvalidMode = async (mode: ApprovalMode) => {
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode: mode,
});
await expect(
invocation.execute(new AbortController().signal),
).rejects.toThrow(/Unexpected approval mode/);
};
await testInvalidMode(ApprovalMode.YOLO);
await testInvalidMode(ApprovalMode.PLAN);
});
});
it('should throw error during build if plan path is outside plans directory', () => {
expect(() => tool.build({ plan_path: '../../../etc/passwd' })).toThrow(
/Access denied/,
);
});
describe('validateToolParams', () => {
it('should reject empty plan_path', () => {
const result = tool.validateToolParams({ plan_path: '' });
expect(result).toBe('plan_path is required.');
});
it('should reject whitespace-only plan_path', () => {
const result = tool.validateToolParams({ plan_path: ' ' });
expect(result).toBe('plan_path is required.');
});
it('should reject path outside plans directory', () => {
const result = tool.validateToolParams({
plan_path: '../../../etc/passwd',
});
expect(result).toContain('Access denied');
});
it('should reject non-existent plan file', async () => {
const result = await validatePlanPath(
'plans/ghost.md',
mockPlansDir,
tempRootDir,
);
expect(result).toContain('Plan file does not exist');
});
it('should reject symbolic links pointing outside the plans directory', () => {
const outsideFile = path.join(tempRootDir, 'outside.txt');
fs.writeFileSync(outsideFile, 'secret');
const maliciousPath = path.join(mockPlansDir, 'malicious.md');
fs.symlinkSync(outsideFile, maliciousPath);
const result = tool.validateToolParams({
plan_path: 'plans/malicious.md',
});
expect(result).toBe(
'Access denied: plan path must be within the designated plans directory.',
);
});
it('should accept valid path within plans directory', () => {
createPlanFile('valid.md', '# Content');
const result = tool.validateToolParams({
plan_path: 'plans/valid.md',
});
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,258 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
type ToolResult,
Kind,
type ToolExitPlanModeConfirmationDetails,
type ToolConfirmationPayload,
type ToolExitPlanModeConfirmationPayload,
ToolConfirmationOutcome,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { checkExhaustive } from '../utils/checks.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
/**
* Returns a human-readable description for an approval mode.
*/
function getApprovalModeDescription(mode: ApprovalMode): string {
switch (mode) {
case ApprovalMode.AUTO_EDIT:
return 'Auto-Edit mode (edits will be applied automatically)';
case ApprovalMode.DEFAULT:
return 'Default mode (edits will require confirmation)';
case ApprovalMode.YOLO:
case ApprovalMode.PLAN:
// YOLO and PLAN are not valid modes to enter when exiting plan mode
throw new Error(`Unexpected approval mode: ${mode}`);
default:
checkExhaustive(mode);
}
}
export interface ExitPlanModeParams {
plan_path: string;
}
export class ExitPlanModeTool extends BaseDeclarativeTool<
ExitPlanModeParams,
ToolResult
> {
constructor(
private config: Config,
messageBus: MessageBus,
) {
super(
EXIT_PLAN_MODE_TOOL_NAME,
'Exit Plan Mode',
'Signals that the planning phase is complete and requests user approval to start implementation.',
Kind.Plan,
{
type: 'object',
required: ['plan_path'],
properties: {
plan_path: {
type: 'string',
description:
'The file path to the finalized plan (e.g., "plans/feature-x.md").',
},
},
},
messageBus,
);
}
protected override validateToolParamValues(
params: ExitPlanModeParams,
): string | null {
if (!params.plan_path || params.plan_path.trim() === '') {
return 'plan_path is required.';
}
// Since validateToolParamValues is synchronous, we use a basic synchronous check
// for path traversal safety. High-level async validation is deferred to shouldConfirmExecute.
const plansDir = resolveToRealPath(
this.config.storage.getProjectTempPlansDir(),
);
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.plan_path,
);
const realPath = resolveToRealPath(resolvedPath);
if (!isSubpath(plansDir, realPath)) {
return `Access denied: plan path must be within the designated plans directory.`;
}
return null;
}
protected createInvocation(
params: ExitPlanModeParams,
messageBus: MessageBus,
toolName: string,
toolDisplayName: string,
): ExitPlanModeInvocation {
return new ExitPlanModeInvocation(
params,
messageBus,
toolName,
toolDisplayName,
this.config,
);
}
}
export class ExitPlanModeInvocation extends BaseToolInvocation<
ExitPlanModeParams,
ToolResult
> {
private confirmationOutcome: ToolConfirmationOutcome | null = null;
private approvalPayload: ToolExitPlanModeConfirmationPayload | null = null;
private planValidationError: string | null = null;
constructor(
params: ExitPlanModeParams,
messageBus: MessageBus,
toolName: string,
toolDisplayName: string,
private config: Config,
) {
super(params, messageBus, toolName, toolDisplayName);
}
override async shouldConfirmExecute(
abortSignal: AbortSignal,
): Promise<ToolExitPlanModeConfirmationDetails | false> {
const resolvedPlanPath = this.getResolvedPlanPath();
const pathError = await validatePlanPath(
this.params.plan_path,
this.config.storage.getProjectTempPlansDir(),
this.config.getTargetDir(),
);
if (pathError) {
this.planValidationError = pathError;
return false;
}
const contentError = await validatePlanContent(resolvedPlanPath);
if (contentError) {
this.planValidationError = contentError;
return false;
}
const decision = await this.getMessageBusDecision(abortSignal);
if (decision === 'DENY') {
throw new Error(
`Tool execution for "${
this._toolDisplayName || this._toolName
}" denied by policy.`,
);
}
if (decision === 'ALLOW') {
// If policy is allow, auto-approve with default settings and execute.
this.confirmationOutcome = ToolConfirmationOutcome.ProceedOnce;
this.approvalPayload = {
approved: true,
approvalMode: ApprovalMode.DEFAULT,
};
return false;
}
// decision is 'ASK_USER'
return {
type: 'exit_plan_mode',
title: 'Plan Approval',
planPath: resolvedPlanPath,
onConfirm: async (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
this.confirmationOutcome = outcome;
if (payload && 'approved' in payload) {
this.approvalPayload = payload;
}
},
};
}
getDescription(): string {
return `Requesting plan approval for: ${this.params.plan_path}`;
}
/**
* Returns the resolved plan path.
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
*/
private getResolvedPlanPath(): string {
return path.resolve(this.config.getTargetDir(), this.params.plan_path);
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const resolvedPlanPath = this.getResolvedPlanPath();
if (this.planValidationError) {
return {
llmContent: this.planValidationError,
returnDisplay: 'Error: Invalid plan',
};
}
if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) {
return {
llmContent:
'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.',
returnDisplay: 'Cancelled',
};
}
const payload = this.approvalPayload;
if (payload?.approved) {
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
this.config.setApprovalMode(newMode);
const description = getApprovalModeDescription(newMode);
return {
llmContent: `Plan approved. Switching to ${description}.
The approved implementation plan is stored at: ${resolvedPlanPath}
Read and follow the plan strictly during implementation.`,
returnDisplay: `Plan approved: ${resolvedPlanPath}`,
};
} else {
const feedback = payload?.feedback?.trim();
if (feedback) {
return {
llmContent: `Plan rejected. User feedback: ${feedback}
The plan is stored at: ${resolvedPlanPath}
Revise the plan based on the feedback.`,
returnDisplay: `Feedback: ${feedback}`,
};
} else {
return {
llmContent: `Plan rejected. No feedback provided.
The plan is stored at: ${resolvedPlanPath}
Ask the user for specific feedback on how to improve the plan.`,
returnDisplay: 'Rejected (no feedback)',
};
}
}
}
}

View File

@@ -25,6 +25,7 @@ export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill';
export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
export const ASK_USER_TOOL_NAME = 'ask_user';
export const ASK_USER_DISPLAY_NAME = 'Ask User';
export const EXIT_PLAN_MODE_TOOL_NAME = 'exit_plan_mode';
/**
* Mapping of legacy tool names to their current names.
@@ -92,6 +93,7 @@ export const PLAN_MODE_TOOLS = [
LS_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
] as const;
/**

View File

@@ -18,6 +18,7 @@ import {
type ToolConfirmationResponse,
type Question,
} from '../confirmation-bus/types.js';
import { type ApprovalMode } from '../policy/types.js';
/**
* Represents a validated and ready-to-execute tool call.
@@ -701,9 +702,19 @@ export interface ToolAskUserConfirmationPayload {
answers: { [questionIndex: string]: string };
}
export interface ToolExitPlanModeConfirmationPayload {
/** Whether the user approved the plan */
approved: boolean;
/** If approved, the approval mode to use for implementation */
approvalMode?: ApprovalMode;
/** If rejected, the user's feedback */
feedback?: string;
}
export type ToolConfirmationPayload =
| ToolEditConfirmationPayload
| ToolAskUserConfirmationPayload;
| ToolAskUserConfirmationPayload
| ToolExitPlanModeConfirmationPayload;
export interface ToolExecuteConfirmationDetails {
type: 'exec';
@@ -742,12 +753,23 @@ export interface ToolAskUserConfirmationDetails {
) => Promise<void>;
}
export interface ToolExitPlanModeConfirmationDetails {
type: 'exit_plan_mode';
title: string;
planPath: string;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
export type ToolCallConfirmationDetails =
| ToolEditConfirmationDetails
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails
| ToolInfoConfirmationDetails
| ToolAskUserConfirmationDetails;
| ToolAskUserConfirmationDetails
| ToolExitPlanModeConfirmationDetails;
export enum ToolConfirmationOutcome {
ProceedOnce = 'proceed_once',
@@ -769,6 +791,7 @@ export enum Kind {
Think = 'think',
Fetch = 'fetch',
Communicate = 'communicate',
Plan = 'plan',
Other = 'other',
}