fix: properly report tool errors in telemetry (#5688)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
parent
5ab184fcaf
commit
f5e0f16157
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { ReadFileTool, ReadFileToolParams } from './read-file.js';
|
import { ReadFileTool, ReadFileToolParams } from './read-file.js';
|
||||||
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
@ -47,276 +48,390 @@ describe('ReadFileTool', () => {
|
||||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||||
};
|
};
|
||||||
const result = tool.build(params);
|
const result = tool.build(params);
|
||||||
expect(result).not.toBeTypeOf('string');
|
expect(typeof result).not.toBe('string');
|
||||||
expect(typeof result).toBe('object');
|
|
||||||
expect(
|
|
||||||
(result as ToolInvocation<ReadFileToolParams, ToolResult>).params,
|
|
||||||
).toEqual(params);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an invocation for valid params with offset and limit', () => {
|
it('should throw error if file path is relative', () => {
|
||||||
const params: ReadFileToolParams = {
|
const params: ReadFileToolParams = {
|
||||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
absolute_path: 'relative/path.txt',
|
||||||
offset: 0,
|
|
||||||
limit: 10,
|
|
||||||
};
|
};
|
||||||
const result = tool.build(params);
|
|
||||||
expect(result).not.toBeTypeOf('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for relative path', () => {
|
|
||||||
const params: ReadFileToolParams = { absolute_path: 'test.txt' };
|
|
||||||
expect(() => tool.build(params)).toThrow(
|
expect(() => tool.build(params)).toThrow(
|
||||||
`File path must be absolute, but was relative: test.txt. You must provide an absolute path.`,
|
'File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for path outside root', () => {
|
it('should throw error if path is outside root', () => {
|
||||||
const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt');
|
const params: ReadFileToolParams = {
|
||||||
const params: ReadFileToolParams = { absolute_path: outsidePath };
|
absolute_path: '/outside/root.txt',
|
||||||
|
};
|
||||||
expect(() => tool.build(params)).toThrow(
|
expect(() => tool.build(params)).toThrow(
|
||||||
'File path must be within one of the workspace directories',
|
/File path must be within one of the workspace directories/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for negative offset', () => {
|
it('should throw error if offset is negative', () => {
|
||||||
const params: ReadFileToolParams = {
|
const params: ReadFileToolParams = {
|
||||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||||
offset: -1,
|
offset: -1,
|
||||||
limit: 10,
|
|
||||||
};
|
};
|
||||||
expect(() => tool.build(params)).toThrow(
|
expect(() => tool.build(params)).toThrow(
|
||||||
'Offset must be a non-negative number',
|
'Offset must be a non-negative number',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for non-positive limit', () => {
|
it('should throw error if limit is zero or negative', () => {
|
||||||
const paramsZero: ReadFileToolParams = {
|
const params: ReadFileToolParams = {
|
||||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||||
offset: 0,
|
|
||||||
limit: 0,
|
limit: 0,
|
||||||
};
|
};
|
||||||
expect(() => tool.build(paramsZero)).toThrow(
|
|
||||||
'Limit must be a positive number',
|
|
||||||
);
|
|
||||||
const paramsNegative: ReadFileToolParams = {
|
|
||||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
|
||||||
offset: 0,
|
|
||||||
limit: -5,
|
|
||||||
};
|
|
||||||
expect(() => tool.build(paramsNegative)).toThrow(
|
|
||||||
'Limit must be a positive number',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for schema validation failure (e.g. missing path)', () => {
|
|
||||||
const params = { offset: 0 } as unknown as ReadFileToolParams;
|
|
||||||
expect(() => tool.build(params)).toThrow(
|
expect(() => tool.build(params)).toThrow(
|
||||||
`params must have required property 'absolute_path'`,
|
'Limit must be a positive number',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ToolInvocation', () => {
|
describe('getDescription', () => {
|
||||||
describe('getDescription', () => {
|
it('should return relative path without limit/offset', () => {
|
||||||
it('should return a shortened, relative path', () => {
|
const subDir = path.join(tempRootDir, 'sub', 'dir');
|
||||||
const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt');
|
const params: ReadFileToolParams = {
|
||||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
absolute_path: path.join(subDir, 'file.txt'),
|
||||||
const invocation = tool.build(params);
|
};
|
||||||
expect(typeof invocation).not.toBe('string');
|
const invocation = tool.build(params);
|
||||||
expect(
|
expect(typeof invocation).not.toBe('string');
|
||||||
(
|
expect(
|
||||||
invocation as ToolInvocation<ReadFileToolParams, ToolResult>
|
(
|
||||||
).getDescription(),
|
invocation as ToolInvocation<ReadFileToolParams, ToolResult>
|
||||||
).toBe(path.join('sub', 'dir', 'file.txt'));
|
).getDescription(),
|
||||||
});
|
).toBe(path.join('sub', 'dir', 'file.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
it('should return . if path is the root directory', () => {
|
it('should return shortened path when file path is deep', () => {
|
||||||
const params: ReadFileToolParams = { absolute_path: tempRootDir };
|
const deepPath = path.join(
|
||||||
const invocation = tool.build(params);
|
tempRootDir,
|
||||||
expect(typeof invocation).not.toBe('string');
|
'very',
|
||||||
expect(
|
'deep',
|
||||||
(
|
'directory',
|
||||||
invocation as ToolInvocation<ReadFileToolParams, ToolResult>
|
'structure',
|
||||||
).getDescription(),
|
'that',
|
||||||
).toBe('.');
|
'exceeds',
|
||||||
|
'the',
|
||||||
|
'normal',
|
||||||
|
'limit',
|
||||||
|
'file.txt',
|
||||||
|
);
|
||||||
|
const params: ReadFileToolParams = { absolute_path: deepPath };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
expect(typeof invocation).not.toBe('string');
|
||||||
|
const desc = (
|
||||||
|
invocation as ToolInvocation<ReadFileToolParams, ToolResult>
|
||||||
|
).getDescription();
|
||||||
|
expect(desc).toContain('...');
|
||||||
|
expect(desc).toContain('file.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-normalized file paths correctly', () => {
|
||||||
|
const subDir = path.join(tempRootDir, 'sub', 'dir');
|
||||||
|
const params: ReadFileToolParams = {
|
||||||
|
absolute_path: path.join(subDir, '..', 'dir', 'file.txt'),
|
||||||
|
};
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
expect(typeof invocation).not.toBe('string');
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
invocation as ToolInvocation<ReadFileToolParams, ToolResult>
|
||||||
|
).getDescription(),
|
||||||
|
).toBe(path.join('sub', 'dir', 'file.txt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return . if path is the root directory', () => {
|
||||||
|
const params: ReadFileToolParams = { absolute_path: tempRootDir };
|
||||||
|
const invocation = tool.build(params);
|
||||||
|
expect(typeof invocation).not.toBe('string');
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
invocation as ToolInvocation<ReadFileToolParams, ToolResult>
|
||||||
|
).getDescription(),
|
||||||
|
).toBe('.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should return error if file does not exist', async () => {
|
||||||
|
const filePath = path.join(tempRootDir, 'nonexistent.txt');
|
||||||
|
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
expect(result).toEqual({
|
||||||
|
llmContent:
|
||||||
|
'Could not read file because no file was found at the specified path.',
|
||||||
|
returnDisplay: 'File not found.',
|
||||||
|
error: {
|
||||||
|
message: `File not found: ${filePath}`,
|
||||||
|
type: ToolErrorType.FILE_NOT_FOUND,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('execute', () => {
|
it('should return success result for a text file', async () => {
|
||||||
it('should return error if file does not exist', async () => {
|
const filePath = path.join(tempRootDir, 'textfile.txt');
|
||||||
const filePath = path.join(tempRootDir, 'nonexistent.txt');
|
const fileContent = 'This is a test file.';
|
||||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||||
const invocation = tool.build(params) as ToolInvocation<
|
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||||
ReadFileToolParams,
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
ToolResult
|
ReadFileToolParams,
|
||||||
>;
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
expect(await invocation.execute(abortSignal)).toEqual({
|
expect(await invocation.execute(abortSignal)).toEqual({
|
||||||
llmContent: `File not found: ${filePath}`,
|
llmContent: fileContent,
|
||||||
returnDisplay: 'File not found.',
|
returnDisplay: '',
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return success result for a text file', async () => {
|
it('should return error if path is a directory', async () => {
|
||||||
const filePath = path.join(tempRootDir, 'textfile.txt');
|
const dirPath = path.join(tempRootDir, 'directory');
|
||||||
const fileContent = 'This is a test file.';
|
await fsp.mkdir(dirPath);
|
||||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
const params: ReadFileToolParams = { absolute_path: dirPath };
|
||||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
const invocation = tool.build(params) as ToolInvocation<
|
ReadFileToolParams,
|
||||||
ReadFileToolParams,
|
ToolResult
|
||||||
ToolResult
|
>;
|
||||||
>;
|
|
||||||
|
|
||||||
expect(await invocation.execute(abortSignal)).toEqual({
|
const result = await invocation.execute(abortSignal);
|
||||||
llmContent: fileContent,
|
expect(result).toEqual({
|
||||||
returnDisplay: '',
|
llmContent:
|
||||||
});
|
'Could not read file because the provided path is a directory, not a file.',
|
||||||
|
returnDisplay: 'Path is a directory.',
|
||||||
|
error: {
|
||||||
|
message: `Path is a directory, not a file: ${dirPath}`,
|
||||||
|
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return success result for an image file', async () => {
|
it('should return error for a file that is too large', async () => {
|
||||||
// A minimal 1x1 transparent PNG file.
|
const filePath = path.join(tempRootDir, 'largefile.txt');
|
||||||
const pngContent = Buffer.from([
|
// 21MB of content exceeds 20MB limit
|
||||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
|
const largeContent = 'x'.repeat(21 * 1024 * 1024);
|
||||||
1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68,
|
await fsp.writeFile(filePath, largeContent, 'utf-8');
|
||||||
65, 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0,
|
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||||
0, 73, 69, 78, 68, 174, 66, 96, 130,
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
]);
|
ReadFileToolParams,
|
||||||
const filePath = path.join(tempRootDir, 'image.png');
|
ToolResult
|
||||||
await fsp.writeFile(filePath, pngContent);
|
>;
|
||||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
|
||||||
const invocation = tool.build(params) as ToolInvocation<
|
|
||||||
ReadFileToolParams,
|
|
||||||
ToolResult
|
|
||||||
>;
|
|
||||||
|
|
||||||
expect(await invocation.execute(abortSignal)).toEqual({
|
const result = await invocation.execute(abortSignal);
|
||||||
llmContent: {
|
expect(result).toHaveProperty('error');
|
||||||
inlineData: {
|
expect(result.error?.type).toBe(ToolErrorType.FILE_TOO_LARGE);
|
||||||
mimeType: 'image/png',
|
expect(result.error?.message).toContain(
|
||||||
data: pngContent.toString('base64'),
|
'File size exceeds the 20MB limit',
|
||||||
},
|
);
|
||||||
},
|
});
|
||||||
returnDisplay: `Read image file: image.png`,
|
|
||||||
});
|
it('should handle text file with lines exceeding maximum length', async () => {
|
||||||
|
const filePath = path.join(tempRootDir, 'longlines.txt');
|
||||||
|
const longLine = 'a'.repeat(2500); // Exceeds MAX_LINE_LENGTH_TEXT_FILE (2000)
|
||||||
|
const fileContent = `Short line\n${longLine}\nAnother short line`;
|
||||||
|
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||||
|
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'IMPORTANT: The file content has been truncated',
|
||||||
|
);
|
||||||
|
expect(result.llmContent).toContain('--- FILE CONTENT (truncated) ---');
|
||||||
|
expect(result.returnDisplay).toContain('some lines were shortened');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle image file and return appropriate content', async () => {
|
||||||
|
const imagePath = path.join(tempRootDir, 'image.png');
|
||||||
|
// Minimal PNG header
|
||||||
|
const pngHeader = Buffer.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
]);
|
||||||
|
await fsp.writeFile(imagePath, pngHeader);
|
||||||
|
const params: ReadFileToolParams = { absolute_path: imagePath };
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
expect(result.llmContent).toEqual({
|
||||||
|
inlineData: {
|
||||||
|
data: pngHeader.toString('base64'),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
expect(result.returnDisplay).toBe('Read image file: image.png');
|
||||||
|
});
|
||||||
|
|
||||||
it('should treat a non-image file with image extension as an image', async () => {
|
it('should handle PDF file and return appropriate content', async () => {
|
||||||
const filePath = path.join(tempRootDir, 'fake-image.png');
|
const pdfPath = path.join(tempRootDir, 'document.pdf');
|
||||||
const fileContent = 'This is not a real png.';
|
// Minimal PDF header
|
||||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
const pdfHeader = Buffer.from('%PDF-1.4');
|
||||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
await fsp.writeFile(pdfPath, pdfHeader);
|
||||||
const invocation = tool.build(params) as ToolInvocation<
|
const params: ReadFileToolParams = { absolute_path: pdfPath };
|
||||||
ReadFileToolParams,
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
ToolResult
|
ReadFileToolParams,
|
||||||
>;
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
expect(await invocation.execute(abortSignal)).toEqual({
|
const result = await invocation.execute(abortSignal);
|
||||||
llmContent: {
|
expect(result.llmContent).toEqual({
|
||||||
inlineData: {
|
inlineData: {
|
||||||
mimeType: 'image/png',
|
data: pdfHeader.toString('base64'),
|
||||||
data: Buffer.from(fileContent).toString('base64'),
|
mimeType: 'application/pdf',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
returnDisplay: `Read image file: fake-image.png`,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
expect(result.returnDisplay).toBe('Read pdf file: document.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return a structured message when a slice of a text file is read', async () => {
|
it('should handle binary file and skip content', async () => {
|
||||||
const filePath = path.join(tempRootDir, 'paginated.txt');
|
const binPath = path.join(tempRootDir, 'binary.bin');
|
||||||
const fileContent = Array.from(
|
// Binary data with null bytes
|
||||||
{ length: 20 },
|
const binaryData = Buffer.from([0x00, 0xff, 0x00, 0xff]);
|
||||||
(_, i) => `Line ${i + 1}`,
|
await fsp.writeFile(binPath, binaryData);
|
||||||
).join('\n');
|
const params: ReadFileToolParams = { absolute_path: binPath };
|
||||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
const params: ReadFileToolParams = {
|
const result = await invocation.execute(abortSignal);
|
||||||
absolute_path: filePath,
|
expect(result.llmContent).toBe(
|
||||||
offset: 5, // Start from line 6
|
'Cannot display content of binary file: binary.bin',
|
||||||
limit: 3,
|
);
|
||||||
};
|
expect(result.returnDisplay).toBe('Skipped binary file: binary.bin');
|
||||||
const invocation = tool.build(params) as ToolInvocation<
|
});
|
||||||
ReadFileToolParams,
|
|
||||||
ToolResult
|
|
||||||
>;
|
|
||||||
|
|
||||||
const result = await invocation.execute(abortSignal);
|
it('should handle SVG file as text', async () => {
|
||||||
|
const svgPath = path.join(tempRootDir, 'image.svg');
|
||||||
|
const svgContent = '<svg><circle cx="50" cy="50" r="40"/></svg>';
|
||||||
|
await fsp.writeFile(svgPath, svgContent, 'utf-8');
|
||||||
|
const params: ReadFileToolParams = { absolute_path: svgPath };
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
const expectedLlmContent = `
|
const result = await invocation.execute(abortSignal);
|
||||||
IMPORTANT: The file content has been truncated.
|
expect(result.llmContent).toBe(svgContent);
|
||||||
Status: Showing lines 6-8 of 20 total lines.
|
expect(result.returnDisplay).toBe('Read SVG as text: image.svg');
|
||||||
Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 8.
|
});
|
||||||
|
|
||||||
--- FILE CONTENT (truncated) ---
|
it('should handle large SVG file', async () => {
|
||||||
Line 6
|
const svgPath = path.join(tempRootDir, 'large.svg');
|
||||||
Line 7
|
// Create SVG content larger than 1MB
|
||||||
Line 8`;
|
const largeContent = '<svg>' + 'x'.repeat(1024 * 1024 + 1) + '</svg>';
|
||||||
|
await fsp.writeFile(svgPath, largeContent, 'utf-8');
|
||||||
|
const params: ReadFileToolParams = { absolute_path: svgPath };
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
expect(result.llmContent).toEqual(expectedLlmContent);
|
const result = await invocation.execute(abortSignal);
|
||||||
expect(result.returnDisplay).toBe(
|
expect(result.llmContent).toBe(
|
||||||
'Read lines 6-8 of 20 from paginated.txt',
|
'Cannot display content of SVG file larger than 1MB: large.svg',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toBe(
|
||||||
|
'Skipped large SVG file (>1MB): large.svg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty file', async () => {
|
||||||
|
const emptyPath = path.join(tempRootDir, 'empty.txt');
|
||||||
|
await fsp.writeFile(emptyPath, '', 'utf-8');
|
||||||
|
const params: ReadFileToolParams = { absolute_path: emptyPath };
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
expect(result.llmContent).toBe('');
|
||||||
|
expect(result.returnDisplay).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support offset and limit for text files', async () => {
|
||||||
|
const filePath = path.join(tempRootDir, 'paginated.txt');
|
||||||
|
const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`);
|
||||||
|
const fileContent = lines.join('\n');
|
||||||
|
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||||
|
|
||||||
|
const params: ReadFileToolParams = {
|
||||||
|
absolute_path: filePath,
|
||||||
|
offset: 5, // Start from line 6
|
||||||
|
limit: 3,
|
||||||
|
};
|
||||||
|
const invocation = tool.build(params) as ToolInvocation<
|
||||||
|
ReadFileToolParams,
|
||||||
|
ToolResult
|
||||||
|
>;
|
||||||
|
|
||||||
|
const result = await invocation.execute(abortSignal);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'IMPORTANT: The file content has been truncated',
|
||||||
|
);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'Status: Showing lines 6-8 of 20 total lines',
|
||||||
|
);
|
||||||
|
expect(result.llmContent).toContain('Line 6');
|
||||||
|
expect(result.llmContent).toContain('Line 7');
|
||||||
|
expect(result.llmContent).toContain('Line 8');
|
||||||
|
expect(result.returnDisplay).toBe(
|
||||||
|
'Read lines 6-8 of 20 from paginated.txt',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with .geminiignore', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await fsp.writeFile(
|
||||||
|
path.join(tempRootDir, '.geminiignore'),
|
||||||
|
['foo.*', 'ignored/'].join('\n'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with .geminiignore', () => {
|
it('should throw error if path is ignored by a .geminiignore pattern', async () => {
|
||||||
beforeEach(async () => {
|
const ignoredFilePath = path.join(tempRootDir, 'foo.bar');
|
||||||
await fsp.writeFile(
|
await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
|
||||||
path.join(tempRootDir, '.geminiignore'),
|
const params: ReadFileToolParams = {
|
||||||
['foo.*', 'ignored/'].join('\n'),
|
absolute_path: ignoredFilePath,
|
||||||
);
|
};
|
||||||
});
|
const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`;
|
||||||
|
expect(() => tool.build(params)).toThrow(expectedError);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw error if path is ignored by a .geminiignore pattern', async () => {
|
it('should throw error if file is in an ignored directory', async () => {
|
||||||
const ignoredFilePath = path.join(tempRootDir, 'foo.bar');
|
const ignoredDirPath = path.join(tempRootDir, 'ignored');
|
||||||
await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
|
await fsp.mkdir(ignoredDirPath, { recursive: true });
|
||||||
const params: ReadFileToolParams = {
|
const ignoredFilePath = path.join(ignoredDirPath, 'file.txt');
|
||||||
absolute_path: ignoredFilePath,
|
await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
|
||||||
};
|
const params: ReadFileToolParams = {
|
||||||
const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`;
|
absolute_path: ignoredFilePath,
|
||||||
expect(() => tool.build(params)).toThrow(expectedError);
|
};
|
||||||
});
|
const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`;
|
||||||
|
expect(() => tool.build(params)).toThrow(expectedError);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw error if path is in an ignored directory', async () => {
|
it('should allow reading non-ignored files', async () => {
|
||||||
const ignoredDirPath = path.join(tempRootDir, 'ignored');
|
const allowedFilePath = path.join(tempRootDir, 'allowed.txt');
|
||||||
await fsp.mkdir(ignoredDirPath);
|
await fsp.writeFile(allowedFilePath, 'content', 'utf-8');
|
||||||
const filePath = path.join(ignoredDirPath, 'somefile.txt');
|
const params: ReadFileToolParams = {
|
||||||
await fsp.writeFile(filePath, 'content', 'utf-8');
|
absolute_path: allowedFilePath,
|
||||||
|
};
|
||||||
const params: ReadFileToolParams = {
|
const invocation = tool.build(params);
|
||||||
absolute_path: filePath,
|
expect(typeof invocation).not.toBe('string');
|
||||||
};
|
|
||||||
const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`;
|
|
||||||
expect(() => tool.build(params)).toThrow(expectedError);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('workspace boundary validation', () => {
|
|
||||||
it('should validate paths are within workspace root', () => {
|
|
||||||
const params: ReadFileToolParams = {
|
|
||||||
absolute_path: path.join(tempRootDir, 'file.txt'),
|
|
||||||
};
|
|
||||||
expect(() => tool.build(params)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject paths outside workspace root', () => {
|
|
||||||
const params: ReadFileToolParams = {
|
|
||||||
absolute_path: '/etc/passwd',
|
|
||||||
};
|
|
||||||
expect(() => tool.build(params)).toThrow(
|
|
||||||
'File path must be within one of the workspace directories',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should provide clear error message with workspace directories', () => {
|
|
||||||
const outsidePath = path.join(os.tmpdir(), 'outside-workspace.txt');
|
|
||||||
const params: ReadFileToolParams = {
|
|
||||||
absolute_path: outsidePath,
|
|
||||||
};
|
|
||||||
expect(() => tool.build(params)).toThrow(
|
|
||||||
'File path must be within one of the workspace directories',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ToolLocation,
|
ToolLocation,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { PartUnion, Type } from '@google/genai';
|
import { PartUnion, Type } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
processSingleFileContent,
|
processSingleFileContent,
|
||||||
|
@ -78,9 +79,45 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
// Map error messages to ToolErrorType
|
||||||
|
let errorType: ToolErrorType;
|
||||||
|
let llmContent: string;
|
||||||
|
|
||||||
|
// Check error message patterns to determine error type
|
||||||
|
if (
|
||||||
|
result.error.includes('File not found') ||
|
||||||
|
result.error.includes('does not exist') ||
|
||||||
|
result.error.includes('ENOENT')
|
||||||
|
) {
|
||||||
|
errorType = ToolErrorType.FILE_NOT_FOUND;
|
||||||
|
llmContent =
|
||||||
|
'Could not read file because no file was found at the specified path.';
|
||||||
|
} else if (
|
||||||
|
result.error.includes('is a directory') ||
|
||||||
|
result.error.includes('EISDIR')
|
||||||
|
) {
|
||||||
|
errorType = ToolErrorType.INVALID_TOOL_PARAMS;
|
||||||
|
llmContent =
|
||||||
|
'Could not read file because the provided path is a directory, not a file.';
|
||||||
|
} else if (
|
||||||
|
result.error.includes('too large') ||
|
||||||
|
result.error.includes('File size exceeds')
|
||||||
|
) {
|
||||||
|
errorType = ToolErrorType.FILE_TOO_LARGE;
|
||||||
|
llmContent = `Could not read file. ${result.error}`;
|
||||||
|
} else {
|
||||||
|
// Other read errors map to READ_CONTENT_FAILURE
|
||||||
|
errorType = ToolErrorType.READ_CONTENT_FAILURE;
|
||||||
|
llmContent = `Could not read file. ${result.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
llmContent: result.error, // The detailed error for LLM
|
llmContent,
|
||||||
returnDisplay: result.returnDisplay || 'Error reading file', // User-friendly error
|
returnDisplay: result.returnDisplay || 'Error reading file',
|
||||||
|
error: {
|
||||||
|
message: result.error,
|
||||||
|
type: errorType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ vi.mock('../utils/summarizer.js');
|
||||||
|
|
||||||
import { isCommandAllowed } from '../utils/shell-utils.js';
|
import { isCommandAllowed } from '../utils/shell-utils.js';
|
||||||
import { ShellTool } from './shell.js';
|
import { ShellTool } from './shell.js';
|
||||||
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { type Config } from '../config/config.js';
|
import { type Config } from '../config/config.js';
|
||||||
import {
|
import {
|
||||||
type ShellExecutionResult,
|
type ShellExecutionResult,
|
||||||
|
@ -203,6 +204,42 @@ describe('ShellTool', () => {
|
||||||
expect(result.llmContent).not.toContain('pgrep');
|
expect(result.llmContent).not.toContain('pgrep');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return error with error property for invalid parameters', async () => {
|
||||||
|
const result = await shellTool.execute(
|
||||||
|
{ command: '' }, // Empty command is invalid
|
||||||
|
mockAbortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'Could not execute command due to invalid parameters:',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toBe('Command cannot be empty.');
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
message: 'Command cannot be empty.',
|
||||||
|
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error with error property for invalid directory', async () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
const result = await shellTool.execute(
|
||||||
|
{ command: 'ls', directory: 'nonexistent' },
|
||||||
|
mockAbortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'Could not execute command due to invalid parameters:',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toBe(
|
||||||
|
"Directory 'nonexistent' is not a registered workspace directory.",
|
||||||
|
);
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
message:
|
||||||
|
"Directory 'nonexistent' is not a registered workspace directory.",
|
||||||
|
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should summarize output when configured', async () => {
|
it('should summarize output when configured', async () => {
|
||||||
(mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({
|
(mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({
|
||||||
[shellTool.name]: { tokenBudget: 1000 },
|
[shellTool.name]: { tokenBudget: 1000 },
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
Icon,
|
Icon,
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { Type } from '@google/genai';
|
import { Type } from '@google/genai';
|
||||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
|
@ -186,8 +187,12 @@ export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
|
||||||
});
|
});
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
return {
|
return {
|
||||||
llmContent: validationError,
|
llmContent: `Could not execute command due to invalid parameters: ${validationError}`,
|
||||||
returnDisplay: validationError,
|
returnDisplay: validationError,
|
||||||
|
error: {
|
||||||
|
message: validationError,
|
||||||
|
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,10 @@ export enum ToolErrorType {
|
||||||
FILE_WRITE_FAILURE = 'file_write_failure',
|
FILE_WRITE_FAILURE = 'file_write_failure',
|
||||||
READ_CONTENT_FAILURE = 'read_content_failure',
|
READ_CONTENT_FAILURE = 'read_content_failure',
|
||||||
ATTEMPT_TO_CREATE_EXISTING_FILE = 'attempt_to_create_existing_file',
|
ATTEMPT_TO_CREATE_EXISTING_FILE = 'attempt_to_create_existing_file',
|
||||||
|
FILE_TOO_LARGE = 'file_too_large',
|
||||||
|
PERMISSION_DENIED = 'permission_denied',
|
||||||
|
NO_SPACE_LEFT = 'no_space_left',
|
||||||
|
TARGET_IS_DIRECTORY = 'target_is_directory',
|
||||||
|
|
||||||
// Edit-specific Errors
|
// Edit-specific Errors
|
||||||
EDIT_PREPARATION_FAILURE = 'edit_preparation_failure',
|
EDIT_PREPARATION_FAILURE = 'edit_preparation_failure',
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
type Mocked,
|
type Mocked,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { WriteFileTool, WriteFileToolParams } from './write-file.js';
|
import { WriteFileTool, WriteFileToolParams } from './write-file.js';
|
||||||
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import {
|
import {
|
||||||
FileDiff,
|
FileDiff,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
|
@ -464,18 +465,27 @@ describe('WriteFileTool', () => {
|
||||||
it('should return error if params are invalid (relative path)', async () => {
|
it('should return error if params are invalid (relative path)', async () => {
|
||||||
const params = { file_path: 'relative.txt', content: 'test' };
|
const params = { file_path: 'relative.txt', content: 'test' };
|
||||||
const result = await tool.execute(params, abortSignal);
|
const result = await tool.execute(params, abortSignal);
|
||||||
expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
|
expect(result.llmContent).toContain(
|
||||||
expect(result.returnDisplay).toMatch(/Error: File path must be absolute/);
|
'Could not write file due to invalid parameters:',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toMatch(/File path must be absolute/);
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
message: 'File path must be absolute: relative.txt',
|
||||||
|
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error if params are invalid (path outside root)', async () => {
|
it('should return error if params are invalid (path outside root)', async () => {
|
||||||
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
|
const outsidePath = path.resolve(tempDir, 'outside-root.txt');
|
||||||
const params = { file_path: outsidePath, content: 'test' };
|
const params = { file_path: outsidePath, content: 'test' };
|
||||||
const result = await tool.execute(params, abortSignal);
|
const result = await tool.execute(params, abortSignal);
|
||||||
expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
|
expect(result.llmContent).toContain(
|
||||||
expect(result.returnDisplay).toContain(
|
'Could not write file due to invalid parameters:',
|
||||||
'Error: File path must be within one of the workspace directories',
|
|
||||||
);
|
);
|
||||||
|
expect(result.returnDisplay).toContain(
|
||||||
|
'File path must be within one of the workspace directories',
|
||||||
|
);
|
||||||
|
expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
|
it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
|
||||||
|
@ -490,10 +500,15 @@ describe('WriteFileTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await tool.execute(params, abortSignal);
|
const result = await tool.execute(params, abortSignal);
|
||||||
expect(result.llmContent).toMatch(/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/,
|
||||||
);
|
);
|
||||||
|
expect(result.error).toEqual({
|
||||||
|
message:
|
||||||
|
'Error checking existing file: Simulated read error for execute',
|
||||||
|
type: ToolErrorType.FILE_WRITE_FAILURE,
|
||||||
|
});
|
||||||
|
|
||||||
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
|
vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
|
||||||
fs.chmodSync(filePath, 0o600);
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
@ -709,4 +724,114 @@ describe('WriteFileTool', () => {
|
||||||
expect(error).toContain(rootDir);
|
expect(error).toContain(rootDir);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('specific error types for write failures', () => {
|
||||||
|
const abortSignal = new AbortController().signal;
|
||||||
|
|
||||||
|
it('should return PERMISSION_DENIED error when write fails with EACCES', async () => {
|
||||||
|
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(() => {
|
||||||
|
const error = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'EACCES';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = { file_path: filePath, content };
|
||||||
|
const result = await tool.execute(params, abortSignal);
|
||||||
|
|
||||||
|
expect(result.error?.type).toBe(ToolErrorType.PERMISSION_DENIED);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
`Permission denied writing to file: ${filePath} (EACCES)`,
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toContain('Permission denied');
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const error = new Error(
|
||||||
|
'No space left on device',
|
||||||
|
) as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ENOSPC';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = { file_path: filePath, content };
|
||||||
|
const result = await tool.execute(params, abortSignal);
|
||||||
|
|
||||||
|
expect(result.error?.type).toBe(ToolErrorType.NO_SPACE_LEFT);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
`No space left on device: ${filePath} (ENOSPC)`,
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toContain('No space left');
|
||||||
|
|
||||||
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return TARGET_IS_DIRECTORY error when write fails with EISDIR', async () => {
|
||||||
|
const dirPath = path.join(rootDir, 'test_directory');
|
||||||
|
const content = 'test content';
|
||||||
|
|
||||||
|
// Mock fs.existsSync to return false to bypass validation
|
||||||
|
const originalExistsSync = fs.existsSync;
|
||||||
|
vi.spyOn(fs, 'existsSync').mockImplementation((path) => {
|
||||||
|
if (path === dirPath) {
|
||||||
|
return false; // Pretend directory doesn't exist to bypass validation
|
||||||
|
}
|
||||||
|
return originalExistsSync(path as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock writeFileSync to throw EISDIR error
|
||||||
|
const originalWriteFileSync = fs.writeFileSync;
|
||||||
|
vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => {
|
||||||
|
const error = new Error('Is a directory') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'EISDIR';
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = { file_path: dirPath, content };
|
||||||
|
const result = await tool.execute(params, abortSignal);
|
||||||
|
|
||||||
|
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
`Target is a directory, not a file: ${dirPath} (EISDIR)`,
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toContain('Target is a directory');
|
||||||
|
|
||||||
|
vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync);
|
||||||
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(originalWriteFileSync);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return FILE_WRITE_FAILURE for generic write errors', async () => {
|
||||||
|
const filePath = path.join(rootDir, 'generic_error_file.txt');
|
||||||
|
const content = 'test content';
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = { file_path: filePath, content };
|
||||||
|
const result = await tool.execute(params, abortSignal);
|
||||||
|
|
||||||
|
expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);
|
||||||
|
expect(result.llmContent).toContain(
|
||||||
|
'Error writing to file: Generic write error',
|
||||||
|
);
|
||||||
|
expect(result.returnDisplay).toContain('Generic write error');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
Icon,
|
Icon,
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import { Type } from '@google/genai';
|
import { Type } from '@google/genai';
|
||||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||||
|
@ -230,8 +231,12 @@ export class WriteFileTool
|
||||||
const validationError = this.validateToolParams(params);
|
const validationError = this.validateToolParams(params);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
return {
|
return {
|
||||||
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
|
llmContent: `Could not write file due to invalid parameters: ${validationError}`,
|
||||||
returnDisplay: `Error: ${validationError}`,
|
returnDisplay: validationError,
|
||||||
|
error: {
|
||||||
|
message: validationError,
|
||||||
|
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -243,10 +248,16 @@ export class WriteFileTool
|
||||||
|
|
||||||
if (correctedContentResult.error) {
|
if (correctedContentResult.error) {
|
||||||
const errDetails = correctedContentResult.error;
|
const errDetails = correctedContentResult.error;
|
||||||
const errorMsg = `Error checking existing file: ${errDetails.message}`;
|
const errorMsg = errDetails.code
|
||||||
|
? `Error checking existing file '${params.file_path}': ${errDetails.message} (${errDetails.code})`
|
||||||
|
: `Error checking existing file: ${errDetails.message}`;
|
||||||
return {
|
return {
|
||||||
llmContent: `Error checking existing file ${params.file_path}: ${errDetails.message}`,
|
llmContent: errorMsg,
|
||||||
returnDisplay: errorMsg,
|
returnDisplay: errorMsg,
|
||||||
|
error: {
|
||||||
|
message: errorMsg,
|
||||||
|
type: ToolErrorType.FILE_WRITE_FAILURE,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -344,10 +355,43 @@ export class WriteFileTool
|
||||||
returnDisplay: displayResult,
|
returnDisplay: displayResult,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`;
|
// Capture detailed error information for debugging
|
||||||
|
let errorMsg: string;
|
||||||
|
let errorType = ToolErrorType.FILE_WRITE_FAILURE;
|
||||||
|
|
||||||
|
if (isNodeError(error)) {
|
||||||
|
// Handle specific Node.js errors with their error codes
|
||||||
|
errorMsg = `Error writing to file '${params.file_path}': ${error.message} (${error.code})`;
|
||||||
|
|
||||||
|
// Log specific error types for better debugging
|
||||||
|
if (error.code === 'EACCES') {
|
||||||
|
errorMsg = `Permission denied writing to file: ${params.file_path} (${error.code})`;
|
||||||
|
errorType = ToolErrorType.PERMISSION_DENIED;
|
||||||
|
} else if (error.code === 'ENOSPC') {
|
||||||
|
errorMsg = `No space left on device: ${params.file_path} (${error.code})`;
|
||||||
|
errorType = ToolErrorType.NO_SPACE_LEFT;
|
||||||
|
} else if (error.code === 'EISDIR') {
|
||||||
|
errorMsg = `Target is a directory, not a file: ${params.file_path} (${error.code})`;
|
||||||
|
errorType = ToolErrorType.TARGET_IS_DIRECTORY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include stack trace in debug mode for better troubleshooting
|
||||||
|
if (this.config.getDebugMode() && error.stack) {
|
||||||
|
console.error('Write file error stack:', error.stack);
|
||||||
|
}
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMsg = `Error writing to file: ${error.message}`;
|
||||||
|
} else {
|
||||||
|
errorMsg = `Error writing to file: ${String(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`,
|
llmContent: errorMsg,
|
||||||
returnDisplay: `Error: ${errorMsg}`,
|
returnDisplay: errorMsg,
|
||||||
|
error: {
|
||||||
|
message: errorMsg,
|
||||||
|
type: errorType,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -195,10 +195,18 @@ export async function detectFileType(
|
||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum FileErrorType {
|
||||||
|
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||||
|
IS_DIRECTORY = 'IS_DIRECTORY',
|
||||||
|
FILE_TOO_LARGE = 'FILE_TOO_LARGE',
|
||||||
|
READ_ERROR = 'READ_ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProcessedFileReadResult {
|
export interface ProcessedFileReadResult {
|
||||||
llmContent: PartUnion; // string for text, Part for image/pdf/unreadable binary
|
llmContent: PartUnion; // string for text, Part for image/pdf/unreadable binary
|
||||||
returnDisplay: string;
|
returnDisplay: string;
|
||||||
error?: string; // Optional error message for the LLM if file processing failed
|
error?: string; // Optional error message for the LLM if file processing failed
|
||||||
|
errorType?: FileErrorType; // Structured error type using enum
|
||||||
isTruncated?: boolean; // For text files, indicates if content was truncated
|
isTruncated?: boolean; // For text files, indicates if content was truncated
|
||||||
originalLineCount?: number; // For text files
|
originalLineCount?: number; // For text files
|
||||||
linesShown?: [number, number]; // For text files [startLine, endLine] (1-based for display)
|
linesShown?: [number, number]; // For text files [startLine, endLine] (1-based for display)
|
||||||
|
@ -225,6 +233,7 @@ export async function processSingleFileContent(
|
||||||
llmContent: '',
|
llmContent: '',
|
||||||
returnDisplay: 'File not found.',
|
returnDisplay: 'File not found.',
|
||||||
error: `File not found: ${filePath}`,
|
error: `File not found: ${filePath}`,
|
||||||
|
errorType: FileErrorType.FILE_NOT_FOUND,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const stats = await fs.promises.stat(filePath);
|
const stats = await fs.promises.stat(filePath);
|
||||||
|
@ -233,6 +242,7 @@ export async function processSingleFileContent(
|
||||||
llmContent: '',
|
llmContent: '',
|
||||||
returnDisplay: 'Path is a directory.',
|
returnDisplay: 'Path is a directory.',
|
||||||
error: `Path is a directory, not a file: ${filePath}`,
|
error: `Path is a directory, not a file: ${filePath}`,
|
||||||
|
errorType: FileErrorType.IS_DIRECTORY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue