feat(plan): add exit_plan_mode tool with plan approval dialog

Introduce a new tool for exiting plan mode that presents users with a
dedicated approval dialog. Users can review the plan content and choose
to approve (with auto-edit or default approval modes) or provide
feedback for revisions.
This commit is contained in:
Jerop Kipruto
2026-01-29 23:10:58 -05:00
parent 5a4359a9fb
commit d72dce66f4
25 changed files with 1205 additions and 36 deletions

View File

@@ -918,4 +918,172 @@ describe('AskUserDialog', () => {
});
});
});
describe('Question content field', () => {
it('renders content in a choice question', () => {
const questionWithContent: Question[] = [
{
question: 'Approve this plan?',
header: 'Plan',
options: [{ label: 'Yes', description: 'Approve and proceed' }],
content:
'## Plan Details\n\n- Step 1: Do something\n- Step 2: Do another thing',
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders content in a text question', () => {
const questionWithContent: Question[] = [
{
question: 'Enter your feedback:',
header: 'Feedback',
type: QuestionType.TEXT,
content:
'Please review the following changes before providing feedback:\n\n- Changed file A\n- Updated file B',
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
});
it('does not render content when content is not provided', () => {
const questionWithoutContent: Question[] = [
{
question: 'Simple question?',
header: 'Simple',
options: [{ label: 'Yes', description: '' }],
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithoutContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
});
it('uses customOptionPlaceholder for the Other option', () => {
const questionWithCustomPlaceholder: Question[] = [
{
question: 'Approve this?',
header: 'Approve',
options: [{ label: 'Yes', description: '' }],
customOptionPlaceholder: 'Enter your feedback here...',
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithCustomPlaceholder}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long content when availableTerminalHeight is small', async () => {
const longContent = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const questionWithLongContent: Question[] = [
{
question: 'Approve this plan?',
header: 'Plan',
options: [{ label: 'Yes', description: '' }],
content: longContent,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithLongContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={15}
/>,
{
width: 120,
uiState: {
availableTerminalHeight: 15,
},
},
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('does not truncate content when availableTerminalHeight is undefined', () => {
const content = Array.from(
{ length: 10 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const questionWithContent: Question[] = [
{
question: 'Approve this plan?',
header: 'Plan',
options: [{ label: 'Yes', description: '' }],
content,
},
];
const { lastFrame } = renderWithProviders(
<AskUserDialog
questions={questionWithContent}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{
width: 120,
uiState: {
availableTerminalHeight: undefined,
},
},
);
expect(lastFrame()).not.toContain('lines hidden');
expect(lastFrame()).toMatchSnapshot();
});
});
});

View File

@@ -702,10 +702,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = Math.max(1, availableHeight - overhead);
const questionHeight = Math.min(3, Math.max(1, listHeight - 4));
const maxItemsToShow = Math.max(
1,
Math.floor((listHeight - questionHeight) / 2),
);
const maxItemsToShow = question.showAllOptions
? selectionItems.length
: Math.max(1, Math.floor((listHeight - questionHeight) / 2));
return (
<Box flexDirection="column">

View File

@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PlanApprovalDialog } from './PlanApprovalDialog.js';
import * as fs from 'node:fs';
// Mock only the fs.promises.readFile method, keeping the rest of the module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
promises: {
...actual.promises,
readFile: vi.fn(),
},
};
});
// Helper to write to stdin with proper act() wrapping
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
act(() => {
stdin.write(key);
});
};
// Helper to wait for content to be loaded with act()
const waitForContentLoad = async () => {
// Allow the promise to resolve and state to update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
describe('PlanApprovalDialog', () => {
const samplePlanContent = `## Overview
Add user authentication to the CLI application.
## Implementation Steps
1. Create \`src/auth/AuthService.ts\` with login/logout methods
2. Add session storage in \`src/storage/SessionStore.ts\`
3. Update \`src/commands/index.ts\` to check auth status
4. Add tests in \`src/auth/__tests__/\`
## Files to Modify
- \`src/index.ts\` - Add auth middleware
- \`src/config.ts\` - Add auth configuration options`;
beforeEach(() => {
vi.mocked(fs.promises.readFile).mockResolvedValue(samplePlanContent);
});
afterEach(() => {
vi.restoreAllMocks();
});
const defaultProps = {
planPath: '/mock/plans/test-plan.md',
onApprove: vi.fn(),
onFeedback: vi.fn(),
onCancel: vi.fn(),
};
it('renders correctly with plan content', async () => {
const { lastFrame } = renderWithProviders(
<PlanApprovalDialog {...defaultProps} />,
);
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalledWith(
'/mock/plans/test-plan.md',
'utf8',
);
});
expect(lastFrame()).toMatchSnapshot();
});
it('calls onApprove when approve option is selected', async () => {
const onApprove = vi.fn();
const { stdin } = renderWithProviders(
<PlanApprovalDialog {...defaultProps} onApprove={onApprove} />,
);
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(onApprove).toHaveBeenCalled();
});
});
it('calls onFeedback when feedback is typed and submitted', async () => {
const onFeedback = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PlanApprovalDialog {...defaultProps} onFeedback={onFeedback} />,
);
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
// Navigate past the approve option to the feedback input
writeKey(stdin, '\x1b[B'); // Down arrow
for (const char of 'Add tests') {
writeKey(stdin, char);
}
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
writeKey(stdin, '\r');
await waitFor(() => {
expect(onFeedback).toHaveBeenCalledWith('Add tests');
});
});
it('calls onCancel when Esc is pressed', async () => {
const onCancel = vi.fn();
const { stdin } = renderWithProviders(
<PlanApprovalDialog {...defaultProps} onCancel={onCancel} />,
);
await waitForContentLoad();
await waitFor(() => {
expect(fs.promises.readFile).toHaveBeenCalled();
});
writeKey(stdin, '\x1b'); // Escape
await waitFor(() => {
expect(onCancel).toHaveBeenCalled();
});
});
it('handles file read error gracefully', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
const { lastFrame } = renderWithProviders(
<PlanApprovalDialog {...defaultProps} />,
);
await waitForContentLoad();
await waitFor(() => {
expect(lastFrame()).toContain('Error reading plan file');
});
});
});

View File

@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState, useEffect, useContext } from 'react';
import {
type Question,
QuestionType,
ApprovalMode,
} from '@google/gemini-cli-core';
import { AskUserDialog } from './AskUserDialog.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import * as fs from 'node:fs';
interface PlanApprovalDialogProps {
planPath: string;
/** Called when user approves the plan with the selected approval mode. */
onApprove: (approvalMode: ApprovalMode) => void;
onFeedback: (feedback: string) => void;
onCancel: () => void;
}
const APPROVE_AUTO_EDIT = 'Yes, automatically accept edits';
const APPROVE_DEFAULT = 'Yes, manually accept edits';
export const PlanApprovalDialog: React.FC<PlanApprovalDialogProps> = ({
planPath,
onApprove,
onFeedback,
onCancel,
}) => {
const uiState = useContext(UIStateContext);
const [planContent, setPlanContent] = useState<string | undefined>(undefined);
useEffect(() => {
let ignore = false;
fs.promises
.readFile(planPath, 'utf8')
.then((content) => {
if (ignore) return;
setPlanContent(content);
})
.catch((err) => {
if (ignore) return;
setPlanContent(`Error reading plan file: ${err.message}`);
});
return () => {
ignore = true;
};
}, [planPath]);
const questions = useMemo(
(): Question[] => [
{
question: 'Ready to start implementation?',
header: 'Plan',
type: QuestionType.CHOICE,
options: [
{
label: APPROVE_AUTO_EDIT,
description: 'Edits will be applied without confirmation',
},
{
label: APPROVE_DEFAULT,
description: 'You will be asked to confirm each edit',
},
],
content: planContent,
customOptionPlaceholder: 'Provide feedback...',
showAllOptions: true,
},
],
[planContent],
);
const handleSubmit = useCallback(
(answers: { [questionIndex: string]: string }) => {
const answer = answers['0'];
if (answer === APPROVE_AUTO_EDIT) {
onApprove(ApprovalMode.AUTO_EDIT);
} else if (answer === APPROVE_DEFAULT) {
onApprove(ApprovalMode.DEFAULT);
} else if (answer) {
onFeedback(answer);
}
},
[onApprove, onFeedback],
);
const width = uiState?.mainAreaWidth ?? 80;
const availableHeight = uiState?.availableTerminalHeight ?? 20;
return (
<AskUserDialog
questions={questions}
onSubmit={handleSubmit}
onCancel={onCancel}
width={width}
availableHeight={availableHeight}
/>
);
};

View File

@@ -25,6 +25,7 @@ function getConfirmationHeader(
Record<SerializableConfirmationDetails['type'], string>
> = {
ask_user: 'Answer Questions',
plan_approval: 'Plan Approval',
};
if (!details?.type) {
return 'Action Required';
@@ -70,7 +71,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
: undefined;
const borderColor = theme.status.warning;
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
const hideToolIdentity =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'plan_approval';
return (
<OverflowProvider>

View File

@@ -1,5 +1,106 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Question content field > does not render content when content is not provided 1`] = `
"Simple question?
● 1. Yes
2. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Question content field > does not truncate content when availableTerminalHeight is undefined 1`] = `
"Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
Approve this plan?
● 1. Yes
2. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Question content field > renders content in a choice question 1`] = `
"Plan Details
- Step 1: Do something
- Step 2: Do another thing
Approve this plan?
● 1. Yes
Approve and proceed
2. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Question content field > renders content in a text question 1`] = `
"Please review the following changes before providing feedback:
- Changed file A
- Updated file B
Enter your feedback:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Question content field > truncates long content when availableTerminalHeight is small 1`] = `
"... first 28 lines hidden ...
Line 29
Line 30
Approve this plan?
● 1. Yes
2. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Question content field > truncates long content when availableTerminalHeight is small 2`] = `
"Approve this plan?
● 1. Yes
2. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Question content field > uses customOptionPlaceholder for the Other option 1`] = `
"Approve this?
● 1. Yes
2. Enter your feedback here...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component?

View File

@@ -0,0 +1,69 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PlanApprovalDialog > calls onFeedback when feedback is typed and submitted 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
Ready to start implementation?
1. Yes, start implementation
● 2. Add tests ✓
Enter to submit · Esc to cancel"
`;
exports[`PlanApprovalDialog > calls onFeedback when feedback is typed and submitted 2`] = `
"Ready to start implementation?
1. Yes, automatically accept edits
Edits will be applied without confirmation
2. Yes, manually accept edits
You will be asked to confirm each edit
● 3. Add tests ✓
Enter to submit · Esc to cancel"
`;
exports[`PlanApprovalDialog > renders correctly with plan content 1`] = `
"Overview
Add user authentication to the CLI application.
Implementation Steps
1. Create src/auth/AuthService.ts with login/logout methods
2. Add session storage in src/storage/SessionStore.ts
3. Update src/commands/index.ts to check auth status
4. Add tests in src/auth/__tests__/
Files to Modify
- src/index.ts - Add auth middleware
- src/config.ts - Add auth configuration options
Ready to start implementation?
● 1. Yes, start implementation
2. Provide feedback...
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;

View File

@@ -10,6 +10,7 @@ import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import {
type ApprovalMode,
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
@@ -33,6 +34,7 @@ import {
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { PlanApprovalDialog } from '../PlanApprovalDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -61,13 +63,22 @@ export const ToolConfirmationMessage: React.FC<
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const handlesOwnUI = confirmationDetails.type === 'ask_user';
const handlesOwnUI =
confirmationDetails.type === 'ask_user' ||
confirmationDetails.type === 'plan_approval';
const isTrustedFolder = config.isTrustedFolder();
const handleConfirm = useCallback(
(
outcome: ToolConfirmationOutcome,
payload?: { answers?: { [questionIndex: string]: string } },
payload?:
| { type: 'ask_user'; answers: { [questionIndex: string]: string } }
| {
type: 'plan_approval';
approved: boolean;
approvalMode?: ApprovalMode;
feedback?: string;
},
) => {
void confirm(callId, outcome, payload).catch((error: unknown) => {
debugLogger.error(
@@ -267,7 +278,10 @@ export const ToolConfirmationMessage: React.FC<
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'ask_user',
answers,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
@@ -279,6 +293,32 @@ export const ToolConfirmationMessage: React.FC<
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'plan_approval') {
bodyContent = (
<PlanApprovalDialog
planPath={confirmationDetails.planPath}
onApprove={(approvalMode: ApprovalMode) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'plan_approval',
approved: true,
approvalMode,
});
}}
onFeedback={(feedback: string) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'plan_approval',
approved: false,
feedback,
});
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
/>
);
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;

View File

@@ -8,10 +8,10 @@ import { createContext, useContext } from 'react';
import { type Key } from '../hooks/useKeypress.js';
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
type AuthType,
type EditorType,
type AgentDefinition,
import type {
AuthType,
EditorType,
AgentDefinition,
} from '@google/gemini-cli-core';
import { type LoadableSettingScope } from '../../config/settings.js';
import type { AuthState } from '../types.js';

View File

@@ -232,7 +232,7 @@ describe('useToolExecutionScheduler', () => {
call.confirmationDetails as ToolCallConfirmationDetails;
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
const mockPayload = { newContent: 'updated code' };
const mockPayload = { type: 'edit' as const, newContent: 'updated code' };
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
mockPayload,

View File

@@ -986,6 +986,9 @@ function toPermissionOptions(
case 'ask_user':
// askuser doesn't need "always allow" options since it's asking questions
return [...basicPermissionOptions];
case 'plan_approval':
// plan_approval doesn't need "always allow" options since it's a one-time approval
return [...basicPermissionOptions];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);

View File

@@ -100,6 +100,11 @@ export type SerializableConfirmationDetails =
type: 'ask_user';
title: string;
questions: Question[];
}
| {
type: 'plan_approval';
title: string;
planPath: string;
};
export interface UpdatePolicy {
@@ -150,6 +155,12 @@ export interface Question {
multiSelect?: boolean;
/** Placeholder hint text. Only applies when type='text'. */
placeholder?: string;
/** Markdown content to display before the question. */
content?: string;
/** Placeholder for the custom "Other" option input. Only applies when type='choice'. Defaults to 'Enter a custom value'. */
customOptionPlaceholder?: string;
/** When true, all options are shown without scrolling. Only applies when type='choice'. */
showAllOptions?: boolean;
}
export interface AskUserRequest {

View File

@@ -752,7 +752,10 @@ describe('CoreToolScheduler with payload', () => {
const confirmationDetails = awaitingCall.confirmationDetails;
if (confirmationDetails) {
const payload: ToolConfirmationPayload = { newContent: 'final version' };
const payload: ToolConfirmationPayload = {
type: 'edit',
newContent: 'final version',
};
await (confirmationDetails as ToolCallConfirmationDetails).onConfirm(
ToolConfirmationOutcome.ProceedOnce,
payload,

View File

@@ -789,7 +789,7 @@ export class CoreToolScheduler {
} else {
// If the client provided new content, apply it and wait for
// re-confirmation.
if (payload?.newContent && toolCall) {
if (payload?.type === 'edit' && payload.newContent && toolCall) {
const result = await this.toolModifier.applyInlineModify(
toolCall as WaitingToolCall,
payload,

View File

@@ -70,6 +70,12 @@ decision = "ask_user"
priority = 50
modes = ["plan"]
[[rule]]
toolName = "exit_plan_mode"
decision = "ask_user"
priority = 50
modes = ["plan"]
# Allow write_file for .md files in plans directory
[[rule]]
toolName = "write_file"

View File

@@ -367,7 +367,7 @@ describe('confirmation.ts', () => {
correlationId: '123e4567-e89b-12d3-a456-426614174000',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce, // Ignored if payload present
payload: { newContent: 'inline' },
payload: { type: 'edit', newContent: 'inline' },
});
mockModifier.applyInlineModify.mockResolvedValue({

View File

@@ -16,6 +16,7 @@ import {
ToolConfirmationOutcome,
type ToolConfirmationPayload,
type ToolCallConfirmationDetails,
type EditConfirmationPayload,
} from '../tools/tools.js';
import type { ValidatingToolCall, WaitingToolCall } from './types.js';
import type { Config } from '../config/config.js';
@@ -156,7 +157,10 @@ export async function resolveConfirmation(
if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
await handleExternalModification(deps, toolCall, signal);
} else if (response.payload?.newContent) {
} else if (
response.payload?.type === 'edit' &&
response.payload.newContent
) {
await handleInlineModification(deps, toolCall, response.payload, signal);
outcome = ToolConfirmationOutcome.ProceedOnce;
}
@@ -219,7 +223,7 @@ async function handleExternalModification(
async function handleInlineModification(
deps: { state: SchedulerStateManager; modifier: ToolModificationHandler },
toolCall: ValidatingToolCall,
payload: ToolConfirmationPayload,
payload: EditConfirmationPayload,
signal: AbortSignal,
): Promise<void> {
const { state, modifier } = deps;
@@ -277,7 +281,7 @@ async function waitForConfirmation(
? ToolConfirmationOutcome.ProceedOnce
: ToolConfirmationOutcome.Cancel,
payload: resolution.content
? { newContent: resolution.content }
? ({ type: 'edit', newContent: resolution.content } as const)
: undefined,
}) as ConfirmationResult,
)

View File

@@ -13,7 +13,7 @@ import { MockModifiableTool, MockTool } from '../test-utils/mock-tool.js';
import type {
ToolResult,
ToolInvocation,
ToolConfirmationPayload,
EditConfirmationPayload,
} from '../tools/tools.js';
import type { ModifyContext } from '../tools/modifiable-tool.js';
import type { Mock } from 'vitest';
@@ -175,7 +175,7 @@ describe('ToolModificationHandler', () => {
const result = await handler.applyInlineModify(
mockWaitingToolCall,
{ newContent: 'foo' },
{ type: 'edit', newContent: 'foo' },
new AbortController().signal,
);
@@ -193,7 +193,10 @@ describe('ToolModificationHandler', () => {
const result = await handler.applyInlineModify(
mockWaitingToolCall,
{ newContent: undefined } as unknown as ToolConfirmationPayload,
{
type: 'edit',
newContent: undefined,
} as unknown as EditConfirmationPayload,
new AbortController().signal,
);
@@ -225,7 +228,7 @@ describe('ToolModificationHandler', () => {
const result = await handler.applyInlineModify(
mockWaitingToolCall,
{ newContent: 'new content' },
{ type: 'edit', newContent: 'new content' },
new AbortController().signal,
);

View File

@@ -11,7 +11,7 @@ import {
modifyWithEditor,
type ModifyContext,
} from '../tools/modifiable-tool.js';
import type { ToolConfirmationPayload } from '../tools/tools.js';
import type { EditConfirmationPayload } from '../tools/tools.js';
import type { WaitingToolCall } from './types.js';
export interface ModificationResult {
@@ -65,7 +65,7 @@ export class ToolModificationHandler {
*/
async applyInlineModify(
toolCall: WaitingToolCall,
payload: ToolConfirmationPayload,
payload: EditConfirmationPayload,
signal: AbortSignal,
): Promise<ModificationResult | undefined> {
if (

View File

@@ -307,6 +307,7 @@ describe('AskUserTool', () => {
if (details && 'onConfirm' in details) {
const answers = { '0': 'Quick fix (Recommended)' };
await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'ask_user',
answers,
});
}
@@ -341,6 +342,7 @@ describe('AskUserTool', () => {
// Simulate confirmation with empty answers
if (details && 'onConfirm' in details) {
await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'ask_user',
answers: {},
});
}

View File

@@ -180,7 +180,7 @@ export class AskUserInvocation extends BaseToolInvocation<
payload?: ToolConfirmationPayload,
) => {
this.confirmationOutcome = outcome;
if (payload?.answers) {
if (payload?.type === 'ask_user' && payload.answers) {
this.userAnswers = payload.answers;
}
},

View File

@@ -0,0 +1,221 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExitPlanModeTool } 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';
describe('ExitPlanModeTool', () => {
let tool: ExitPlanModeTool;
let mockMessageBus: ReturnType<typeof createMockMessageBus>;
let mockConfig: Partial<Config>;
const mockTargetDir = path.resolve('/mock/dir');
const mockPlansDir = path.resolve('/mock/dir/plans');
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
mockMessageBus = createMockMessageBus();
vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined);
mockConfig = {
getTargetDir: vi.fn().mockReturnValue(mockTargetDir),
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,
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('shouldConfirmExecute', () => {
it('should return plan approval confirmation details', async () => {
const planPath = 'plans/test-plan.md';
const invocation = tool.build({ plan_path: planPath });
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).not.toBe(false);
if (result === false) return;
expect(result.type).toBe('plan_approval');
expect(result.title).toBe('Plan Approval');
if (result.type === 'plan_approval') {
expect(result.planPath).toBe(
path.resolve(mockPlansDir, 'test-plan.md'),
);
}
expect(typeof result.onConfirm).toBe('function');
});
it('should return false if plan path is invalid', async () => {
// Create a tool with a plan path that resolves outside the plans directory
const invocation = tool.build({ plan_path: 'plans/valid.md' });
// Override getProjectTempPlansDir to make the validation fail
vi.mocked(mockConfig.storage!.getProjectTempPlansDir).mockReturnValue(
'/completely/different/path',
);
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).toBe(false);
});
});
describe('execute', () => {
it('should return approval message when plan is approved', async () => {
const planPath = 'plans/test-plan.md';
const invocation = tool.build({ plan_path: planPath });
// Simulate the confirmation flow
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
// Call onConfirm with approval (using default approval mode)
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'plan_approval',
approved: true,
approvalMode: ApprovalMode.DEFAULT,
});
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.resolve(mockPlansDir, 'test-plan.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 feedback message when plan is rejected', async () => {
const planPath = 'plans/test-plan.md';
const invocation = tool.build({ plan_path: planPath });
// Simulate the confirmation flow
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
// Call onConfirm with rejection and feedback
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
type: 'plan_approval',
approved: false,
feedback: 'Please add more details.',
});
const result = await invocation.execute(new AbortController().signal);
const expectedPath = path.resolve(mockPlansDir, 'test-plan.md');
expect(result).toEqual({
llmContent: `Plan rejected. 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 return cancellation message when cancelled', async () => {
const planPath = 'plans/test-plan.md';
const invocation = tool.build({ plan_path: planPath });
// Simulate the confirmation flow
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmDetails).not.toBe(false);
if (confirmDetails === false) return;
// Call onConfirm with cancellation
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',
});
});
it('should return error for invalid plan path', async () => {
const planPath = 'plans/test-plan.md';
const invocation = tool.build({ plan_path: planPath });
// Override getProjectTempPlansDir to make the validation fail
vi.mocked(mockConfig.storage!.getProjectTempPlansDir).mockReturnValue(
'/completely/different/path',
);
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({
llmContent:
'Error: Plan path is outside the designated plans directory.',
returnDisplay:
'Error: Plan path is outside the designated plans directory.',
});
});
});
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 accept valid path within plans directory', () => {
const result = tool.validateToolParams({
plan_path: 'plans/valid-plan.md',
});
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
type ToolResult,
Kind,
type ToolPlanApprovalConfirmationDetails,
type ToolConfirmationPayload,
type PlanApprovalConfirmationPayload,
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 { isWithinRoot } from '../utils/fileUtils.js';
import { ApprovalMode } from '../policy/types.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:
default:
return 'Default mode (edits will require confirmation)';
}
}
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.';
}
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.plan_path,
);
const plansDir = this.config.storage.getProjectTempPlansDir();
if (!isWithinRoot(resolvedPath, plansDir)) {
return `Access denied: plan path must be within the designated plans directory (${plansDir}).`;
}
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: PlanApprovalConfirmationPayload | 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<ToolPlanApprovalConfirmationDetails | false> {
const resolvedPlanPath = this.getValidatedPlanPath();
if (!resolvedPlanPath) {
return false;
}
return {
type: 'plan_approval',
title: 'Plan Approval',
planPath: resolvedPlanPath,
onConfirm: async (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
this.confirmationOutcome = outcome;
if (payload?.type === 'plan_approval') {
this.approvalPayload = payload;
}
},
};
}
getDescription(): string {
return `Requesting plan approval for: ${this.params.plan_path}`;
}
/**
* Returns the resolved plan path if valid, or null if outside the plans directory.
*/
private getValidatedPlanPath(): string | null {
const plansDir = this.config.storage.getProjectTempPlansDir();
const resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.plan_path,
);
return isWithinRoot(resolvedPath, plansDir) ? resolvedPath : null;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const resolvedPlanPath = this.getValidatedPlanPath();
if (!resolvedPlanPath) {
return {
llmContent:
'Error: Plan path is outside the designated plans directory.',
returnDisplay:
'Error: Plan path is outside the designated plans directory.',
};
}
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) {
// Set the approval mode based on user's choice
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 || 'None';
return {
llmContent: `Plan rejected. Feedback: ${feedback}
The plan is stored at: ${resolvedPlanPath}
Revise the plan based on the feedback.`,
returnDisplay: `Feedback: ${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';
/** Prefix used for tools discovered via the toolDiscoveryCommand. */
export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';

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.
@@ -688,18 +689,47 @@ export interface ToolEditConfirmationDetails {
ideConfirmation?: Promise<DiffUpdateResult>;
}
export interface ToolConfirmationPayload {
// used to override `modifiedProposedContent` for modifiable tools in the
// inline modify flow
newContent?: string;
// used for askuser tool to return user's answers
answers?: { [questionIndex: string]: string };
/**
* Discriminated union for tool confirmation payloads.
* Each tool type has its own payload shape.
*/
export type ToolConfirmationPayload =
| EditConfirmationPayload
| AskUserConfirmationPayload
| PlanApprovalConfirmationPayload;
/** Payload for edit tool confirmations (inline modify flow) */
export interface EditConfirmationPayload {
type: 'edit';
/** Modified content from inline editor */
newContent: string;
}
/** Payload for ask_user tool confirmations */
export interface AskUserConfirmationPayload {
type: 'ask_user';
/** User's answers keyed by question index */
answers: { [questionIndex: string]: string };
}
/** Payload for plan approval confirmations */
export interface PlanApprovalConfirmationPayload {
type: 'plan_approval';
/** 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 interface ToolExecuteConfirmationDetails {
type: 'exec';
title: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
command: string;
rootCommand: string;
rootCommands: string[];
@@ -712,13 +742,19 @@ export interface ToolMcpConfirmationDetails {
serverName: string;
toolName: string;
toolDisplayName: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
export interface ToolInfoConfirmationDetails {
type: 'info';
title: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
prompt: string;
urls?: string[];
}
@@ -733,12 +769,23 @@ export interface ToolAskUserConfirmationDetails {
) => Promise<void>;
}
export interface ToolPlanApprovalConfirmationDetails {
type: 'plan_approval';
title: string;
planPath: string;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
export type ToolCallConfirmationDetails =
| ToolEditConfirmationDetails
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails
| ToolInfoConfirmationDetails
| ToolAskUserConfirmationDetails;
| ToolAskUserConfirmationDetails
| ToolPlanApprovalConfirmationDetails;
export enum ToolConfirmationOutcome {
ProceedOnce = 'proceed_once',
@@ -760,6 +807,7 @@ export enum Kind {
Think = 'think',
Fetch = 'fetch',
Communicate = 'communicate',
Plan = 'plan',
Other = 'other',
}