refactor: Unify file modification confirmation state
- Modifies `EditTool` and `WriteFileTool` to share a single confirmation preference. - The "Always Proceed" choice for file modifications is now stored in `Config.alwaysSkipModificationConfirmation`. - This ensures that if a user chooses to always skip confirmation for one file modification tool, this preference is respected by the other. - `WriteFileTool` constructor now accepts `Config` instead of `targetDir` to facilitate this shared state. - Tests updated to reflect the new shared confirmation logic. Fixes https://b.corp.google.com/issues/415897960
This commit is contained in:
parent
58e0224061
commit
5dcdbe64ab
|
@ -43,6 +43,7 @@ export class Config {
|
||||||
private readonly userAgent: string,
|
private readonly userAgent: string,
|
||||||
private userMemory: string = '', // Made mutable for refresh
|
private userMemory: string = '', // Made mutable for refresh
|
||||||
private geminiMdFileCount: number = 0,
|
private geminiMdFileCount: number = 0,
|
||||||
|
private alwaysSkipModificationConfirmation: boolean = false,
|
||||||
) {
|
) {
|
||||||
// toolRegistry still needs initialization based on the instance
|
// toolRegistry still needs initialization based on the instance
|
||||||
this.toolRegistry = createToolRegistry(this);
|
this.toolRegistry = createToolRegistry(this);
|
||||||
|
@ -114,6 +115,14 @@ export class Config {
|
||||||
setGeminiMdFileCount(count: number): void {
|
setGeminiMdFileCount(count: number): void {
|
||||||
this.geminiMdFileCount = count;
|
this.geminiMdFileCount = count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAlwaysSkipModificationConfirmation(): boolean {
|
||||||
|
return this.alwaysSkipModificationConfirmation;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlwaysSkipModificationConfirmation(skip: boolean): void {
|
||||||
|
this.alwaysSkipModificationConfirmation = skip;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findEnvFile(startDir: string): string | null {
|
function findEnvFile(startDir: string): string | null {
|
||||||
|
@ -159,6 +168,7 @@ export function createServerConfig(
|
||||||
userAgent?: string,
|
userAgent?: string,
|
||||||
userMemory?: string,
|
userMemory?: string,
|
||||||
geminiMdFileCount?: number,
|
geminiMdFileCount?: number,
|
||||||
|
alwaysSkipModificationConfirmation?: boolean,
|
||||||
): Config {
|
): Config {
|
||||||
return new Config(
|
return new Config(
|
||||||
apiKey,
|
apiKey,
|
||||||
|
@ -175,6 +185,7 @@ export function createServerConfig(
|
||||||
userAgent ?? 'GeminiCLI/unknown', // Default user agent
|
userAgent ?? 'GeminiCLI/unknown', // Default user agent
|
||||||
userMemory ?? '',
|
userMemory ?? '',
|
||||||
geminiMdFileCount ?? 0,
|
geminiMdFileCount ?? 0,
|
||||||
|
alwaysSkipModificationConfirmation ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,7 +199,7 @@ function createToolRegistry(config: Config): ToolRegistry {
|
||||||
new GrepTool(targetDir),
|
new GrepTool(targetDir),
|
||||||
new GlobTool(targetDir),
|
new GlobTool(targetDir),
|
||||||
new EditTool(config),
|
new EditTool(config),
|
||||||
new WriteFileTool(targetDir),
|
new WriteFileTool(config),
|
||||||
new WebFetchTool(),
|
new WebFetchTool(),
|
||||||
new ReadManyFilesTool(targetDir),
|
new ReadManyFilesTool(targetDir),
|
||||||
new ShellTool(config),
|
new ShellTool(config),
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest';
|
||||||
import { EditTool, EditToolParams } from './edit.js';
|
import { EditTool, EditToolParams } from './edit.js';
|
||||||
import { FileDiff } from './tools.js';
|
import { FileDiff } from './tools.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
@ -38,10 +38,37 @@ describe('EditTool', () => {
|
||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getTargetDir: () => rootDir,
|
getTargetDir: () => rootDir,
|
||||||
getGeminiConfig: () => ({ apiKey: 'test-api-key' }),
|
getAlwaysSkipModificationConfirmation: vi.fn(() => false),
|
||||||
|
setAlwaysSkipModificationConfirmation: vi.fn(),
|
||||||
|
// 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:
|
||||||
|
getApiKey: () => 'test-api-key',
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getSandbox: () => false,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getQuestion: () => undefined,
|
||||||
|
getFullContext: () => false,
|
||||||
|
getToolDiscoveryCommand: () => undefined,
|
||||||
|
getToolCallCommand: () => undefined,
|
||||||
|
getMcpServerCommand: () => undefined,
|
||||||
|
getMcpServers: () => undefined,
|
||||||
|
getUserAgent: () => 'test-agent',
|
||||||
|
getUserMemory: () => '',
|
||||||
|
setUserMemory: vi.fn(),
|
||||||
|
getGeminiMdFileCount: () => 0,
|
||||||
|
setGeminiMdFileCount: vi.fn(),
|
||||||
|
getToolRegistry: () => ({}) as any, // Minimal mock for ToolRegistry
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
|
// Reset mocks before each test
|
||||||
|
(mockConfig.getAlwaysSkipModificationConfirmation as Mock).mockClear();
|
||||||
|
(mockConfig.setAlwaysSkipModificationConfirmation as Mock).mockClear();
|
||||||
|
// Default to not skipping confirmation
|
||||||
|
(mockConfig.getAlwaysSkipModificationConfirmation as Mock).mockReturnValue(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// Reset mocks and set default implementation for ensureCorrectEdit
|
// Reset mocks and set default implementation for ensureCorrectEdit
|
||||||
mockEnsureCorrectEdit.mockReset();
|
mockEnsureCorrectEdit.mockReset();
|
||||||
mockEnsureCorrectEdit.mockImplementation(async (currentContent, params) => {
|
mockEnsureCorrectEdit.mockImplementation(async (currentContent, params) => {
|
||||||
|
@ -290,9 +317,10 @@ describe('EditTool', () => {
|
||||||
new_string: fileContent,
|
new_string: fileContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
(tool as any).shouldAlwaysEdit = true;
|
(
|
||||||
|
mockConfig.getAlwaysSkipModificationConfirmation as Mock
|
||||||
|
).mockReturnValueOnce(true);
|
||||||
const result = await tool.execute(params, new AbortController().signal);
|
const result = await tool.execute(params, new AbortController().signal);
|
||||||
(tool as any).shouldAlwaysEdit = false;
|
|
||||||
|
|
||||||
expect(result.llmContent).toMatch(/Created new file/);
|
expect(result.llmContent).toMatch(/Created new file/);
|
||||||
expect(fs.existsSync(newFilePath)).toBe(true);
|
expect(fs.existsSync(newFilePath)).toBe(true);
|
||||||
|
|
|
@ -56,7 +56,7 @@ interface CalculatedEdit {
|
||||||
*/
|
*/
|
||||||
export class EditTool extends BaseTool<EditToolParams, ToolResult> {
|
export class EditTool extends BaseTool<EditToolParams, ToolResult> {
|
||||||
static readonly Name = 'replace';
|
static readonly Name = 'replace';
|
||||||
private shouldAlwaysEdit = false;
|
private readonly config: Config;
|
||||||
private readonly rootDirectory: string;
|
private readonly rootDirectory: string;
|
||||||
private readonly client: GeminiClient;
|
private readonly client: GeminiClient;
|
||||||
|
|
||||||
|
@ -98,8 +98,9 @@ Expectation for parameters:
|
||||||
type: 'object',
|
type: 'object',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.rootDirectory = path.resolve(config.getTargetDir());
|
this.config = config;
|
||||||
this.client = new GeminiClient(config);
|
this.rootDirectory = path.resolve(this.config.getTargetDir());
|
||||||
|
this.client = new GeminiClient(this.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -234,7 +235,7 @@ Expectation for parameters:
|
||||||
async shouldConfirmExecute(
|
async shouldConfirmExecute(
|
||||||
params: EditToolParams,
|
params: EditToolParams,
|
||||||
): Promise<ToolCallConfirmationDetails | false> {
|
): Promise<ToolCallConfirmationDetails | false> {
|
||||||
if (this.shouldAlwaysEdit) {
|
if (this.config.getAlwaysSkipModificationConfirmation()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const validationError = this.validateToolParams(params);
|
const validationError = this.validateToolParams(params);
|
||||||
|
@ -295,7 +296,7 @@ Expectation for parameters:
|
||||||
fileDiff,
|
fileDiff,
|
||||||
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
onConfirm: async (outcome: ToolConfirmationOutcome) => {
|
||||||
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
|
||||||
this.shouldAlwaysEdit = true;
|
this.config.setAlwaysSkipModificationConfirmation(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,18 +4,48 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { WriteFileTool } from './write-file.js';
|
import { WriteFileTool } from './write-file.js';
|
||||||
import { FileDiff, ToolConfirmationOutcome } from './tools.js';
|
import { FileDiff, ToolConfirmationOutcome } from './tools.js';
|
||||||
|
import { Config } from '../config/config.js';
|
||||||
|
import { ToolRegistry } from './tool-registry.js'; // Added import
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
|
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
|
||||||
|
|
||||||
|
// Mock Config
|
||||||
|
const mockConfigInternal = {
|
||||||
|
getTargetDir: () => rootDir,
|
||||||
|
getAlwaysSkipModificationConfirmation: vi.fn(() => false),
|
||||||
|
setAlwaysSkipModificationConfirmation: vi.fn(),
|
||||||
|
getApiKey: () => 'test-key',
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getSandbox: () => false,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getQuestion: () => undefined,
|
||||||
|
getFullContext: () => false,
|
||||||
|
getToolDiscoveryCommand: () => undefined,
|
||||||
|
getToolCallCommand: () => undefined,
|
||||||
|
getMcpServerCommand: () => undefined,
|
||||||
|
getMcpServers: () => undefined,
|
||||||
|
getUserAgent: () => 'test-agent',
|
||||||
|
getUserMemory: () => '',
|
||||||
|
setUserMemory: vi.fn(),
|
||||||
|
getGeminiMdFileCount: () => 0,
|
||||||
|
setGeminiMdFileCount: vi.fn(),
|
||||||
|
getToolRegistry: () =>
|
||||||
|
({
|
||||||
|
registerTool: vi.fn(),
|
||||||
|
discoverTools: vi.fn(),
|
||||||
|
}) as unknown as ToolRegistry,
|
||||||
|
};
|
||||||
|
const mockConfig = mockConfigInternal as unknown as Config;
|
||||||
|
|
||||||
describe('WriteFileTool', () => {
|
describe('WriteFileTool', () => {
|
||||||
let tool: WriteFileTool;
|
let tool: WriteFileTool;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
// Using a subdirectory within the OS temp directory for the root to avoid potential permission issues.
|
|
||||||
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create a unique temporary directory for files created outside the root (for testing boundary conditions)
|
// Create a unique temporary directory for files created outside the root (for testing boundary conditions)
|
||||||
|
@ -26,7 +56,12 @@ describe('WriteFileTool', () => {
|
||||||
if (!fs.existsSync(rootDir)) {
|
if (!fs.existsSync(rootDir)) {
|
||||||
fs.mkdirSync(rootDir, { recursive: true });
|
fs.mkdirSync(rootDir, { recursive: true });
|
||||||
}
|
}
|
||||||
tool = new WriteFileTool(rootDir);
|
tool = new WriteFileTool(mockConfig);
|
||||||
|
// Reset mocks before each test that might use them for confirmation logic
|
||||||
|
mockConfigInternal.getAlwaysSkipModificationConfirmation.mockReturnValue(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
mockConfigInternal.setAlwaysSkipModificationConfirmation.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
@ -7,6 +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 {
|
import {
|
||||||
BaseTool,
|
BaseTool,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
|
@ -15,9 +16,10 @@ import {
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
import { SchemaValidator } from '../utils/schemaValidator.js'; // Updated import
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||||
import { makeRelative, shortenPath } from '../utils/paths.js'; // Updated import
|
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||||
import { isNodeError } from '../utils/errors.js';
|
import { isNodeError } from '../utils/errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameters for the WriteFile tool
|
* Parameters for the WriteFile tool
|
||||||
*/
|
*/
|
||||||
|
@ -38,9 +40,8 @@ export interface WriteFileToolParams {
|
||||||
*/
|
*/
|
||||||
export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
|
export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
|
||||||
static readonly Name: string = 'write_file';
|
static readonly Name: string = 'write_file';
|
||||||
private shouldAlwaysWrite = false;
|
|
||||||
|
|
||||||
constructor(private readonly rootDirectory: string) {
|
constructor(private readonly config: Config) {
|
||||||
super(
|
super(
|
||||||
WriteFileTool.Name,
|
WriteFileTool.Name,
|
||||||
'WriteFile',
|
'WriteFile',
|
||||||
|
@ -61,12 +62,11 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.rootDirectory = path.resolve(rootDirectory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isWithinRoot(pathToCheck: string): boolean {
|
private isWithinRoot(pathToCheck: string): boolean {
|
||||||
const normalizedPath = path.normalize(pathToCheck);
|
const normalizedPath = path.normalize(pathToCheck);
|
||||||
const normalizedRoot = path.normalize(this.rootDirectory);
|
const normalizedRoot = path.normalize(this.config.getTargetDir());
|
||||||
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
||||||
? normalizedRoot
|
? normalizedRoot
|
||||||
: normalizedRoot + path.sep;
|
: normalizedRoot + path.sep;
|
||||||
|
@ -90,13 +90,16 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
|
||||||
return `File path must be absolute: ${params.file_path}`;
|
return `File path must be absolute: ${params.file_path}`;
|
||||||
}
|
}
|
||||||
if (!this.isWithinRoot(params.file_path)) {
|
if (!this.isWithinRoot(params.file_path)) {
|
||||||
return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`;
|
return `File path must be within the root directory (${this.config.getTargetDir()}): ${params.file_path}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDescription(params: WriteFileToolParams): string {
|
getDescription(params: WriteFileToolParams): string {
|
||||||
const relativePath = makeRelative(params.file_path, this.rootDirectory);
|
const relativePath = makeRelative(
|
||||||
|
params.file_path,
|
||||||
|
this.config.getTargetDir(),
|
||||||
|
);
|
||||||
return `Writing to ${shortenPath(relativePath)}`;
|
return `Writing to ${shortenPath(relativePath)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,7 +109,7 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
|
||||||
async shouldConfirmExecute(
|
async shouldConfirmExecute(
|
||||||
params: WriteFileToolParams,
|
params: WriteFileToolParams,
|
||||||
): Promise<ToolCallConfirmationDetails | false> {
|
): Promise<ToolCallConfirmationDetails | false> {
|
||||||
if (this.shouldAlwaysWrite) {
|
if (this.config.getAlwaysSkipModificationConfirmation()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,7 +121,10 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = makeRelative(params.file_path, this.rootDirectory);
|
const relativePath = makeRelative(
|
||||||
|
params.file_path,
|
||||||
|
this.config.getTargetDir(),
|
||||||
|
);
|
||||||
const fileName = path.basename(params.file_path);
|
const fileName = path.basename(params.file_path);
|
||||||
|
|
||||||
let currentContent = '';
|
let currentContent = '';
|
||||||
|
@ -143,7 +149,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.shouldAlwaysWrite = true;
|
this.config.setAlwaysSkipModificationConfirmation(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue