Read and write files through Zed (#6169)
Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
parent
4394b6ab4f
commit
fb3ceb0da4
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<string, Session> = 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<acp.InitializeResponse> {
|
||||
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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -125,7 +125,9 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
|||
| 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<EditToolParams, ToolResult> {
|
|||
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -388,6 +388,7 @@ ${finalExclusionPatternsForDescription
|
|||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
this.config.getFileSystemService(),
|
||||
);
|
||||
|
||||
if (fileReadResult.error) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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<ProcessedFileReadResult> {
|
||||
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in New Issue