feat: auto-approve pending tool calls when auto_edit/yolo is activated (#6665)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
This commit is contained in:
Arya Gummadi
2025-09-14 20:20:21 -07:00
committed by GitHub
parent 00ecfdeb06
commit 1145f25ee3
5 changed files with 649 additions and 24 deletions

View File

@@ -163,12 +163,6 @@ export const AppContainer = (props: AppContainerProps) => {
const [isConfigInitialized, setConfigInitialized] = useState(false);
// Auto-accept indicator
const showAutoAcceptIndicator = useAutoAcceptIndicator({
config,
addItem: historyManager.addItem,
});
const logger = useLogger(config.storage);
const [userMessages, setUserMessages] = useState<string[]>([]);
@@ -536,6 +530,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
} = useGeminiStream(
@@ -560,6 +555,13 @@ Logging in with Google... Please restart Gemini CLI to continue.
shellFocused,
);
// Auto-accept indicator
const showAutoAcceptIndicator = useAutoAcceptIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
});
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
useMessageQueue({
isConfigInitialized,

View File

@@ -16,8 +16,8 @@ import {
import { renderHook, act } from '@testing-library/react';
import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js';
import type { Config as ActualConfigType } from '@google/gemini-cli-core';
import { Config, ApprovalMode } from '@google/gemini-cli-core';
import type { Config as ActualConfigType } from '@google/gemini-cli-core';
import type { Key } from './useKeypress.js';
import { useKeypress } from './useKeypress.js';
import { MessageType } from '../types.js';
@@ -470,4 +470,124 @@ describe('useAutoAcceptIndicator', () => {
expect(mockAddItem).toHaveBeenCalledTimes(2);
});
});
it('should call onApprovalModeChange when switching to YOLO mode', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(mockOnApprovalModeChange).toHaveBeenCalledWith(ApprovalMode.YOLO);
});
it('should call onApprovalModeChange when switching to AUTO_EDIT mode', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockOnApprovalModeChange).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
});
it('should call onApprovalModeChange when switching to DEFAULT mode', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); // This should toggle from YOLO to DEFAULT
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(mockOnApprovalModeChange).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
});
it('should not call onApprovalModeChange when callback is not provided', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
}),
);
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
// Should not throw an error when callback is not provided
});
it('should handle multiple mode changes correctly', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const mockOnApprovalModeChange = vi.fn();
renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
onApprovalModeChange: mockOnApprovalModeChange,
}),
);
// Switch to YOLO
act(() => {
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
// Switch to AUTO_EDIT
act(() => {
capturedUseKeypressHandler({ name: 'tab', shift: true } as Key);
});
expect(mockOnApprovalModeChange).toHaveBeenCalledTimes(2);
expect(mockOnApprovalModeChange).toHaveBeenNthCalledWith(
1,
ApprovalMode.YOLO,
);
expect(mockOnApprovalModeChange).toHaveBeenNthCalledWith(
2,
ApprovalMode.AUTO_EDIT,
);
});
});

View File

@@ -12,12 +12,14 @@ import { MessageType } from '../types.js';
export interface UseAutoAcceptIndicatorArgs {
config: Config;
addItem: (item: HistoryItemWithoutId, timestamp: number) => void;
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
onApprovalModeChange?: (mode: ApprovalMode) => void;
}
export function useAutoAcceptIndicator({
config,
addItem,
onApprovalModeChange,
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
const currentConfigValue = config.getApprovalMode();
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
@@ -48,14 +50,19 @@ export function useAutoAcceptIndicator({
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
// Notify the central handler about the approval mode change
onApprovalModeChange?.(nextApprovalMode);
} catch (e) {
addItem(
{
type: MessageType.INFO,
text: (e as Error).message,
},
Date.now(),
);
if (addItem) {
addItem(
{
type: MessageType.INFO,
text: (e as Error).message,
},
Date.now(),
);
}
}
}
},

View File

@@ -16,6 +16,7 @@ import type {
TrackedCompletedToolCall,
TrackedExecutingToolCall,
TrackedCancelledToolCall,
TrackedWaitingToolCall,
} from './useReactToolScheduler.js';
import { useReactToolScheduler } from './useReactToolScheduler.js';
import type {
@@ -29,6 +30,7 @@ import {
AuthType,
GeminiEventType as ServerGeminiEventType,
ToolErrorType,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -1340,6 +1342,458 @@ describe('useGeminiStream', () => {
});
});
describe('handleApprovalModeChange', () => {
it('should auto-approve all pending tool calls when switching to YOLO mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirm,
onCancel: vi.fn(),
message: 'Replace text?',
displayedText: 'Replace old with new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'read_file',
args: { path: '/test/file.txt' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirm,
onCancel: vi.fn(),
message: 'Read file?',
displayedText: 'Read /test/file.txt',
},
tool: {
name: 'read_file',
displayName: 'read_file',
description: 'Read file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Both tool calls should be auto-approved
expect(mockOnConfirm).toHaveBeenCalledTimes(2);
expect(mockOnConfirm).toHaveBeenNthCalledWith(
1,
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockOnConfirm).toHaveBeenNthCalledWith(
2,
ToolConfirmationOutcome.ProceedOnce,
);
});
it('should only auto-approve edit tools when switching to AUTO_EDIT mode', async () => {
const mockOnConfirmReplace = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmWrite = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmRead = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmReplace,
onCancel: vi.fn(),
message: 'Replace text?',
displayedText: 'Replace old with new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'write_file',
args: { path: '/test/new.txt', content: 'content' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmWrite,
onCancel: vi.fn(),
message: 'Write file?',
displayedText: 'Write to /test/new.txt',
},
tool: {
name: 'write_file',
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call3',
name: 'read_file',
args: { path: '/test/file.txt' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmRead,
onCancel: vi.fn(),
message: 'Read file?',
displayedText: 'Read /test/file.txt',
},
tool: {
name: 'read_file',
displayName: 'read_file',
description: 'Read file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.AUTO_EDIT);
});
// Only replace and write_file should be auto-approved
expect(mockOnConfirmReplace).toHaveBeenCalledTimes(1);
expect(mockOnConfirmReplace).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockOnConfirmWrite).toHaveBeenCalledTimes(1);
expect(mockOnConfirmWrite).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
// read_file should not be auto-approved
expect(mockOnConfirmRead).not.toHaveBeenCalled();
});
it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirm,
onCancel: vi.fn(),
message: 'Replace text?',
displayedText: 'Replace old with new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
await act(async () => {
await result.current.handleApprovalModeChange(
ApprovalMode.REQUIRE_CONFIRMATION,
);
});
// No tools should be auto-approved
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('should handle errors gracefully when auto-approving tool calls', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const mockOnConfirmSuccess = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmError = vi
.fn()
.mockRejectedValue(new Error('Approval failed'));
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmSuccess,
onCancel: vi.fn(),
message: 'Replace text?',
displayedText: 'Replace old with new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'write_file',
args: { path: '/test/file.txt', content: 'content' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmError,
onCancel: vi.fn(),
message: 'Write file?',
displayedText: 'Write to /test/file.txt',
},
tool: {
name: 'write_file',
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Both confirmation methods should be called
expect(mockOnConfirmSuccess).toHaveBeenCalledTimes(1);
expect(mockOnConfirmError).toHaveBeenCalledTimes(1);
// Error should be logged
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to auto-approve tool call call2:',
expect.any(Error),
);
consoleSpy.mockRestore();
});
it('should skip tool calls without confirmationDetails', async () => {
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
// No confirmationDetails
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
// Should not throw an error
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
});
it('should skip tool calls without onConfirm method in confirmationDetails', async () => {
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onCancel: vi.fn(),
message: 'Replace text?',
displayedText: 'Replace old with new',
// No onConfirm method
} as any,
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
// Should not throw an error
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
});
it('should only process tool calls with awaiting_approval status', async () => {
const mockOnConfirmAwaiting = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmExecuting = vi.fn().mockResolvedValue(undefined);
const mixedStatusToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmAwaiting,
onCancel: vi.fn(),
message: 'Replace text?',
displayedText: 'Replace old with new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
{
request: {
callId: 'call2',
name: 'write_file',
args: { path: '/test/file.txt', content: 'content' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'executing',
responseSubmittedToGemini: false,
confirmationDetails: {
onConfirm: mockOnConfirmExecuting,
onCancel: vi.fn(),
message: 'Write file?',
displayedText: 'Write to /test/file.txt',
},
tool: {
name: 'write_file',
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
startTime: Date.now(),
liveOutput: 'Writing...',
} as TrackedExecutingToolCall,
];
const { result } = renderTestHook(mixedStatusToolCalls);
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Only the awaiting_approval tool should be processed
expect(mockOnConfirmAwaiting).toHaveBeenCalledTimes(1);
expect(mockOnConfirmExecuting).not.toHaveBeenCalled();
});
});
describe('handleFinishedEvent', () => {
it('should add info message for MAX_TOKENS finish reason', async () => {
// Setup mock to return a stream with MAX_TOKENS finish reason

View File

@@ -31,6 +31,7 @@ import {
ConversationFinishedEvent,
ApprovalMode,
parseAndFormatApiError,
ToolConfirmationOutcome,
getCodeAssistServer,
UserTierId,
promptIdContext,
@@ -50,17 +51,16 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
import type {
TrackedToolCall,
TrackedCompletedToolCall,
TrackedCancelledToolCall,
} from './useReactToolScheduler.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import {
useReactToolScheduler,
mapToDisplay as mapTrackedToolCallsToDisplay,
type TrackedToolCall,
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedWaitingToolCall,
} from './useReactToolScheduler.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
@@ -71,6 +71,8 @@ enum StreamProcessingStatus {
Error,
}
const EDIT_TOOL_NAMES = new Set(['replace', 'write_file']);
function showCitations(settings: LoadedSettings, config: Config): boolean {
const enabled = settings?.merged?.ui?.showCitations;
if (enabled !== undefined) {
@@ -847,6 +849,45 @@ export const useGeminiStream = (
],
);
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
// Auto-approve pending tool calls when switching to auto-approval modes
if (
newApprovalMode === ApprovalMode.YOLO ||
newApprovalMode === ApprovalMode.AUTO_EDIT
) {
let awaitingApprovalCalls = toolCalls.filter(
(call): call is TrackedWaitingToolCall =>
call.status === 'awaiting_approval',
);
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
EDIT_TOOL_NAMES.has(call.request.name),
);
}
// Process pending tool calls sequentially to reduce UI chaos
for (const call of awaitingApprovalCalls) {
if (call.confirmationDetails?.onConfirm) {
try {
await call.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
} catch (error) {
console.error(
`Failed to auto-approve tool call ${call.request.callId}:`,
error,
);
}
}
}
}
},
[toolCalls],
);
const handleCompletedTools = useCallback(
async (completedToolCallsFromScheduler: TrackedToolCall[]) => {
if (isResponding) {
@@ -981,8 +1022,7 @@ export const useGeminiStream = (
}
const restorableToolCalls = toolCalls.filter(
(toolCall) =>
(toolCall.request.name === 'replace' ||
toolCall.request.name === 'write_file') &&
EDIT_TOOL_NAMES.has(toolCall.request.name) &&
toolCall.status === 'awaiting_approval',
);
@@ -1101,6 +1141,8 @@ export const useGeminiStream = (
pendingHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls: toolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
};