feat: Add --yolo mode that automatically accepts all tools executions (#695)

Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
Tolik Malibroda 2025-06-02 22:05:45 +02:00 committed by GitHub
parent 42bedbc3d3
commit 0795e55f0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 364 additions and 156 deletions

View File

@ -15,6 +15,7 @@ import {
ConfigParameters, ConfigParameters,
setGeminiMdFilename as setServerGeminiMdFilename, setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename, getCurrentGeminiMdFilename,
ApprovalMode,
} from '@gemini-code/core'; } from '@gemini-code/core';
import { Settings } from './settings.js'; import { Settings } from './settings.js';
import { readPackageUp } from 'read-package-up'; import { readPackageUp } from 'read-package-up';
@ -38,6 +39,7 @@ interface CliArgs {
prompt: string | undefined; prompt: string | undefined;
all_files: boolean | undefined; all_files: boolean | undefined;
show_memory_usage: boolean | undefined; show_memory_usage: boolean | undefined;
yolo: boolean | undefined;
} }
async function parseArguments(): Promise<CliArgs> { async function parseArguments(): Promise<CliArgs> {
@ -75,6 +77,13 @@ async function parseArguments(): Promise<CliArgs> {
description: 'Show memory usage in status bar', description: 'Show memory usage in status bar',
default: false, default: false,
}) })
.option('yolo', {
alias: 'y',
type: 'boolean',
description:
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.version() // This will enable the --version flag based on package.json .version() // This will enable the --version flag based on package.json
.help() .help()
.alias('h', 'help') .alias('h', 'help')
@ -158,7 +167,7 @@ export async function loadCliConfig(settings: Settings): Promise<Config> {
const configParams: ConfigParameters = { const configParams: ConfigParameters = {
apiKey: apiKeyForServer, apiKey: apiKeyForServer,
model: argv.model || DEFAULT_GEMINI_MODEL, model: argv.model || DEFAULT_GEMINI_MODEL,
sandbox: argv.sandbox ?? settings.sandbox ?? false, sandbox: argv.sandbox ?? settings.sandbox ?? argv.yolo ?? false,
targetDir: process.cwd(), targetDir: process.cwd(),
debugMode, debugMode,
question: argv.prompt || '', question: argv.prompt || '',
@ -171,6 +180,7 @@ export async function loadCliConfig(settings: Settings): Promise<Config> {
userAgent, userAgent,
userMemory: memoryContent, userMemory: memoryContent,
geminiMdFileCount: fileCount, geminiMdFileCount: fileCount,
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
vertexai: useVertexAI, vertexai: useVertexAI,
showMemoryUsage: showMemoryUsage:
argv.show_memory_usage || settings.showMemoryUsage || false, argv.show_memory_usage || settings.showMemoryUsage || false,

View File

@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { App } from './App.js'; import { App } from './App.js';
import { Config as ServerConfig, MCPServerConfig } from '@gemini-code/core'; import { Config as ServerConfig, MCPServerConfig } from '@gemini-code/core';
import type { ToolRegistry } from '@gemini-code/core'; import { ApprovalMode, ToolRegistry } from '@gemini-code/core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
// Define a more complete mock server config based on actual Config // Define a more complete mock server config based on actual Config
@ -28,7 +28,7 @@ interface MockServerConfig {
userAgent: string; userAgent: string;
userMemory: string; userMemory: string;
geminiMdFileCount: number; geminiMdFileCount: number;
alwaysSkipModificationConfirmation: boolean; approvalMode: ApprovalMode;
vertexai?: boolean; vertexai?: boolean;
showMemoryUsage?: boolean; showMemoryUsage?: boolean;
@ -50,8 +50,8 @@ interface MockServerConfig {
setUserMemory: Mock<(newUserMemory: string) => void>; setUserMemory: Mock<(newUserMemory: string) => void>;
getGeminiMdFileCount: Mock<() => number>; getGeminiMdFileCount: Mock<() => number>;
setGeminiMdFileCount: Mock<(count: number) => void>; setGeminiMdFileCount: Mock<(count: number) => void>;
getAlwaysSkipModificationConfirmation: Mock<() => boolean>; getApprovalMode: Mock<() => ApprovalMode>;
setAlwaysSkipModificationConfirmation: Mock<(skip: boolean) => void>; setApprovalMode: Mock<(skip: ApprovalMode) => void>;
getVertexAI: Mock<() => boolean | undefined>; getVertexAI: Mock<() => boolean | undefined>;
getShowMemoryUsage: Mock<() => boolean>; getShowMemoryUsage: Mock<() => boolean>;
} }
@ -80,8 +80,7 @@ vi.mock('@gemini-code/core', async (importOriginal) => {
userAgent: opts.userAgent || 'test-agent', userAgent: opts.userAgent || 'test-agent',
userMemory: opts.userMemory || '', userMemory: opts.userMemory || '',
geminiMdFileCount: opts.geminiMdFileCount || 0, geminiMdFileCount: opts.geminiMdFileCount || 0,
alwaysSkipModificationConfirmation: approvalMode: opts.approvalMode ?? ApprovalMode.DEFAULT,
opts.alwaysSkipModificationConfirmation ?? false,
vertexai: opts.vertexai, vertexai: opts.vertexai,
showMemoryUsage: opts.showMemoryUsage ?? false, showMemoryUsage: opts.showMemoryUsage ?? false,
@ -105,10 +104,8 @@ vi.mock('@gemini-code/core', async (importOriginal) => {
setUserMemory: vi.fn(), setUserMemory: vi.fn(),
getGeminiMdFileCount: vi.fn(() => opts.geminiMdFileCount || 0), getGeminiMdFileCount: vi.fn(() => opts.geminiMdFileCount || 0),
setGeminiMdFileCount: vi.fn(), setGeminiMdFileCount: vi.fn(),
getAlwaysSkipModificationConfirmation: vi.fn( getApprovalMode: vi.fn(() => opts.approvalMode ?? ApprovalMode.DEFAULT),
() => opts.alwaysSkipModificationConfirmation ?? false, setApprovalMode: vi.fn(),
),
setAlwaysSkipModificationConfirmation: vi.fn(),
getVertexAI: vi.fn(() => opts.vertexai), getVertexAI: vi.fn(() => opts.vertexai),
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false), getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
}; };

View File

@ -44,6 +44,7 @@ import {
getErrorMessage, getErrorMessage,
type Config, type Config,
getCurrentGeminiMdFilename, getCurrentGeminiMdFilename,
ApprovalMode,
} from '@gemini-code/core'; } from '@gemini-code/core';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js'; import { StreamingContext } from './contexts/StreamingContext.js';
@ -412,9 +413,12 @@ export const App = ({
)} )}
</Box> </Box>
<Box> <Box>
{showAutoAcceptIndicator && !shellModeActive && ( {showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
<AutoAcceptIndicator /> !shellModeActive && (
)} <AutoAcceptIndicator
approvalMode={showAutoAcceptIndicator}
/>
)}
{shellModeActive && <ShellModeIndicator />} {shellModeActive && <ShellModeIndicator />}
</Box> </Box>
</Box> </Box>

View File

@ -7,12 +7,41 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { ApprovalMode } from '@gemini-code/core';
export const AutoAcceptIndicator: React.FC = () => ( interface AutoAcceptIndicatorProps {
<Box> approvalMode: ApprovalMode;
<Text color={Colors.AccentGreen}> }
accepting edits
<Text color={Colors.SubtleComment}> (shift + tab to disable)</Text> export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
</Text> approvalMode,
</Box> }) => {
); let textColor = '';
let textContent = '';
let subText = '';
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = Colors.AccentGreen;
textContent = 'accepting edits';
subText = ' (shift + tab to toggle)';
break;
case ApprovalMode.YOLO:
textColor = Colors.AccentRed;
textContent = 'YOLO mode';
subText = ' (ctrl + y to toggle)';
break;
case ApprovalMode.DEFAULT:
default:
break;
}
return (
<Box>
<Text color={textColor}>
{textContent}
{subText && <Text color={Colors.SubtleComment}>{subText}</Text>}
</Text>
</Box>
);
};

View File

@ -16,7 +16,11 @@ import {
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js'; import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js';
import type { Config as ActualConfigType } from '@gemini-code/core'; import {
Config,
Config as ActualConfigType,
ApprovalMode,
} from '@gemini-code/core';
import { useInput, type Key as InkKey } from 'ink'; import { useInput, type Key as InkKey } from 'ink';
vi.mock('ink'); vi.mock('ink');
@ -31,11 +35,9 @@ vi.mock('@gemini-code/core', async () => {
}; };
}); });
import { Config } from '@gemini-code/core';
interface MockConfigInstanceShape { interface MockConfigInstanceShape {
getAlwaysSkipModificationConfirmation: Mock<() => boolean>; getApprovalMode: Mock<() => ApprovalMode>;
setAlwaysSkipModificationConfirmation: Mock<(value: boolean) => void>; setApprovalMode: Mock<(value: ApprovalMode) => void>;
getCoreTools: Mock<() => string[]>; getCoreTools: Mock<() => string[]>;
getToolDiscoveryCommand: Mock<() => string | undefined>; getToolDiscoveryCommand: Mock<() => string | undefined>;
getTargetDir: Mock<() => string>; getTargetDir: Mock<() => string>;
@ -65,14 +67,16 @@ describe('useAutoAcceptIndicator', () => {
( (
Config as unknown as MockedFunction<() => MockConfigInstanceShape> Config as unknown as MockedFunction<() => MockConfigInstanceShape>
).mockImplementation(() => { ).mockImplementation(() => {
const instanceGetAlwaysSkipMock = vi.fn(); const instanceGetApprovalModeMock = vi.fn();
const instanceSetAlwaysSkipMock = vi.fn(); const instanceSetApprovalModeMock = vi.fn();
const instance: MockConfigInstanceShape = { const instance: MockConfigInstanceShape = {
getAlwaysSkipModificationConfirmation: getApprovalMode: instanceGetApprovalModeMock as Mock<
instanceGetAlwaysSkipMock as Mock<() => boolean>, () => ApprovalMode
setAlwaysSkipModificationConfirmation: >,
instanceSetAlwaysSkipMock as Mock<(value: boolean) => void>, setApprovalMode: instanceSetApprovalModeMock as Mock<
(value: ApprovalMode) => void
>,
getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>, getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock< getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
() => string | undefined () => string | undefined
@ -101,8 +105,8 @@ describe('useAutoAcceptIndicator', () => {
() => { discoverTools: Mock<() => void> } () => { discoverTools: Mock<() => void> }
>, >,
}; };
instanceSetAlwaysSkipMock.mockImplementation((value: boolean) => { instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => {
instanceGetAlwaysSkipMock.mockReturnValue(value); instanceGetApprovalModeMock.mockReturnValue(value);
}); });
return instance; return instance;
}); });
@ -116,68 +120,99 @@ describe('useAutoAcceptIndicator', () => {
mockConfigInstance = new (Config as any)() as MockConfigInstanceShape; mockConfigInstance = new (Config as any)() as MockConfigInstanceShape;
}); });
it('should initialize with true if config.getAlwaysSkipModificationConfirmation returns true', () => { it('should initialize with ApprovalMode.AUTO_EDIT if config.getApprovalMode returns ApprovalMode.AUTO_EDIT', () => {
mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
true,
);
const { result } = renderHook(() => const { result } = renderHook(() =>
useAutoAcceptIndicator({ useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType, config: mockConfigInstance as unknown as ActualConfigType,
}), }),
); );
expect(result.current).toBe(true); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
expect( expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
mockConfigInstance.getAlwaysSkipModificationConfirmation,
).toHaveBeenCalledTimes(1);
}); });
it('should initialize with false if config.getAlwaysSkipModificationConfirmation returns false', () => { it('should initialize with ApprovalMode.DEFAULT if config.getApprovalMode returns ApprovalMode.DEFAULT', () => {
mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
false,
);
const { result } = renderHook(() => const { result } = renderHook(() =>
useAutoAcceptIndicator({ useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType, config: mockConfigInstance as unknown as ActualConfigType,
}), }),
); );
expect(result.current).toBe(false); expect(result.current).toBe(ApprovalMode.DEFAULT);
expect( expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
mockConfigInstance.getAlwaysSkipModificationConfirmation,
).toHaveBeenCalledTimes(1);
}); });
it('should toggle the indicator and update config when Shift+Tab is pressed', () => { it('should initialize with ApprovalMode.YOLO if config.getApprovalMode returns ApprovalMode.YOLO', () => {
mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
false,
);
const { result } = renderHook(() => const { result } = renderHook(() =>
useAutoAcceptIndicator({ useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType, config: mockConfigInstance as unknown as ActualConfigType,
}), }),
); );
expect(result.current).toBe(false); expect(result.current).toBe(ApprovalMode.YOLO);
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
});
it('should toggle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => {
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
const { result } = renderHook(() =>
useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType,
}),
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey); capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
}); });
expect( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
mockConfigInstance.setAlwaysSkipModificationConfirmation, ApprovalMode.AUTO_EDIT,
).toHaveBeenCalledWith(true); );
expect(result.current).toBe(true); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey); capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
}); });
expect( expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
mockConfigInstance.setAlwaysSkipModificationConfirmation, ApprovalMode.AUTO_EDIT,
).toHaveBeenCalledWith(false); );
expect(result.current).toBe(false); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(result.current).toBe(ApprovalMode.DEFAULT);
}); });
it('should not toggle if only Tab, only Shift, or other keys are pressed', () => { it('should not toggle if only one key or other keys combinations are pressed', () => {
mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
false,
);
renderHook(() => renderHook(() =>
useAutoAcceptIndicator({ useAutoAcceptIndicator({
config: mockConfigInstance as unknown as ActualConfigType, config: mockConfigInstance as unknown as ActualConfigType,
@ -187,29 +222,41 @@ describe('useAutoAcceptIndicator', () => {
act(() => { act(() => {
capturedUseInputHandler('', { tab: true, shift: false } as InkKey); capturedUseInputHandler('', { tab: true, shift: false } as InkKey);
}); });
expect( expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
mockConfigInstance.setAlwaysSkipModificationConfirmation,
).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('', { tab: false, shift: true } as InkKey); capturedUseInputHandler('', { tab: false, shift: true } as InkKey);
}); });
expect( expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
mockConfigInstance.setAlwaysSkipModificationConfirmation,
).not.toHaveBeenCalled();
act(() => { act(() => {
capturedUseInputHandler('a', { tab: false, shift: false } as InkKey); capturedUseInputHandler('a', { tab: false, shift: false } as InkKey);
}); });
expect( expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
mockConfigInstance.setAlwaysSkipModificationConfirmation,
).not.toHaveBeenCalled(); act(() => {
capturedUseInputHandler('y', { tab: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { ctrl: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('y', { shift: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { ctrl: true, shift: true } as InkKey);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
}); });
it('should update indicator when config value changes externally (useEffect dependency)', () => { it('should update indicator when config value changes externally (useEffect dependency)', () => {
mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
false,
);
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
(props: { config: ActualConfigType }) => useAutoAcceptIndicator(props), (props: { config: ActualConfigType }) => useAutoAcceptIndicator(props),
{ {
@ -218,16 +265,12 @@ describe('useAutoAcceptIndicator', () => {
}, },
}, },
); );
expect(result.current).toBe(false); expect(result.current).toBe(ApprovalMode.DEFAULT);
mockConfigInstance.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
true,
);
rerender({ config: mockConfigInstance as unknown as ActualConfigType }); rerender({ config: mockConfigInstance as unknown as ActualConfigType });
expect(result.current).toBe(true); expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
expect( expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(3);
mockConfigInstance.getAlwaysSkipModificationConfirmation,
).toHaveBeenCalledTimes(3);
}); });
}); });

View File

@ -6,7 +6,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useInput } from 'ink'; import { useInput } from 'ink';
import type { Config } from '@gemini-code/core'; import { ApprovalMode, type Config } from '@gemini-code/core';
export interface UseAutoAcceptIndicatorArgs { export interface UseAutoAcceptIndicatorArgs {
config: Config; config: Config;
@ -14,8 +14,8 @@ export interface UseAutoAcceptIndicatorArgs {
export function useAutoAcceptIndicator({ export function useAutoAcceptIndicator({
config, config,
}: UseAutoAcceptIndicatorArgs): boolean { }: UseAutoAcceptIndicatorArgs): ApprovalMode {
const currentConfigValue = config.getAlwaysSkipModificationConfirmation(); const currentConfigValue = config.getApprovalMode();
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] = const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
useState(currentConfigValue); useState(currentConfigValue);
@ -23,15 +23,25 @@ export function useAutoAcceptIndicator({
setShowAutoAcceptIndicator(currentConfigValue); setShowAutoAcceptIndicator(currentConfigValue);
}, [currentConfigValue]); }, [currentConfigValue]);
useInput((_input, key) => { useInput((input, key) => {
if (key.tab && key.shift) { let nextApprovalMode: ApprovalMode | undefined;
const alwaysAcceptModificationConfirmations =
!config.getAlwaysSkipModificationConfirmation(); if (key.ctrl && input === 'y') {
config.setAlwaysSkipModificationConfirmation( nextApprovalMode =
alwaysAcceptModificationConfirmations, config.getApprovalMode() === ApprovalMode.YOLO
); ? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.tab && key.shift) {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
}
if (nextApprovalMode) {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness // Update local state immediately for responsiveness
setShowAutoAcceptIndicator(alwaysAcceptModificationConfirmations); setShowAutoAcceptIndicator(nextApprovalMode);
} }
}); });

View File

@ -134,6 +134,7 @@ export function useReactToolScheduler(
outputUpdateHandler, outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler, onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler, onToolCallsUpdate: toolCallsUpdateHandler,
approvalMode: config.getApprovalMode(),
}); });
}, [config, onComplete, setPendingHistoryItem]); }, [config, onComplete, setPendingHistoryItem]);

View File

@ -28,7 +28,8 @@ import {
ToolCallResponseInfo, ToolCallResponseInfo,
formatLlmContentForFunctionResponse, // Import from core formatLlmContentForFunctionResponse, // Import from core
ToolCall, // Import from core ToolCall, // Import from core
Status as ToolCallStatusType, // Import from core Status as ToolCallStatusType,
ApprovalMode, // Import from core
} from '@gemini-code/core'; } from '@gemini-code/core';
import { import {
HistoryItemWithoutId, HistoryItemWithoutId,
@ -52,6 +53,7 @@ const mockToolRegistry = {
const mockConfig = { const mockConfig = {
getToolRegistry: vi.fn(() => mockToolRegistry as unknown as ToolRegistry), getToolRegistry: vi.fn(() => mockToolRegistry as unknown as ToolRegistry),
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
}; };
const mockTool: Tool = { const mockTool: Tool = {
@ -205,6 +207,109 @@ describe('formatLlmContentForFunctionResponse', () => {
}); });
}); });
describe('useReactToolScheduler in YOLO Mode', () => {
let onComplete: Mock;
let setPendingHistoryItem: Mock;
beforeEach(() => {
onComplete = vi.fn();
setPendingHistoryItem = vi.fn();
mockToolRegistry.getTool.mockClear();
(mockToolRequiresConfirmation.execute as Mock).mockClear();
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
// IMPORTANT: Enable YOLO mode for this test suite
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
// IMPORTANT: Disable YOLO mode after this test suite
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT);
});
const renderSchedulerInYoloMode = () =>
renderHook(() =>
useReactToolScheduler(
onComplete,
mockConfig as unknown as Config,
setPendingHistoryItem,
),
);
it('should skip confirmation and execute tool directly when yoloMode is true', async () => {
mockToolRegistry.getTool.mockReturnValue(mockToolRequiresConfirmation);
const expectedOutput = 'YOLO Confirmed output';
(mockToolRequiresConfirmation.execute as Mock).mockResolvedValue({
llmContent: expectedOutput,
returnDisplay: 'YOLO Formatted tool output',
} as ToolResult);
const { result } = renderSchedulerInYoloMode();
const schedule = result.current[1];
const request: ToolCallRequestInfo = {
callId: 'yoloCall',
name: 'mockToolRequiresConfirmation',
args: { data: 'any data' },
};
act(() => {
schedule(request);
});
await act(async () => {
await vi.runAllTimersAsync(); // Process validation
});
await act(async () => {
await vi.runAllTimersAsync(); // Process scheduling
});
await act(async () => {
await vi.runAllTimersAsync(); // Process execution
});
// Check that shouldConfirmExecute was NOT called
expect(
mockToolRequiresConfirmation.shouldConfirmExecute,
).not.toHaveBeenCalled();
// Check that execute WAS called
expect(mockToolRequiresConfirmation.execute).toHaveBeenCalledWith(
request.args,
expect.any(AbortSignal),
undefined,
);
// Check that onComplete was called with success
expect(onComplete).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
request,
response: expect.objectContaining({
resultDisplay: 'YOLO Formatted tool output',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: expectedOutput },
}),
}),
]),
}),
}),
]);
// Ensure no confirmation UI was triggered (setPendingHistoryItem should not have been called with confirmation details)
const setPendingHistoryItemCalls = setPendingHistoryItem.mock.calls;
const confirmationCall = setPendingHistoryItemCalls.find((call) => {
const item = typeof call[0] === 'function' ? call[0]({}) : call[0];
return item?.tools?.[0]?.confirmationDetails;
});
expect(confirmationCall).toBeUndefined();
});
});
describe('useReactToolScheduler', () => { describe('useReactToolScheduler', () => {
// TODO(ntaylormullen): The following tests are skipped due to difficulties in // TODO(ntaylormullen): The following tests are skipped due to difficulties in
// reliably testing the asynchronous state updates and interactions with timers. // reliably testing the asynchronous state updates and interactions with timers.

View File

@ -22,6 +22,12 @@ import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js'; import { WebSearchTool } from '../tools/web-search.js';
export enum ApprovalMode {
DEFAULT = 'default',
AUTO_EDIT = 'autoEdit',
YOLO = 'yolo',
}
export class MCPServerConfig { export class MCPServerConfig {
constructor( constructor(
// For stdio transport // For stdio transport
@ -53,7 +59,7 @@ export interface ConfigParameters {
userAgent: string; userAgent: string;
userMemory?: string; userMemory?: string;
geminiMdFileCount?: number; geminiMdFileCount?: number;
alwaysSkipModificationConfirmation?: boolean; approvalMode?: ApprovalMode;
vertexai?: boolean; vertexai?: boolean;
showMemoryUsage?: boolean; showMemoryUsage?: boolean;
contextFileName?: string; contextFileName?: string;
@ -76,7 +82,7 @@ export class Config {
private readonly userAgent: string; private readonly userAgent: string;
private userMemory: string; private userMemory: string;
private geminiMdFileCount: number; private geminiMdFileCount: number;
private alwaysSkipModificationConfirmation: boolean; private approvalMode: ApprovalMode;
private readonly vertexai: boolean | undefined; private readonly vertexai: boolean | undefined;
private readonly showMemoryUsage: boolean; private readonly showMemoryUsage: boolean;
@ -96,8 +102,7 @@ export class Config {
this.userAgent = params.userAgent; this.userAgent = params.userAgent;
this.userMemory = params.userMemory ?? ''; this.userMemory = params.userMemory ?? '';
this.geminiMdFileCount = params.geminiMdFileCount ?? 0; this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
this.alwaysSkipModificationConfirmation = this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
params.alwaysSkipModificationConfirmation ?? false;
this.vertexai = params.vertexai; this.vertexai = params.vertexai;
this.showMemoryUsage = params.showMemoryUsage ?? false; this.showMemoryUsage = params.showMemoryUsage ?? false;
@ -179,12 +184,12 @@ export class Config {
this.geminiMdFileCount = count; this.geminiMdFileCount = count;
} }
getAlwaysSkipModificationConfirmation(): boolean { getApprovalMode(): ApprovalMode {
return this.alwaysSkipModificationConfirmation; return this.approvalMode;
} }
setAlwaysSkipModificationConfirmation(skip: boolean): void { setApprovalMode(mode: ApprovalMode): void {
this.alwaysSkipModificationConfirmation = skip; this.approvalMode = mode;
} }
getVertexAI(): boolean | undefined { getVertexAI(): boolean | undefined {

View File

@ -12,6 +12,7 @@ import {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolResult, ToolResult,
ToolRegistry, ToolRegistry,
ApprovalMode,
} from '../index.js'; } from '../index.js';
import { Part, PartUnion, PartListUnion } from '@google/genai'; import { Part, PartUnion, PartListUnion } from '@google/genai';
@ -159,6 +160,7 @@ interface CoreToolSchedulerOptions {
outputUpdateHandler?: OutputUpdateHandler; outputUpdateHandler?: OutputUpdateHandler;
onAllToolCallsComplete?: AllToolCallsCompleteHandler; onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler; onToolCallsUpdate?: ToolCallsUpdateHandler;
approvalMode?: ApprovalMode;
} }
export class CoreToolScheduler { export class CoreToolScheduler {
@ -168,12 +170,14 @@ export class CoreToolScheduler {
private outputUpdateHandler?: OutputUpdateHandler; private outputUpdateHandler?: OutputUpdateHandler;
private onAllToolCallsComplete?: AllToolCallsCompleteHandler; private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
private onToolCallsUpdate?: ToolCallsUpdateHandler; private onToolCallsUpdate?: ToolCallsUpdateHandler;
private approvalMode: ApprovalMode;
constructor(options: CoreToolSchedulerOptions) { constructor(options: CoreToolSchedulerOptions) {
this.toolRegistry = options.toolRegistry; this.toolRegistry = options.toolRegistry;
this.outputUpdateHandler = options.outputUpdateHandler; this.outputUpdateHandler = options.outputUpdateHandler;
this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate; this.onToolCallsUpdate = options.onToolCallsUpdate;
this.approvalMode = options.approvalMode ?? ApprovalMode.DEFAULT;
this.abortController = new AbortController(); this.abortController = new AbortController();
} }
@ -324,29 +328,33 @@ export class CoreToolScheduler {
const { request: reqInfo, tool: toolInstance } = toolCall; const { request: reqInfo, tool: toolInstance } = toolCall;
try { try {
const confirmationDetails = await toolInstance.shouldConfirmExecute( if (this.approvalMode === ApprovalMode.YOLO) {
reqInfo.args,
this.abortController.signal,
);
if (confirmationDetails) {
const originalOnConfirm = confirmationDetails.onConfirm;
const wrappedConfirmationDetails: ToolCallConfirmationDetails = {
...confirmationDetails,
onConfirm: (outcome: ToolConfirmationOutcome) =>
this.handleConfirmationResponse(
reqInfo.callId,
originalOnConfirm,
outcome,
),
};
this.setStatusInternal(
reqInfo.callId,
'awaiting_approval',
wrappedConfirmationDetails,
);
} else {
this.setStatusInternal(reqInfo.callId, 'scheduled'); this.setStatusInternal(reqInfo.callId, 'scheduled');
} else {
const confirmationDetails = await toolInstance.shouldConfirmExecute(
reqInfo.args,
this.abortController.signal,
);
if (confirmationDetails) {
const originalOnConfirm = confirmationDetails.onConfirm;
const wrappedConfirmationDetails: ToolCallConfirmationDetails = {
...confirmationDetails,
onConfirm: (outcome: ToolConfirmationOutcome) =>
this.handleConfirmationResponse(
reqInfo.callId,
originalOnConfirm,
outcome,
),
};
this.setStatusInternal(
reqInfo.callId,
'awaiting_approval',
wrappedConfirmationDetails,
);
} else {
this.setStatusInternal(reqInfo.callId, 'scheduled');
}
} }
} catch (error) { } catch (error) {
this.setStatusInternal( this.setStatusInternal(

View File

@ -25,7 +25,7 @@ import { FileDiff } from './tools.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import { Config } from '../config/config.js'; import { ApprovalMode, Config } from '../config/config.js';
import { Content, Part, SchemaUnion } from '@google/genai'; import { Content, Part, SchemaUnion } from '@google/genai';
describe('EditTool', () => { describe('EditTool', () => {
@ -41,8 +41,8 @@ describe('EditTool', () => {
mockConfig = { mockConfig = {
getTargetDir: () => rootDir, getTargetDir: () => rootDir,
getAlwaysSkipModificationConfirmation: vi.fn(() => false), getApprovalMode: vi.fn(() => false),
setAlwaysSkipModificationConfirmation: vi.fn(), setApprovalMode: vi.fn(),
// getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method // getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method
// Add other properties/methods of Config if EditTool uses them // Add other properties/methods of Config if EditTool uses them
// Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses: // Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses:
@ -65,12 +65,10 @@ describe('EditTool', () => {
} as unknown as Config; } as unknown as Config;
// Reset mocks before each test // Reset mocks before each test
(mockConfig.getAlwaysSkipModificationConfirmation as Mock).mockClear(); (mockConfig.getApprovalMode as Mock).mockClear();
(mockConfig.setAlwaysSkipModificationConfirmation as Mock).mockClear(); (mockConfig.getApprovalMode as Mock).mockClear();
// Default to not skipping confirmation // Default to not skipping confirmation
(mockConfig.getAlwaysSkipModificationConfirmation as Mock).mockReturnValue( (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT);
false,
);
// Reset mocks and set default implementation for ensureCorrectEdit // Reset mocks and set default implementation for ensureCorrectEdit
mockEnsureCorrectEdit.mockReset(); mockEnsureCorrectEdit.mockReset();
@ -439,9 +437,9 @@ describe('EditTool', () => {
new_string: fileContent, new_string: fileContent,
}; };
( (mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
mockConfig.getAlwaysSkipModificationConfirmation as Mock ApprovalMode.AUTO_EDIT,
).mockReturnValueOnce(true); );
const result = await tool.execute(params, new AbortController().signal); const result = await tool.execute(params, new AbortController().signal);
expect(result.llmContent).toMatch(/Created new file/); expect(result.llmContent).toMatch(/Created new file/);

View File

@ -20,7 +20,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js'; import { isNodeError } from '../utils/errors.js';
import { ReadFileTool } from './read-file.js'; import { ReadFileTool } from './read-file.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { Config } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js';
import { ensureCorrectEdit } from '../utils/editCorrector.js'; import { ensureCorrectEdit } from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
@ -281,7 +281,7 @@ Expectation for required parameters:
params: EditToolParams, params: EditToolParams,
abortSignal: AbortSignal, abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> { ): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getAlwaysSkipModificationConfirmation()) { if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false; return false;
} }
const validationError = this.validateToolParams(params); const validationError = this.validateToolParams(params);
@ -356,7 +356,7 @@ Expectation for required parameters:
fileDiff, fileDiff,
onConfirm: async (outcome: ToolConfirmationOutcome) => { onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) { if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setAlwaysSkipModificationConfirmation(true); this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
} }
}, },
}; };

View File

@ -16,7 +16,7 @@ import {
} from 'vitest'; } from 'vitest';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js'; import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DiscoveredMCPTool } from './mcp-tool.js'; import { DiscoveredMCPTool } from './mcp-tool.js';
import { Config, ConfigParameters } from '../config/config.js'; import { ApprovalMode, Config, ConfigParameters } from '../config/config.js';
import { BaseTool, ToolResult } from './tools.js'; import { BaseTool, ToolResult } from './tools.js';
import { FunctionDeclaration } from '@google/genai'; import { FunctionDeclaration } from '@google/genai';
import { execSync, spawn } from 'node:child_process'; // Import spawn here import { execSync, spawn } from 'node:child_process'; // Import spawn here
@ -85,7 +85,7 @@ const baseConfigParams: ConfigParameters = {
userAgent: 'TestAgent/1.0', userAgent: 'TestAgent/1.0',
userMemory: '', userMemory: '',
geminiMdFileCount: 0, geminiMdFileCount: 0,
alwaysSkipModificationConfirmation: false, approvalMode: ApprovalMode.DEFAULT,
vertexai: false, vertexai: false,
}; };

View File

@ -20,7 +20,7 @@ import {
ToolEditConfirmationDetails, ToolEditConfirmationDetails,
} from './tools.js'; } from './tools.js';
import { type EditToolParams } from './edit.js'; import { type EditToolParams } from './edit.js';
import { Config } from '../config/config.js'; import { ApprovalMode, Config } from '../config/config.js';
import { ToolRegistry } from './tool-registry.js'; import { ToolRegistry } from './tool-registry.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
@ -51,8 +51,8 @@ vi.mocked(ensureCorrectFileContent).mockImplementation(
// Mock Config // Mock Config
const mockConfigInternal = { const mockConfigInternal = {
getTargetDir: () => rootDir, getTargetDir: () => rootDir,
getAlwaysSkipModificationConfirmation: vi.fn(() => false), getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
setAlwaysSkipModificationConfirmation: vi.fn(), setApprovalMode: vi.fn(),
getApiKey: () => 'test-key', getApiKey: () => 'test-key',
getModel: () => 'test-model', getModel: () => 'test-model',
getSandbox: () => false, getSandbox: () => false,
@ -100,10 +100,8 @@ describe('WriteFileTool', () => {
tool = new WriteFileTool(mockConfig); tool = new WriteFileTool(mockConfig);
// Reset mocks before each test // Reset mocks before each test
mockConfigInternal.getAlwaysSkipModificationConfirmation.mockReturnValue( mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
false, mockConfigInternal.setApprovalMode.mockClear();
);
mockConfigInternal.setAlwaysSkipModificationConfirmation.mockClear();
mockEnsureCorrectEdit.mockReset(); mockEnsureCorrectEdit.mockReset();
mockEnsureCorrectFileContent.mockReset(); mockEnsureCorrectFileContent.mockReset();

View File

@ -7,7 +7,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import * as Diff from 'diff'; import * as Diff from 'diff';
import { Config } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js';
import { import {
BaseTool, BaseTool,
ToolResult, ToolResult,
@ -143,7 +143,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
params: WriteFileToolParams, params: WriteFileToolParams,
abortSignal: AbortSignal, abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> { ): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getAlwaysSkipModificationConfirmation()) { if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false; return false;
} }
@ -186,7 +186,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
fileDiff, fileDiff,
onConfirm: async (outcome: ToolConfirmationOutcome) => { onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) { if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setAlwaysSkipModificationConfirmation(true); this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
} }
}, },
}; };