mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(core): Introduce DeclarativeTool and ToolInvocation. (#5613)
This commit is contained in:
@@ -13,6 +13,7 @@ import fsp from 'fs/promises';
|
||||
import { Config } from '../config/config.js';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { ToolInvocation, ToolResult } from './tools.js';
|
||||
|
||||
describe('ReadFileTool', () => {
|
||||
let tempRootDir: string;
|
||||
@@ -40,57 +41,62 @@ describe('ReadFileTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
it('should return null for valid params (absolute path within root)', () => {
|
||||
describe('build', () => {
|
||||
it('should return an invocation for valid params (absolute path within root)', () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||
};
|
||||
expect(tool.validateToolParams(params)).toBeNull();
|
||||
const result = tool.build(params);
|
||||
expect(result).not.toBeTypeOf('string');
|
||||
expect(typeof result).toBe('object');
|
||||
expect(
|
||||
(result as ToolInvocation<ReadFileToolParams, ToolResult>).params,
|
||||
).toEqual(params);
|
||||
});
|
||||
|
||||
it('should return null for valid params with offset and limit', () => {
|
||||
it('should return an invocation for valid params with offset and limit', () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
};
|
||||
expect(tool.validateToolParams(params)).toBeNull();
|
||||
const result = tool.build(params);
|
||||
expect(result).not.toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('should return error for relative path', () => {
|
||||
it('should throw error for relative path', () => {
|
||||
const params: ReadFileToolParams = { absolute_path: 'test.txt' };
|
||||
expect(tool.validateToolParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
`File path must be absolute, but was relative: test.txt. You must provide an absolute path.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for path outside root', () => {
|
||||
it('should throw error for path outside root', () => {
|
||||
const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt');
|
||||
const params: ReadFileToolParams = { absolute_path: outsidePath };
|
||||
const error = tool.validateToolParams(params);
|
||||
expect(error).toContain(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'File path must be within one of the workspace directories',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for negative offset', () => {
|
||||
it('should throw error for negative offset', () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||
offset: -1,
|
||||
limit: 10,
|
||||
};
|
||||
expect(tool.validateToolParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'Offset must be a non-negative number',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for non-positive limit', () => {
|
||||
it('should throw error for non-positive limit', () => {
|
||||
const paramsZero: ReadFileToolParams = {
|
||||
absolute_path: path.join(tempRootDir, 'test.txt'),
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
};
|
||||
expect(tool.validateToolParams(paramsZero)).toBe(
|
||||
expect(() => tool.build(paramsZero)).toThrow(
|
||||
'Limit must be a positive number',
|
||||
);
|
||||
const paramsNegative: ReadFileToolParams = {
|
||||
@@ -98,168 +104,182 @@ describe('ReadFileTool', () => {
|
||||
offset: 0,
|
||||
limit: -5,
|
||||
};
|
||||
expect(tool.validateToolParams(paramsNegative)).toBe(
|
||||
expect(() => tool.build(paramsNegative)).toThrow(
|
||||
'Limit must be a positive number',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for schema validation failure (e.g. missing path)', () => {
|
||||
it('should throw error for schema validation failure (e.g. missing path)', () => {
|
||||
const params = { offset: 0 } as unknown as ReadFileToolParams;
|
||||
expect(tool.validateToolParams(params)).toBe(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
`params must have required property 'absolute_path'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
it('should return a shortened, relative path', () => {
|
||||
const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
expect(tool.getDescription(params)).toBe(
|
||||
path.join('sub', 'dir', 'file.txt'),
|
||||
);
|
||||
});
|
||||
describe('ToolInvocation', () => {
|
||||
describe('getDescription', () => {
|
||||
it('should return a shortened, relative path', () => {
|
||||
const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
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 };
|
||||
expect(tool.getDescription(params)).toBe('.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return validation error if params are invalid', async () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: 'relative/path.txt',
|
||||
};
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent:
|
||||
'Error: Invalid parameters provided. Reason: File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.',
|
||||
returnDisplay:
|
||||
'File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.',
|
||||
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('.');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if file does not exist', async () => {
|
||||
const filePath = path.join(tempRootDir, 'nonexistent.txt');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
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
|
||||
>;
|
||||
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: `File not found: ${filePath}`,
|
||||
returnDisplay: 'File not found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return success result for a text file', async () => {
|
||||
const filePath = path.join(tempRootDir, 'textfile.txt');
|
||||
const fileContent = 'This is a test file.';
|
||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: fileContent,
|
||||
returnDisplay: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return success result for an image file', async () => {
|
||||
// A minimal 1x1 transparent PNG file.
|
||||
const pngContent = Buffer.from([
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
|
||||
1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65,
|
||||
84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0, 0, 73,
|
||||
69, 78, 68, 174, 66, 96, 130,
|
||||
]);
|
||||
const filePath = path.join(tempRootDir, 'image.png');
|
||||
await fsp.writeFile(filePath, pngContent);
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: {
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: pngContent.toString('base64'),
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read image file: image.png`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat a non-image file with image extension as an image', async () => {
|
||||
const filePath = path.join(tempRootDir, 'fake-image.png');
|
||||
const fileContent = 'This is not a real png.';
|
||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: {
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from(fileContent).toString('base64'),
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read image file: fake-image.png`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass offset and limit to read a slice of a text file', async () => {
|
||||
const filePath = path.join(tempRootDir, 'paginated.txt');
|
||||
const fileContent = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: filePath,
|
||||
offset: 5, // Start from line 6
|
||||
limit: 3,
|
||||
};
|
||||
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: [
|
||||
'[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]',
|
||||
'Line 6',
|
||||
'Line 7',
|
||||
'Line 8',
|
||||
].join('\n'),
|
||||
returnDisplay: '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'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if path is ignored by a .geminiignore pattern', async () => {
|
||||
const ignoredFilePath = path.join(tempRootDir, 'foo.bar');
|
||||
await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: ignoredFilePath,
|
||||
};
|
||||
const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`;
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${expectedError}`,
|
||||
returnDisplay: expectedError,
|
||||
expect(await invocation.execute(abortSignal)).toEqual({
|
||||
llmContent: `File not found: ${filePath}`,
|
||||
returnDisplay: 'File not found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if path is in an ignored directory', async () => {
|
||||
const ignoredDirPath = path.join(tempRootDir, 'ignored');
|
||||
await fsp.mkdir(ignoredDirPath);
|
||||
const filePath = path.join(ignoredDirPath, 'somefile.txt');
|
||||
await fsp.writeFile(filePath, 'content', 'utf-8');
|
||||
it('should return success result for a text file', async () => {
|
||||
const filePath = path.join(tempRootDir, 'textfile.txt');
|
||||
const fileContent = 'This is a test file.';
|
||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
const invocation = tool.build(params) as ToolInvocation<
|
||||
ReadFileToolParams,
|
||||
ToolResult
|
||||
>;
|
||||
|
||||
expect(await invocation.execute(abortSignal)).toEqual({
|
||||
llmContent: fileContent,
|
||||
returnDisplay: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return success result for an image file', async () => {
|
||||
// A minimal 1x1 transparent PNG file.
|
||||
const pngContent = Buffer.from([
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0,
|
||||
1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68,
|
||||
65, 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0,
|
||||
0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
]);
|
||||
const filePath = path.join(tempRootDir, 'image.png');
|
||||
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({
|
||||
llmContent: {
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: pngContent.toString('base64'),
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read image file: image.png`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should treat a non-image file with image extension as an image', async () => {
|
||||
const filePath = path.join(tempRootDir, 'fake-image.png');
|
||||
const fileContent = 'This is not a real png.';
|
||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||
const params: ReadFileToolParams = { absolute_path: filePath };
|
||||
const invocation = tool.build(params) as ToolInvocation<
|
||||
ReadFileToolParams,
|
||||
ToolResult
|
||||
>;
|
||||
|
||||
expect(await invocation.execute(abortSignal)).toEqual({
|
||||
llmContent: {
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from(fileContent).toString('base64'),
|
||||
},
|
||||
},
|
||||
returnDisplay: `Read image file: fake-image.png`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass offset and limit to read a slice of a text file', async () => {
|
||||
const filePath = path.join(tempRootDir, 'paginated.txt');
|
||||
const fileContent = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
await fsp.writeFile(filePath, fileContent, 'utf-8');
|
||||
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: filePath,
|
||||
offset: 5, // Start from line 6
|
||||
limit: 3,
|
||||
};
|
||||
const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`;
|
||||
expect(await tool.execute(params, abortSignal)).toEqual({
|
||||
llmContent: `Error: Invalid parameters provided. Reason: ${expectedError}`,
|
||||
returnDisplay: expectedError,
|
||||
const invocation = tool.build(params) as ToolInvocation<
|
||||
ReadFileToolParams,
|
||||
ToolResult
|
||||
>;
|
||||
|
||||
expect(await invocation.execute(abortSignal)).toEqual({
|
||||
llmContent: [
|
||||
'[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]',
|
||||
'Line 6',
|
||||
'Line 7',
|
||||
'Line 8',
|
||||
].join('\n'),
|
||||
returnDisplay: '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'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if path is ignored by a .geminiignore pattern', async () => {
|
||||
const ignoredFilePath = path.join(tempRootDir, 'foo.bar');
|
||||
await fsp.writeFile(ignoredFilePath, 'content', 'utf-8');
|
||||
const params: ReadFileToolParams = {
|
||||
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 in an ignored directory', async () => {
|
||||
const ignoredDirPath = path.join(tempRootDir, 'ignored');
|
||||
await fsp.mkdir(ignoredDirPath);
|
||||
const filePath = path.join(ignoredDirPath, 'somefile.txt');
|
||||
await fsp.writeFile(filePath, 'content', 'utf-8');
|
||||
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: filePath,
|
||||
};
|
||||
const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`;
|
||||
expect(() => tool.build(params)).toThrow(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -270,18 +290,16 @@ describe('ReadFileTool', () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: path.join(tempRootDir, 'file.txt'),
|
||||
};
|
||||
expect(tool.validateToolParams(params)).toBeNull();
|
||||
expect(() => tool.build(params)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject paths outside workspace root', () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: '/etc/passwd',
|
||||
};
|
||||
const error = tool.validateToolParams(params);
|
||||
expect(error).toContain(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'File path must be within one of the workspace directories',
|
||||
);
|
||||
expect(error).toContain(tempRootDir);
|
||||
});
|
||||
|
||||
it('should provide clear error message with workspace directories', () => {
|
||||
@@ -289,11 +307,9 @@ describe('ReadFileTool', () => {
|
||||
const params: ReadFileToolParams = {
|
||||
absolute_path: outsidePath,
|
||||
};
|
||||
const error = tool.validateToolParams(params);
|
||||
expect(error).toContain(
|
||||
expect(() => tool.build(params)).toThrow(
|
||||
'File path must be within one of the workspace directories',
|
||||
);
|
||||
expect(error).toContain(tempRootDir);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user