diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 583c0b2e..5509d9ff 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -11,6 +11,7 @@ import { FileDiscoveryService, GlobTool, ReadManyFilesTool, + StandardFileSystemService, ToolRegistry, } from '@google/gemini-cli-core'; import * as os from 'os'; @@ -56,6 +57,7 @@ describe('handleAtCommand', () => { respectGitIgnore: true, respectGeminiIgnore: true, }), + getFileSystemService: () => new StandardFileSystemService(), getEnableRecursiveFileSearch: vi.fn(() => true), getWorkspaceContext: () => ({ isPathWithinWorkspace: () => true, diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/zed-integration/fileSystemService.ts new file mode 100644 index 00000000..deb9857f --- /dev/null +++ b/packages/cli/src/zed-integration/fileSystemService.ts @@ -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 { + 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 { + if (!this.capabilities.writeTextFile) { + return this.fallback.writeTextFile(filePath, content); + } + + await this.client.writeTextFile({ + path: filePath, + content, + sessionId: this.sessionId, + }); + } +} diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 1b5baa8a..6adaeb70 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -24,6 +24,7 @@ import { MCPServerConfig, } from '@google/gemini-cli-core'; import * as acp from './acp.js'; +import { AcpFileSystemService } from './fileSystemService.js'; import { Readable, Writable } from 'node:stream'; import { Content, Part, FunctionCall, PartListUnion } from '@google/genai'; import { LoadedSettings, SettingScope } from '../config/settings.js'; @@ -60,6 +61,7 @@ export async function runZedIntegration( class GeminiAgent { private sessions: Map = new Map(); + private clientCapabilities: acp.ClientCapabilities | undefined; constructor( private config: Config, @@ -70,8 +72,9 @@ class GeminiAgent { ) {} async initialize( - _args: acp.InitializeRequest, + args: acp.InitializeRequest, ): Promise { + this.clientCapabilities = args.clientCapabilities; const authMethods = [ { id: AuthType.LOGIN_WITH_GOOGLE, @@ -129,6 +132,16 @@ class GeminiAgent { 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 chat = await geminiClient.startChat(); const session = new Session(sessionId, chat, config, this.client); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 16485bc4..ad4f8ed9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -47,6 +47,10 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import { IdeClient } from '../ide/ide-client.js'; import type { Content } from '@google/genai'; +import { + FileSystemService, + StandardFileSystemService, +} from '../services/fileSystemService.js'; import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js'; import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js'; @@ -204,6 +208,7 @@ export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private readonly sessionId: string; + private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private readonly embeddingModel: string; private readonly sandbox: SandboxConfig | undefined; @@ -268,6 +273,7 @@ export class Config { this.sessionId = params.sessionId; this.embeddingModel = params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; + this.fileSystemService = new StandardFileSystemService(); this.sandbox = params.sandbox; this.targetDir = path.resolve(params.targetDir); this.workspaceContext = new WorkspaceContext( @@ -700,6 +706,20 @@ export class Config { 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 { return this.chatCompression; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e2cefddd..82ffa1ef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,7 @@ export * from './utils/errorParsing.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/fileSystemService.js'; // Export IDE specific logic export * from './ide/ide-client.js'; diff --git a/packages/core/src/services/fileSystemService.test.ts b/packages/core/src/services/fileSystemService.test.ts new file mode 100644 index 00000000..c61ec066 --- /dev/null +++ b/packages/core/src/services/fileSystemService.test.ts @@ -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', + ); + }); + }); +}); diff --git a/packages/core/src/services/fileSystemService.ts b/packages/core/src/services/fileSystemService.ts new file mode 100644 index 00000000..e2f30cf4 --- /dev/null +++ b/packages/core/src/services/fileSystemService.ts @@ -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; + + /** + * 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; +} + +/** + * Standard file system implementation + */ +export class StandardFileSystemService implements FileSystemService { + async readTextFile(filePath: string): Promise { + return fs.readFile(filePath, 'utf-8'); + } + + async writeTextFile(filePath: string, content: string): Promise { + await fs.writeFile(filePath, content, 'utf-8'); + } +} diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index b2e31fdd..539ae3ef 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -36,6 +36,7 @@ import os from 'os'; import { ApprovalMode, Config } from '../config/config.js'; import { Content, Part, SchemaUnion } from '@google/genai'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; describe('EditTool', () => { let tool: EditTool; @@ -60,6 +61,7 @@ describe('EditTool', () => { getApprovalMode: vi.fn(), setApprovalMode: vi.fn(), getWorkspaceContext: () => createMockWorkspaceContext(rootDir), + getFileSystemService: () => new StandardFileSystemService(), getIdeClient: () => undefined, getIdeMode: () => false, // getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 8d90dfe4..35e828b0 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -125,7 +125,9 @@ class EditToolInvocation implements ToolInvocation { | undefined = undefined; 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. currentContent = currentContent.replace(/\r\n/g, '\n'); fileExists = true; @@ -339,7 +341,9 @@ class EditToolInvocation implements ToolInvocation { try { 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; if (editData.isNewFile) { @@ -504,7 +508,9 @@ Expectation for required parameters: getFilePath: (params: EditToolParams) => params.file_path, getCurrentContent: async (params: EditToolParams): Promise => { try { - return fs.readFileSync(params.file_path, 'utf8'); + return this.config + .getFileSystemService() + .readTextFile(params.file_path); } catch (err) { if (!isNodeError(err) || err.code !== 'ENOENT') throw err; return ''; @@ -512,7 +518,9 @@ Expectation for required parameters: }, getProposedContent: async (params: EditToolParams): Promise => { try { - const currentContent = fs.readFileSync(params.file_path, 'utf8'); + const currentContent = await this.config + .getFileSystemService() + .readTextFile(params.file_path); return applyReplacement( currentContent, params.old_string, diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 101b74a5..b7b323cf 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -13,6 +13,7 @@ import fs from 'fs'; import fsp from 'fs/promises'; import { Config } from '../config/config.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { ToolInvocation, ToolResult } from './tools.js'; @@ -29,6 +30,7 @@ describe('ReadFileTool', () => { const mockConfigInstance = { getFileService: () => new FileDiscoveryService(tempRootDir), + getFileSystemService: () => new StandardFileSystemService(), getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), } as unknown as Config; diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index f02db506..dde3cc0c 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -74,6 +74,7 @@ class ReadFileToolInvocation extends BaseToolInvocation< const result = await processSingleFileContent( this.params.absolute_path, this.config.getTargetDir(), + this.config.getFileSystemService(), this.params.offset, this.params.limit, ); diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index af5012cd..a57b3851 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -14,6 +14,7 @@ import fs from 'fs'; // Actual fs for setup import os from 'os'; import { Config } from '../config/config.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; vi.mock('mime-types', () => { const lookup = (filename: string) => { @@ -59,6 +60,7 @@ describe('ReadManyFilesTool', () => { const fileService = new FileDiscoveryService(tempRootDir); const mockConfig = { getFileService: () => fileService, + getFileSystemService: () => new StandardFileSystemService(), getFileFilteringOptions: () => ({ respectGitIgnore: true, @@ -456,6 +458,7 @@ describe('ReadManyFilesTool', () => { const fileService = new FileDiscoveryService(tempDir1); const mockConfig = { getFileService: () => fileService, + getFileSystemService: () => new StandardFileSystemService(), getFileFilteringOptions: () => ({ respectGitIgnore: true, respectGeminiIgnore: true, diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index aaf524c4..46aab23d 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -388,6 +388,7 @@ ${finalExclusionPatternsForDescription const fileReadResult = await processSingleFileContent( filePath, this.config.getTargetDir(), + this.config.getFileSystemService(), ); if (fileReadResult.error) { diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 2d877115..e5d5ece9 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -37,6 +37,7 @@ import { CorrectedEditResult, } from '../utils/editCorrector.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root'); @@ -55,11 +56,13 @@ vi.mocked(ensureCorrectFileContent).mockImplementation( ); // Mock Config +const fsService = new StandardFileSystemService(); const mockConfigInternal = { getTargetDir: () => rootDir, getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn(), getGeminiClient: vi.fn(), // Initialize as a plain mock function + getFileSystemService: () => fsService, getIdeClient: vi.fn(), getIdeMode: vi.fn(() => false), getWorkspaceContext: () => createMockWorkspaceContext(rootDir), @@ -316,10 +319,9 @@ describe('WriteFileTool', () => { fs.writeFileSync(filePath, 'content', { mode: 0o000 }); const readError = new Error('Permission denied'); - const originalReadFileSync = fs.readFileSync; - vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { - throw readError; - }); + vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() => + Promise.reject(readError), + ); const result = await getCorrectedFileContent( mockConfig, @@ -328,7 +330,7 @@ describe('WriteFileTool', () => { abortSignal, ); - expect(fs.readFileSync).toHaveBeenCalledWith(filePath, 'utf8'); + expect(fsService.readTextFile).toHaveBeenCalledWith(filePath); expect(mockEnsureCorrectEdit).not.toHaveBeenCalled(); expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled(); expect(result.correctedContent).toBe(proposedContent); @@ -339,7 +341,6 @@ describe('WriteFileTool', () => { code: undefined, }); - vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync); fs.chmodSync(filePath, 0o600); }); }); @@ -353,16 +354,14 @@ describe('WriteFileTool', () => { fs.writeFileSync(filePath, 'original', { mode: 0o000 }); const readError = new Error('Simulated read error for confirmation'); - const originalReadFileSync = fs.readFileSync; - vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { - throw readError; - }); + vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() => + Promise.reject(readError), + ); const invocation = tool.build(params); const confirmation = await invocation.shouldConfirmExecute(abortSignal); expect(confirmation).toBe(false); - vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync); fs.chmodSync(filePath, 0o600); }); @@ -453,15 +452,14 @@ describe('WriteFileTool', () => { const params = { file_path: filePath, content: 'test content' }; fs.writeFileSync(filePath, 'original', { mode: 0o000 }); - const readError = new Error('Simulated read error for execute'); - const originalReadFileSync = fs.readFileSync; - vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => { - throw readError; + vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() => { + const readError = new Error('Simulated read error for execute'); + return Promise.reject(readError); }); const invocation = tool.build(params); 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( /Error checking existing file: Simulated read error for execute/, ); @@ -471,7 +469,6 @@ describe('WriteFileTool', () => { type: ToolErrorType.FILE_WRITE_FAILURE, }); - vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync); fs.chmodSync(filePath, 0o600); }); @@ -504,7 +501,8 @@ describe('WriteFileTool', () => { /Successfully created and wrote to new file/, ); 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; expect(display.fileName).toBe('execute_new_corrected_file.txt'); expect(display.fileDiff).toMatch( @@ -563,7 +561,8 @@ describe('WriteFileTool', () => { abortSignal, ); 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; expect(display.fileName).toBe('execute_existing_corrected_file.txt'); expect(display.fileDiff).toMatch( @@ -675,12 +674,11 @@ describe('WriteFileTool', () => { const filePath = path.join(rootDir, 'permission_denied_file.txt'); const content = 'test content'; - // Mock writeFileSync to throw EACCES error - const originalWriteFileSync = fs.writeFileSync; - vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { + // Mock FileSystemService writeTextFile to throw EACCES error + vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => { const error = new Error('Permission denied') as NodeJS.ErrnoException; error.code = 'EACCES'; - throw error; + return Promise.reject(error); }); const params = { file_path: filePath, content }; @@ -694,22 +692,19 @@ describe('WriteFileTool', () => { expect(result.returnDisplay).toContain( `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 () => { const filePath = path.join(rootDir, 'no_space_file.txt'); const content = 'test content'; - // Mock writeFileSync to throw ENOSPC error - const originalWriteFileSync = fs.writeFileSync; - vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { + // Mock FileSystemService writeTextFile to throw ENOSPC error + vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => { const error = new Error( 'No space left on device', ) as NodeJS.ErrnoException; error.code = 'ENOSPC'; - throw error; + return Promise.reject(error); }); const params = { file_path: filePath, content }; @@ -723,8 +718,6 @@ describe('WriteFileTool', () => { expect(result.returnDisplay).toContain( `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 () => { @@ -740,12 +733,11 @@ describe('WriteFileTool', () => { return originalExistsSync(path as string); }); - // Mock writeFileSync to throw EISDIR error - const originalWriteFileSync = fs.writeFileSync; - vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { + // Mock FileSystemService writeTextFile to throw EISDIR error + vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => { const error = new Error('Is a directory') as NodeJS.ErrnoException; error.code = 'EISDIR'; - throw error; + return Promise.reject(error); }); const params = { file_path: dirPath, content }; @@ -761,7 +753,6 @@ describe('WriteFileTool', () => { ); vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync); - vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync); }); 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 vi.restoreAllMocks(); - // Mock writeFileSync to throw generic error - vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { - throw new Error('Generic write error'); - }); + // Mock FileSystemService writeTextFile to throw generic error + vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => + Promise.reject(new Error('Generic write error')), + ); const params = { file_path: filePath, content }; const invocation = tool.build(params); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index c889d6a3..57cc4646 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -80,7 +80,9 @@ export async function getCorrectedFileContent( let correctedContent = proposedContent; try { - originalContent = fs.readFileSync(filePath, 'utf8'); + originalContent = await config + .getFileSystemService() + .readTextFile(filePath); fileExists = true; // File exists and was read } catch (err) { if (isNodeError(err) && err.code === 'ENOENT') { @@ -261,7 +263,9 @@ class WriteFileToolInvocation extends BaseToolInvocation< fs.mkdirSync(dirName, { recursive: true }); } - fs.writeFileSync(file_path, fileContent, 'utf8'); + await this.config + .getFileSystemService() + .writeTextFile(file_path, fileContent); // Generate diff for display result const fileName = path.basename(file_path); diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index cfedfe27..7b3e3ca1 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -26,6 +26,7 @@ import { detectFileType, processSingleFileContent, } from './fileUtils.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; vi.mock('mime-types', () => ({ default: { lookup: vi.fn() }, @@ -280,6 +281,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.llmContent).toBe(content); expect(result.returnDisplay).toBe(''); @@ -290,6 +292,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( nonexistentFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.error).toContain('File not found'); expect(result.returnDisplay).toContain('File not found'); @@ -303,6 +306,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.error).toContain('Simulated read error'); expect(result.returnDisplay).toContain('Simulated read error'); @@ -317,6 +321,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testImageFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.error).toContain('Simulated image read error'); expect(result.returnDisplay).toContain('Simulated image read error'); @@ -329,6 +334,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testImageFilePath, tempRootDir, + new StandardFileSystemService(), ); expect( (result.llmContent as { inlineData: unknown }).inlineData, @@ -350,6 +356,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testPdfFilePath, tempRootDir, + new StandardFileSystemService(), ); expect( (result.llmContent as { inlineData: unknown }).inlineData, @@ -378,6 +385,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testSvgFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.llmContent).toBe(svgContent); @@ -395,6 +403,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testBinaryFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.llmContent).toContain( 'Cannot display content of binary file', @@ -403,7 +412,11 @@ describe('fileUtils', () => { }); 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.returnDisplay).toContain('Path is a directory'); }); @@ -415,6 +428,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), 5, 5, ); // Read lines 6-10 @@ -435,6 +449,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), 10, 10, ); @@ -454,6 +469,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), 0, 10, ); @@ -476,6 +492,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.llmContent).toContain('Short line'); @@ -497,6 +514,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), 0, 5, ); @@ -515,6 +533,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), 0, 11, ); @@ -540,6 +559,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), 0, 10, ); @@ -558,6 +578,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, tempRootDir, + new StandardFileSystemService(), ); expect(result.error).toContain('File size exceeds the 20MB limit'); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 92186e4f..8dfdbc22 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { PartUnion } from '@google/genai'; import mime from 'mime-types'; +import { FileSystemService } from '../services/fileSystemService.js'; // Constants for text file processing const DEFAULT_MAX_LINES_TEXT_FILE = 2000; @@ -223,6 +224,7 @@ export interface ProcessedFileReadResult { export async function processSingleFileContent( filePath: string, rootDirectory: string, + fileSystemService: FileSystemService, offset?: number, limit?: number, ): Promise { @@ -279,14 +281,14 @@ export async function processSingleFileContent( returnDisplay: `Skipped large SVG file (>1MB): ${relativePathForDisplay}`, }; } - const content = await fs.promises.readFile(filePath, 'utf8'); + const content = await fileSystemService.readTextFile(filePath); return { llmContent: content, returnDisplay: `Read SVG as text: ${relativePathForDisplay}`, }; } case 'text': { - const content = await fs.promises.readFile(filePath, 'utf8'); + const content = await fileSystemService.readTextFile(filePath); const lines = content.split('\n'); const originalLineCount = lines.length;