Read and write files through Zed (#6169)

Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Conrad Irwin 2025-08-18 16:29:45 -06:00 committed by GitHub
parent 4394b6ab4f
commit fb3ceb0da4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 268 additions and 50 deletions

View File

@ -11,6 +11,7 @@ import {
FileDiscoveryService, FileDiscoveryService,
GlobTool, GlobTool,
ReadManyFilesTool, ReadManyFilesTool,
StandardFileSystemService,
ToolRegistry, ToolRegistry,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import * as os from 'os'; import * as os from 'os';
@ -56,6 +57,7 @@ describe('handleAtCommand', () => {
respectGitIgnore: true, respectGitIgnore: true,
respectGeminiIgnore: true, respectGeminiIgnore: true,
}), }),
getFileSystemService: () => new StandardFileSystemService(),
getEnableRecursiveFileSearch: vi.fn(() => true), getEnableRecursiveFileSearch: vi.fn(() => true),
getWorkspaceContext: () => ({ getWorkspaceContext: () => ({
isPathWithinWorkspace: () => true, isPathWithinWorkspace: () => true,

View File

@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { FileSystemService } from '@google/gemini-cli-core';
import * as acp from './acp.js';
/**
* ACP client-based implementation of FileSystemService
*/
export class AcpFileSystemService implements FileSystemService {
constructor(
private readonly client: acp.Client,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapability,
private readonly fallback: FileSystemService,
) {}
async readTextFile(filePath: string): Promise<string> {
if (!this.capabilities.readTextFile) {
return this.fallback.readTextFile(filePath);
}
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
return response.content;
}
async writeTextFile(filePath: string, content: string): Promise<void> {
if (!this.capabilities.writeTextFile) {
return this.fallback.writeTextFile(filePath, content);
}
await this.client.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
}
}

View File

@ -24,6 +24,7 @@ import {
MCPServerConfig, MCPServerConfig,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import * as acp from './acp.js'; import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js';
import { Readable, Writable } from 'node:stream'; import { Readable, Writable } from 'node:stream';
import { Content, Part, FunctionCall, PartListUnion } from '@google/genai'; import { Content, Part, FunctionCall, PartListUnion } from '@google/genai';
import { LoadedSettings, SettingScope } from '../config/settings.js'; import { LoadedSettings, SettingScope } from '../config/settings.js';
@ -60,6 +61,7 @@ export async function runZedIntegration(
class GeminiAgent { class GeminiAgent {
private sessions: Map<string, Session> = new Map(); private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
constructor( constructor(
private config: Config, private config: Config,
@ -70,8 +72,9 @@ class GeminiAgent {
) {} ) {}
async initialize( async initialize(
_args: acp.InitializeRequest, args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> { ): Promise<acp.InitializeResponse> {
this.clientCapabilities = args.clientCapabilities;
const authMethods = [ const authMethods = [
{ {
id: AuthType.LOGIN_WITH_GOOGLE, id: AuthType.LOGIN_WITH_GOOGLE,
@ -129,6 +132,16 @@ class GeminiAgent {
throw acp.RequestError.authRequired(); throw acp.RequestError.authRequired();
} }
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.client,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const geminiClient = config.getGeminiClient(); const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat(); const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.client); const session = new Session(sessionId, chat, config, this.client);

View File

@ -47,6 +47,10 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import { IdeClient } from '../ide/ide-client.js'; import { IdeClient } from '../ide/ide-client.js';
import type { Content } from '@google/genai'; import type { Content } from '@google/genai';
import {
FileSystemService,
StandardFileSystemService,
} from '../services/fileSystemService.js';
import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js'; import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js';
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js'; import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
@ -204,6 +208,7 @@ export class Config {
private toolRegistry!: ToolRegistry; private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry; private promptRegistry!: PromptRegistry;
private readonly sessionId: string; private readonly sessionId: string;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfig!: ContentGeneratorConfig;
private readonly embeddingModel: string; private readonly embeddingModel: string;
private readonly sandbox: SandboxConfig | undefined; private readonly sandbox: SandboxConfig | undefined;
@ -268,6 +273,7 @@ export class Config {
this.sessionId = params.sessionId; this.sessionId = params.sessionId;
this.embeddingModel = this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
this.sandbox = params.sandbox; this.sandbox = params.sandbox;
this.targetDir = path.resolve(params.targetDir); this.targetDir = path.resolve(params.targetDir);
this.workspaceContext = new WorkspaceContext( this.workspaceContext = new WorkspaceContext(
@ -700,6 +706,20 @@ export class Config {
return this.ideClient; return this.ideClient;
} }
/**
* Get the current FileSystemService
*/
getFileSystemService(): FileSystemService {
return this.fileSystemService;
}
/**
* Set a custom FileSystemService
*/
setFileSystemService(fileSystemService: FileSystemService): void {
this.fileSystemService = fileSystemService;
}
getChatCompression(): ChatCompressionSettings | undefined { getChatCompression(): ChatCompressionSettings | undefined {
return this.chatCompression; return this.chatCompression;
} }

View File

@ -46,6 +46,7 @@ export * from './utils/errorParsing.js';
// Export services // Export services
export * from './services/fileDiscoveryService.js'; export * from './services/fileDiscoveryService.js';
export * from './services/gitService.js'; export * from './services/gitService.js';
export * from './services/fileSystemService.js';
// Export IDE specific logic // Export IDE specific logic
export * from './ide/ide-client.js'; export * from './ide/ide-client.js';

View File

@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import { StandardFileSystemService } from './fileSystemService.js';
vi.mock('fs/promises');
describe('StandardFileSystemService', () => {
let fileSystem: StandardFileSystemService;
beforeEach(() => {
vi.resetAllMocks();
fileSystem = new StandardFileSystemService();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readTextFile', () => {
it('should read file content using fs', async () => {
const testContent = 'Hello, World!';
vi.mocked(fs.readFile).mockResolvedValue(testContent);
const result = await fileSystem.readTextFile('/test/file.txt');
expect(fs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
expect(result).toBe(testContent);
});
it('should propagate fs.readFile errors', async () => {
const error = new Error('ENOENT: File not found');
vi.mocked(fs.readFile).mockRejectedValue(error);
await expect(fileSystem.readTextFile('/test/file.txt')).rejects.toThrow(
'ENOENT: File not found',
);
});
});
describe('writeTextFile', () => {
it('should write file content using fs', async () => {
vi.mocked(fs.writeFile).mockResolvedValue();
await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!');
expect(fs.writeFile).toHaveBeenCalledWith(
'/test/file.txt',
'Hello, World!',
'utf-8',
);
});
});
});

View File

@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
/**
* Interface for file system operations that may be delegated to different implementations
*/
export interface FileSystemService {
/**
* Read text content from a file
*
* @param filePath - The path to the file to read
* @returns The file content as a string
*/
readTextFile(filePath: string): Promise<string>;
/**
* Write text content to a file
*
* @param filePath - The path to the file to write
* @param content - The content to write
*/
writeTextFile(filePath: string, content: string): Promise<void>;
}
/**
* Standard file system implementation
*/
export class StandardFileSystemService implements FileSystemService {
async readTextFile(filePath: string): Promise<string> {
return fs.readFile(filePath, 'utf-8');
}
async writeTextFile(filePath: string, content: string): Promise<void> {
await fs.writeFile(filePath, content, 'utf-8');
}
}

View File

@ -36,6 +36,7 @@ import os from 'os';
import { ApprovalMode, 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';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
describe('EditTool', () => { describe('EditTool', () => {
let tool: EditTool; let tool: EditTool;
@ -60,6 +61,7 @@ describe('EditTool', () => {
getApprovalMode: vi.fn(), getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
getWorkspaceContext: () => createMockWorkspaceContext(rootDir), getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
getFileSystemService: () => new StandardFileSystemService(),
getIdeClient: () => undefined, getIdeClient: () => undefined,
getIdeMode: () => false, getIdeMode: () => false,
// getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method // getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method

View File

@ -125,7 +125,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
| undefined = undefined; | undefined = undefined;
try { try {
currentContent = fs.readFileSync(params.file_path, 'utf8'); currentContent = await this.config
.getFileSystemService()
.readTextFile(params.file_path);
// Normalize line endings to LF for consistent processing. // Normalize line endings to LF for consistent processing.
currentContent = currentContent.replace(/\r\n/g, '\n'); currentContent = currentContent.replace(/\r\n/g, '\n');
fileExists = true; fileExists = true;
@ -339,7 +341,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
try { try {
this.ensureParentDirectoriesExist(this.params.file_path); this.ensureParentDirectoriesExist(this.params.file_path);
fs.writeFileSync(this.params.file_path, editData.newContent, 'utf8'); await this.config
.getFileSystemService()
.writeTextFile(this.params.file_path, editData.newContent);
let displayResult: ToolResultDisplay; let displayResult: ToolResultDisplay;
if (editData.isNewFile) { if (editData.isNewFile) {
@ -504,7 +508,9 @@ Expectation for required parameters:
getFilePath: (params: EditToolParams) => params.file_path, getFilePath: (params: EditToolParams) => params.file_path,
getCurrentContent: async (params: EditToolParams): Promise<string> => { getCurrentContent: async (params: EditToolParams): Promise<string> => {
try { try {
return fs.readFileSync(params.file_path, 'utf8'); return this.config
.getFileSystemService()
.readTextFile(params.file_path);
} catch (err) { } catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err; if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
return ''; return '';
@ -512,7 +518,9 @@ Expectation for required parameters:
}, },
getProposedContent: async (params: EditToolParams): Promise<string> => { getProposedContent: async (params: EditToolParams): Promise<string> => {
try { try {
const currentContent = fs.readFileSync(params.file_path, 'utf8'); const currentContent = await this.config
.getFileSystemService()
.readTextFile(params.file_path);
return applyReplacement( return applyReplacement(
currentContent, currentContent,
params.old_string, params.old_string,

View File

@ -13,6 +13,7 @@ import fs from 'fs';
import fsp from 'fs/promises'; import fsp from 'fs/promises';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolInvocation, ToolResult } from './tools.js'; import { ToolInvocation, ToolResult } from './tools.js';
@ -29,6 +30,7 @@ describe('ReadFileTool', () => {
const mockConfigInstance = { const mockConfigInstance = {
getFileService: () => new FileDiscoveryService(tempRootDir), getFileService: () => new FileDiscoveryService(tempRootDir),
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => tempRootDir, getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
} as unknown as Config; } as unknown as Config;

View File

@ -74,6 +74,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
const result = await processSingleFileContent( const result = await processSingleFileContent(
this.params.absolute_path, this.params.absolute_path,
this.config.getTargetDir(), this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.offset, this.params.offset,
this.params.limit, this.params.limit,
); );

View File

@ -14,6 +14,7 @@ import fs from 'fs'; // Actual fs for setup
import os from 'os'; import os from 'os';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js'; import { WorkspaceContext } from '../utils/workspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
vi.mock('mime-types', () => { vi.mock('mime-types', () => {
const lookup = (filename: string) => { const lookup = (filename: string) => {
@ -59,6 +60,7 @@ describe('ReadManyFilesTool', () => {
const fileService = new FileDiscoveryService(tempRootDir); const fileService = new FileDiscoveryService(tempRootDir);
const mockConfig = { const mockConfig = {
getFileService: () => fileService, getFileService: () => fileService,
getFileSystemService: () => new StandardFileSystemService(),
getFileFilteringOptions: () => ({ getFileFilteringOptions: () => ({
respectGitIgnore: true, respectGitIgnore: true,
@ -456,6 +458,7 @@ describe('ReadManyFilesTool', () => {
const fileService = new FileDiscoveryService(tempDir1); const fileService = new FileDiscoveryService(tempDir1);
const mockConfig = { const mockConfig = {
getFileService: () => fileService, getFileService: () => fileService,
getFileSystemService: () => new StandardFileSystemService(),
getFileFilteringOptions: () => ({ getFileFilteringOptions: () => ({
respectGitIgnore: true, respectGitIgnore: true,
respectGeminiIgnore: true, respectGeminiIgnore: true,

View File

@ -388,6 +388,7 @@ ${finalExclusionPatternsForDescription
const fileReadResult = await processSingleFileContent( const fileReadResult = await processSingleFileContent(
filePath, filePath,
this.config.getTargetDir(), this.config.getTargetDir(),
this.config.getFileSystemService(),
); );
if (fileReadResult.error) { if (fileReadResult.error) {

View File

@ -37,6 +37,7 @@ import {
CorrectedEditResult, CorrectedEditResult,
} from '../utils/editCorrector.js'; } from '../utils/editCorrector.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root'); const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
@ -55,11 +56,13 @@ vi.mocked(ensureCorrectFileContent).mockImplementation(
); );
// Mock Config // Mock Config
const fsService = new StandardFileSystemService();
const mockConfigInternal = { const mockConfigInternal = {
getTargetDir: () => rootDir, getTargetDir: () => rootDir,
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(), setApprovalMode: vi.fn(),
getGeminiClient: vi.fn(), // Initialize as a plain mock function getGeminiClient: vi.fn(), // Initialize as a plain mock function
getFileSystemService: () => fsService,
getIdeClient: vi.fn(), getIdeClient: vi.fn(),
getIdeMode: vi.fn(() => false), getIdeMode: vi.fn(() => false),
getWorkspaceContext: () => createMockWorkspaceContext(rootDir), getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
@ -316,10 +319,9 @@ describe('WriteFileTool', () => {
fs.writeFileSync(filePath, 'content', { mode: 0o000 }); fs.writeFileSync(filePath, 'content', { mode: 0o000 });
const readError = new Error('Permission denied'); const readError = new Error('Permission denied');
const originalReadFileSync = fs.readFileSync; vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() =>
vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { Promise.reject(readError),
throw readError; );
});
const result = await getCorrectedFileContent( const result = await getCorrectedFileContent(
mockConfig, mockConfig,
@ -328,7 +330,7 @@ describe('WriteFileTool', () => {
abortSignal, abortSignal,
); );
expect(fs.readFileSync).toHaveBeenCalledWith(filePath, 'utf8'); expect(fsService.readTextFile).toHaveBeenCalledWith(filePath);
expect(mockEnsureCorrectEdit).not.toHaveBeenCalled(); expect(mockEnsureCorrectEdit).not.toHaveBeenCalled();
expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled(); expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
expect(result.correctedContent).toBe(proposedContent); expect(result.correctedContent).toBe(proposedContent);
@ -339,7 +341,6 @@ describe('WriteFileTool', () => {
code: undefined, code: undefined,
}); });
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
fs.chmodSync(filePath, 0o600); fs.chmodSync(filePath, 0o600);
}); });
}); });
@ -353,16 +354,14 @@ describe('WriteFileTool', () => {
fs.writeFileSync(filePath, 'original', { mode: 0o000 }); fs.writeFileSync(filePath, 'original', { mode: 0o000 });
const readError = new Error('Simulated read error for confirmation'); const readError = new Error('Simulated read error for confirmation');
const originalReadFileSync = fs.readFileSync; vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() =>
vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { Promise.reject(readError),
throw readError; );
});
const invocation = tool.build(params); const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(abortSignal); const confirmation = await invocation.shouldConfirmExecute(abortSignal);
expect(confirmation).toBe(false); expect(confirmation).toBe(false);
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
fs.chmodSync(filePath, 0o600); fs.chmodSync(filePath, 0o600);
}); });
@ -453,15 +452,14 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: 'test content' }; const params = { file_path: filePath, content: 'test content' };
fs.writeFileSync(filePath, 'original', { mode: 0o000 }); fs.writeFileSync(filePath, 'original', { mode: 0o000 });
const readError = new Error('Simulated read error for execute'); vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() => {
const originalReadFileSync = fs.readFileSync; const readError = new Error('Simulated read error for execute');
vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { return Promise.reject(readError);
throw readError;
}); });
const invocation = tool.build(params); const invocation = tool.build(params);
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Error checking existing file:'); expect(result.llmContent).toContain('Error checking existing file');
expect(result.returnDisplay).toMatch( expect(result.returnDisplay).toMatch(
/Error checking existing file: Simulated read error for execute/, /Error checking existing file: Simulated read error for execute/,
); );
@ -471,7 +469,6 @@ describe('WriteFileTool', () => {
type: ToolErrorType.FILE_WRITE_FAILURE, type: ToolErrorType.FILE_WRITE_FAILURE,
}); });
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
fs.chmodSync(filePath, 0o600); fs.chmodSync(filePath, 0o600);
}); });
@ -504,7 +501,8 @@ describe('WriteFileTool', () => {
/Successfully created and wrote to new file/, /Successfully created and wrote to new file/,
); );
expect(fs.existsSync(filePath)).toBe(true); expect(fs.existsSync(filePath)).toBe(true);
expect(fs.readFileSync(filePath, 'utf8')).toBe(correctedContent); const writtenContent = await fsService.readTextFile(filePath);
expect(writtenContent).toBe(correctedContent);
const display = result.returnDisplay as FileDiff; const display = result.returnDisplay as FileDiff;
expect(display.fileName).toBe('execute_new_corrected_file.txt'); expect(display.fileName).toBe('execute_new_corrected_file.txt');
expect(display.fileDiff).toMatch( expect(display.fileDiff).toMatch(
@ -563,7 +561,8 @@ describe('WriteFileTool', () => {
abortSignal, abortSignal,
); );
expect(result.llmContent).toMatch(/Successfully overwrote file/); expect(result.llmContent).toMatch(/Successfully overwrote file/);
expect(fs.readFileSync(filePath, 'utf8')).toBe(correctedProposedContent); const writtenContent = await fsService.readTextFile(filePath);
expect(writtenContent).toBe(correctedProposedContent);
const display = result.returnDisplay as FileDiff; const display = result.returnDisplay as FileDiff;
expect(display.fileName).toBe('execute_existing_corrected_file.txt'); expect(display.fileName).toBe('execute_existing_corrected_file.txt');
expect(display.fileDiff).toMatch( expect(display.fileDiff).toMatch(
@ -675,12 +674,11 @@ describe('WriteFileTool', () => {
const filePath = path.join(rootDir, 'permission_denied_file.txt'); const filePath = path.join(rootDir, 'permission_denied_file.txt');
const content = 'test content'; const content = 'test content';
// Mock writeFileSync to throw EACCES error // Mock FileSystemService writeTextFile to throw EACCES error
const originalWriteFileSync = fs.writeFileSync; vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {
vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => {
const error = new Error('Permission denied') as NodeJS.ErrnoException; const error = new Error('Permission denied') as NodeJS.ErrnoException;
error.code = 'EACCES'; error.code = 'EACCES';
throw error; return Promise.reject(error);
}); });
const params = { file_path: filePath, content }; const params = { file_path: filePath, content };
@ -694,22 +692,19 @@ describe('WriteFileTool', () => {
expect(result.returnDisplay).toContain( expect(result.returnDisplay).toContain(
`Permission denied writing to file: ${filePath} (EACCES)`, `Permission denied writing to file: ${filePath} (EACCES)`,
); );
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
}); });
it('should return NO_SPACE_LEFT error when write fails with ENOSPC', async () => { it('should return NO_SPACE_LEFT error when write fails with ENOSPC', async () => {
const filePath = path.join(rootDir, 'no_space_file.txt'); const filePath = path.join(rootDir, 'no_space_file.txt');
const content = 'test content'; const content = 'test content';
// Mock writeFileSync to throw ENOSPC error // Mock FileSystemService writeTextFile to throw ENOSPC error
const originalWriteFileSync = fs.writeFileSync; vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {
vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => {
const error = new Error( const error = new Error(
'No space left on device', 'No space left on device',
) as NodeJS.ErrnoException; ) as NodeJS.ErrnoException;
error.code = 'ENOSPC'; error.code = 'ENOSPC';
throw error; return Promise.reject(error);
}); });
const params = { file_path: filePath, content }; const params = { file_path: filePath, content };
@ -723,8 +718,6 @@ describe('WriteFileTool', () => {
expect(result.returnDisplay).toContain( expect(result.returnDisplay).toContain(
`No space left on device: ${filePath} (ENOSPC)`, `No space left on device: ${filePath} (ENOSPC)`,
); );
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
}); });
it('should return TARGET_IS_DIRECTORY error when write fails with EISDIR', async () => { it('should return TARGET_IS_DIRECTORY error when write fails with EISDIR', async () => {
@ -740,12 +733,11 @@ describe('WriteFileTool', () => {
return originalExistsSync(path as string); return originalExistsSync(path as string);
}); });
// Mock writeFileSync to throw EISDIR error // Mock FileSystemService writeTextFile to throw EISDIR error
const originalWriteFileSync = fs.writeFileSync; vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {
vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => {
const error = new Error('Is a directory') as NodeJS.ErrnoException; const error = new Error('Is a directory') as NodeJS.ErrnoException;
error.code = 'EISDIR'; error.code = 'EISDIR';
throw error; return Promise.reject(error);
}); });
const params = { file_path: dirPath, content }; const params = { file_path: dirPath, content };
@ -761,7 +753,6 @@ describe('WriteFileTool', () => {
); );
vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync); vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync);
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
}); });
it('should return FILE_WRITE_FAILURE for generic write errors', async () => { it('should return FILE_WRITE_FAILURE for generic write errors', async () => {
@ -771,10 +762,10 @@ describe('WriteFileTool', () => {
// Ensure fs.existsSync is not mocked for this test // Ensure fs.existsSync is not mocked for this test
vi.restoreAllMocks(); vi.restoreAllMocks();
// Mock writeFileSync to throw generic error // Mock FileSystemService writeTextFile to throw generic error
vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() =>
throw new Error('Generic write error'); Promise.reject(new Error('Generic write error')),
}); );
const params = { file_path: filePath, content }; const params = { file_path: filePath, content };
const invocation = tool.build(params); const invocation = tool.build(params);

View File

@ -80,7 +80,9 @@ export async function getCorrectedFileContent(
let correctedContent = proposedContent; let correctedContent = proposedContent;
try { try {
originalContent = fs.readFileSync(filePath, 'utf8'); originalContent = await config
.getFileSystemService()
.readTextFile(filePath);
fileExists = true; // File exists and was read fileExists = true; // File exists and was read
} catch (err) { } catch (err) {
if (isNodeError(err) && err.code === 'ENOENT') { if (isNodeError(err) && err.code === 'ENOENT') {
@ -261,7 +263,9 @@ class WriteFileToolInvocation extends BaseToolInvocation<
fs.mkdirSync(dirName, { recursive: true }); fs.mkdirSync(dirName, { recursive: true });
} }
fs.writeFileSync(file_path, fileContent, 'utf8'); await this.config
.getFileSystemService()
.writeTextFile(file_path, fileContent);
// Generate diff for display result // Generate diff for display result
const fileName = path.basename(file_path); const fileName = path.basename(file_path);

View File

@ -26,6 +26,7 @@ import {
detectFileType, detectFileType,
processSingleFileContent, processSingleFileContent,
} from './fileUtils.js'; } from './fileUtils.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
vi.mock('mime-types', () => ({ vi.mock('mime-types', () => ({
default: { lookup: vi.fn() }, default: { lookup: vi.fn() },
@ -280,6 +281,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.llmContent).toBe(content); expect(result.llmContent).toBe(content);
expect(result.returnDisplay).toBe(''); expect(result.returnDisplay).toBe('');
@ -290,6 +292,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
nonexistentFilePath, nonexistentFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.error).toContain('File not found'); expect(result.error).toContain('File not found');
expect(result.returnDisplay).toContain('File not found'); expect(result.returnDisplay).toContain('File not found');
@ -303,6 +306,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.error).toContain('Simulated read error'); expect(result.error).toContain('Simulated read error');
expect(result.returnDisplay).toContain('Simulated read error'); expect(result.returnDisplay).toContain('Simulated read error');
@ -317,6 +321,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testImageFilePath, testImageFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.error).toContain('Simulated image read error'); expect(result.error).toContain('Simulated image read error');
expect(result.returnDisplay).toContain('Simulated image read error'); expect(result.returnDisplay).toContain('Simulated image read error');
@ -329,6 +334,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testImageFilePath, testImageFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect( expect(
(result.llmContent as { inlineData: unknown }).inlineData, (result.llmContent as { inlineData: unknown }).inlineData,
@ -350,6 +356,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testPdfFilePath, testPdfFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect( expect(
(result.llmContent as { inlineData: unknown }).inlineData, (result.llmContent as { inlineData: unknown }).inlineData,
@ -378,6 +385,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testSvgFilePath, testSvgFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.llmContent).toBe(svgContent); expect(result.llmContent).toBe(svgContent);
@ -395,6 +403,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testBinaryFilePath, testBinaryFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.llmContent).toContain( expect(result.llmContent).toContain(
'Cannot display content of binary file', 'Cannot display content of binary file',
@ -403,7 +412,11 @@ describe('fileUtils', () => {
}); });
it('should handle path being a directory', async () => { it('should handle path being a directory', async () => {
const result = await processSingleFileContent(directoryPath, tempRootDir); const result = await processSingleFileContent(
directoryPath,
tempRootDir,
new StandardFileSystemService(),
);
expect(result.error).toContain('Path is a directory'); expect(result.error).toContain('Path is a directory');
expect(result.returnDisplay).toContain('Path is a directory'); expect(result.returnDisplay).toContain('Path is a directory');
}); });
@ -415,6 +428,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
5, 5,
5, 5,
); // Read lines 6-10 ); // Read lines 6-10
@ -435,6 +449,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
10, 10,
10, 10,
); );
@ -454,6 +469,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
0, 0,
10, 10,
); );
@ -476,6 +492,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.llmContent).toContain('Short line'); expect(result.llmContent).toContain('Short line');
@ -497,6 +514,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
0, 0,
5, 5,
); );
@ -515,6 +533,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
0, 0,
11, 11,
); );
@ -540,6 +559,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
0, 0,
10, 10,
); );
@ -558,6 +578,7 @@ describe('fileUtils', () => {
const result = await processSingleFileContent( const result = await processSingleFileContent(
testTextFilePath, testTextFilePath,
tempRootDir, tempRootDir,
new StandardFileSystemService(),
); );
expect(result.error).toContain('File size exceeds the 20MB limit'); expect(result.error).toContain('File size exceeds the 20MB limit');

View File

@ -8,6 +8,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { PartUnion } from '@google/genai'; import { PartUnion } from '@google/genai';
import mime from 'mime-types'; import mime from 'mime-types';
import { FileSystemService } from '../services/fileSystemService.js';
// Constants for text file processing // Constants for text file processing
const DEFAULT_MAX_LINES_TEXT_FILE = 2000; const DEFAULT_MAX_LINES_TEXT_FILE = 2000;
@ -223,6 +224,7 @@ export interface ProcessedFileReadResult {
export async function processSingleFileContent( export async function processSingleFileContent(
filePath: string, filePath: string,
rootDirectory: string, rootDirectory: string,
fileSystemService: FileSystemService,
offset?: number, offset?: number,
limit?: number, limit?: number,
): Promise<ProcessedFileReadResult> { ): Promise<ProcessedFileReadResult> {
@ -279,14 +281,14 @@ export async function processSingleFileContent(
returnDisplay: `Skipped large SVG file (>1MB): ${relativePathForDisplay}`, returnDisplay: `Skipped large SVG file (>1MB): ${relativePathForDisplay}`,
}; };
} }
const content = await fs.promises.readFile(filePath, 'utf8'); const content = await fileSystemService.readTextFile(filePath);
return { return {
llmContent: content, llmContent: content,
returnDisplay: `Read SVG as text: ${relativePathForDisplay}`, returnDisplay: `Read SVG as text: ${relativePathForDisplay}`,
}; };
} }
case 'text': { case 'text': {
const content = await fs.promises.readFile(filePath, 'utf8'); const content = await fileSystemService.readTextFile(filePath);
const lines = content.split('\n'); const lines = content.split('\n');
const originalLineCount = lines.length; const originalLineCount = lines.length;