Refactored 4 files of tools package (#13235)

Co-authored-by: riddhi <duttariddhi@google.com>
This commit is contained in:
Riddhi Dutta
2025-11-18 01:01:29 +05:30
committed by GitHub
parent ba88707b1e
commit 1d1bdc57ce
4 changed files with 492 additions and 696 deletions
+124 -147
View File
@@ -176,53 +176,48 @@ describe('SmartEditTool', () => {
describe('calculateReplacement', () => { describe('calculateReplacement', () => {
const abortSignal = new AbortController().signal; const abortSignal = new AbortController().signal;
it('should perform an exact replacement', async () => { it.each([
const content = 'hello world'; {
const result = await calculateReplacement(mockConfig, { name: 'perform an exact replacement',
params: { content: 'hello world',
file_path: 'test.txt', old_string: 'world',
instruction: 'test', new_string: 'moon',
old_string: 'world', expected: 'hello moon',
new_string: 'moon', occurrences: 1,
}, },
currentContent: content, {
abortSignal, name: 'perform a flexible, whitespace-insensitive replacement',
}); content: ' hello\n world\n',
expect(result.newContent).toBe('hello moon'); old_string: 'hello\nworld',
expect(result.occurrences).toBe(1); new_string: 'goodbye\nmoon',
}); expected: ' goodbye\n moon\n',
occurrences: 1,
it('should perform a flexible, whitespace-insensitive replacement', async () => { },
const content = ' hello\n world\n'; {
const result = await calculateReplacement(mockConfig, { name: 'return 0 occurrences if no match is found',
params: { content: 'hello world',
file_path: 'test.txt', old_string: 'nomatch',
instruction: 'test', new_string: 'moon',
old_string: 'hello\nworld', expected: 'hello world',
new_string: 'goodbye\nmoon', occurrences: 0,
}, },
currentContent: content, ])(
abortSignal, 'should $name',
}); async ({ content, old_string, new_string, expected, occurrences }) => {
expect(result.newContent).toBe(' goodbye\n moon\n'); const result = await calculateReplacement(mockConfig, {
expect(result.occurrences).toBe(1); params: {
}); file_path: 'test.txt',
instruction: 'test',
it('should return 0 occurrences if no match is found', async () => { old_string,
const content = 'hello world'; new_string,
const result = await calculateReplacement(mockConfig, { },
params: { currentContent: content,
file_path: 'test.txt', abortSignal,
instruction: 'test', });
old_string: 'nomatch', expect(result.newContent).toBe(expected);
new_string: 'moon', expect(result.occurrences).toBe(occurrences);
}, },
currentContent: content, );
abortSignal,
});
expect(result.newContent).toBe(content);
expect(result.occurrences).toBe(0);
});
it('should perform a regex-based replacement for flexible intra-line whitespace', async () => { it('should perform a regex-based replacement for flexible intra-line whitespace', async () => {
// This case would fail with the previous exact and line-trimming flexible logic // This case would fail with the previous exact and line-trimming flexible logic
@@ -496,60 +491,44 @@ describe('SmartEditTool', () => {
filePath = path.join(rootDir, testFile); filePath = path.join(rootDir, testFile);
}); });
it('should return FILE_NOT_FOUND error', async () => { it.each([
const params: EditToolParams = { {
file_path: filePath, name: 'FILE_NOT_FOUND',
instruction: 'test', setup: () => {}, // no file created
old_string: 'any', params: { old_string: 'any', new_string: 'new' },
new_string: 'new', expectedError: ToolErrorType.FILE_NOT_FOUND,
}; },
const invocation = tool.build(params); {
const result = await invocation.execute(new AbortController().signal); name: 'ATTEMPT_TO_CREATE_EXISTING_FILE',
expect(result.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND); setup: (fp: string) => fs.writeFileSync(fp, 'existing content', 'utf8'),
}); params: { old_string: '', new_string: 'new content' },
expectedError: ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE,
it('should return ATTEMPT_TO_CREATE_EXISTING_FILE error', async () => { },
fs.writeFileSync(filePath, 'existing content', 'utf8'); {
const params: EditToolParams = { name: 'NO_OCCURRENCE_FOUND',
file_path: filePath, setup: (fp: string) => fs.writeFileSync(fp, 'content', 'utf8'),
instruction: 'test', params: { old_string: 'not-found', new_string: 'new' },
old_string: '', expectedError: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
new_string: 'new content', },
}; {
const invocation = tool.build(params); name: 'EXPECTED_OCCURRENCE_MISMATCH',
const result = await invocation.execute(new AbortController().signal); setup: (fp: string) => fs.writeFileSync(fp, 'one one two', 'utf8'),
expect(result.error?.type).toBe( params: { old_string: 'one', new_string: 'new' },
ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE, expectedError: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
); },
}); ])(
'should return $name error',
it('should return NO_OCCURRENCE_FOUND error', async () => { async ({ setup, params, expectedError }) => {
fs.writeFileSync(filePath, 'content', 'utf8'); setup(filePath);
const params: EditToolParams = { const invocation = tool.build({
file_path: filePath, file_path: filePath,
instruction: 'test', instruction: 'test',
old_string: 'not-found', ...params,
new_string: 'new', });
}; const result = await invocation.execute(new AbortController().signal);
const invocation = tool.build(params); expect(result.error?.type).toBe(expectedError);
const result = await invocation.execute(new AbortController().signal); },
expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND); );
});
it('should return EXPECTED_OCCURRENCE_MISMATCH error', async () => {
fs.writeFileSync(filePath, 'one one two', 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'test',
old_string: 'one',
new_string: 'new',
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
);
});
}); });
describe('expected_replacements', () => { describe('expected_replacements', () => {
@@ -560,53 +539,51 @@ describe('SmartEditTool', () => {
filePath = path.join(rootDir, testFile); filePath = path.join(rootDir, testFile);
}); });
it('should succeed when occurrences match expected_replacements', async () => { it.each([
fs.writeFileSync(filePath, 'foo foo foo', 'utf8'); {
const params: EditToolParams = { name: 'succeed when occurrences match expected_replacements',
file_path: filePath, content: 'foo foo foo',
instruction: 'Replace all foo with bar', expected: 3,
old_string: 'foo', shouldSucceed: true,
new_string: 'bar', finalContent: 'bar bar bar',
expected_replacements: 3, },
}; {
const invocation = tool.build(params); name: 'fail when occurrences do not match expected_replacements',
const result = await invocation.execute(new AbortController().signal); content: 'foo foo foo',
expect(result.error).toBeUndefined(); expected: 2,
expect(fs.readFileSync(filePath, 'utf8')).toBe('bar bar bar'); shouldSucceed: false,
}); },
{
name: 'default to 1 expected replacement if not specified',
content: 'foo foo',
expected: undefined,
shouldSucceed: false,
},
])(
'should $name',
async ({ content, expected, shouldSucceed, finalContent }) => {
fs.writeFileSync(filePath, content, 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace all foo with bar',
old_string: 'foo',
new_string: 'bar',
...(expected !== undefined && { expected_replacements: expected }),
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
it('should fail when occurrences do not match expected_replacements', async () => { if (shouldSucceed) {
fs.writeFileSync(filePath, 'foo foo foo', 'utf8'); expect(result.error).toBeUndefined();
const params: EditToolParams = { if (finalContent)
file_path: filePath, expect(fs.readFileSync(filePath, 'utf8')).toBe(finalContent);
instruction: 'Replace all foo with bar', } else {
old_string: 'foo', expect(result.error?.type).toBe(
new_string: 'bar', ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
expected_replacements: 2, );
}; }
const invocation = tool.build(params); },
const result = await invocation.execute(new AbortController().signal); );
expect(result.error?.type).toBe(
ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
);
});
it('should default to 1 expected replacement if not specified', async () => {
fs.writeFileSync(filePath, 'foo foo', 'utf8');
const params: EditToolParams = {
file_path: filePath,
instruction: 'Replace foo with bar',
old_string: 'foo',
new_string: 'bar',
// expected_replacements is undefined, defaults to 1
};
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
// Should fail because there are 2 occurrences but default expectation is 1
expect(result.error?.type).toBe(
ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
);
});
}); });
describe('IDE mode', () => { describe('IDE mode', () => {
+85 -148
View File
@@ -92,6 +92,72 @@ const createMockCallableTool = (
callTool: vi.fn(), callTool: vi.fn(),
}); });
// Helper to create a DiscoveredMCPTool
const createMCPTool = (
serverName: string,
toolName: string,
description: string,
mockCallable: CallableTool = {} as CallableTool,
) => new DiscoveredMCPTool(mockCallable, serverName, toolName, description, {});
// Helper to create a mock spawn process for tool discovery
const createDiscoveryProcess = (toolDeclarations: FunctionDeclaration[]) => {
const mockProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
on: vi.fn(),
};
mockProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([{ functionDeclarations: toolDeclarations }]),
),
);
}
return mockProcess as any;
});
mockProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
return mockProcess as any;
});
return mockProcess;
};
// Helper to create a mock spawn process for tool execution
const createExecutionProcess = (exitCode: number, stderrMessage?: string) => {
const mockProcess = {
stdout: { on: vi.fn(), removeListener: vi.fn() },
stderr: { on: vi.fn(), removeListener: vi.fn() },
stdin: { write: vi.fn(), end: vi.fn() },
on: vi.fn(),
connected: true,
disconnect: vi.fn(),
removeListener: vi.fn(),
};
if (stderrMessage) {
mockProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(Buffer.from(stderrMessage));
}
});
}
mockProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(exitCode);
}
});
return mockProcess;
};
const baseConfigParams: ConfigParameters = { const baseConfigParams: ConfigParameters = {
cwd: '/tmp', cwd: '/tmp',
model: 'test-model', model: 'test-model',
@@ -165,13 +231,10 @@ describe('ToolRegistry', () => {
name: 'excluded-tool-class', name: 'excluded-tool-class',
displayName: 'Excluded Tool Class', displayName: 'Excluded Tool Class',
}); });
const mockCallable = {} as CallableTool; const mcpTool = createMCPTool(
const mcpTool = new DiscoveredMCPTool(
mockCallable,
'mcp-server', 'mcp-server',
'excluded-mcp-tool', 'excluded-mcp-tool',
'description', 'description',
{},
); );
const allowedTool = new MockTool({ const allowedTool = new MockTool({
name: 'allowed-tool', name: 'allowed-tool',
@@ -271,36 +334,10 @@ describe('ToolRegistry', () => {
it('should return only tools matching the server name, sorted by name', async () => { it('should return only tools matching the server name, sorted by name', async () => {
const server1Name = 'mcp-server-uno'; const server1Name = 'mcp-server-uno';
const server2Name = 'mcp-server-dos'; const server2Name = 'mcp-server-dos';
const mockCallable = {} as CallableTool; const mcpTool1_c = createMCPTool(server1Name, 'zebra-tool', 'd1');
const mcpTool1_c = new DiscoveredMCPTool( const mcpTool1_a = createMCPTool(server1Name, 'apple-tool', 'd2');
mockCallable, const mcpTool1_b = createMCPTool(server1Name, 'banana-tool', 'd3');
server1Name, const mcpTool2 = createMCPTool(server2Name, 'tool-on-server2', 'd4');
'zebra-tool',
'd1',
{},
);
const mcpTool1_a = new DiscoveredMCPTool(
mockCallable,
server1Name,
'apple-tool',
'd2',
{},
);
const mcpTool1_b = new DiscoveredMCPTool(
mockCallable,
server1Name,
'banana-tool',
'd3',
{},
);
const mcpTool2 = new DiscoveredMCPTool(
mockCallable,
server2Name,
'tool-on-server2',
'd4',
{},
);
const nonMcpTool = new MockTool({ name: 'regular-tool' }); const nonMcpTool = new MockTool({ name: 'regular-tool' });
toolRegistry.registerTool(mcpTool1_c); toolRegistry.registerTool(mcpTool1_c);
@@ -339,21 +376,8 @@ describe('ToolRegistry', () => {
'desc', 'desc',
{}, {},
); );
const mockCallable = {} as CallableTool; const mcpZebra = createMCPTool('zebra-server', 'mcp-zebra', 'desc');
const mcpZebra = new DiscoveredMCPTool( const mcpApple = createMCPTool('apple-server', 'mcp-apple', 'desc');
mockCallable,
'zebra-server',
'mcp-zebra',
'desc',
{},
);
const mcpApple = new DiscoveredMCPTool(
mockCallable,
'apple-server',
'mcp-apple',
'desc',
{},
);
// Register in mixed order // Register in mixed order
toolRegistry.registerTool(mcpZebra); toolRegistry.registerTool(mcpZebra);
@@ -394,34 +418,9 @@ describe('ToolRegistry', () => {
}; };
const mockSpawn = vi.mocked(spawn); const mockSpawn = vi.mocked(spawn);
const mockChildProcess = { mockSpawn.mockReturnValue(
stdout: { on: vi.fn() }, createDiscoveryProcess([unsanitizedToolDeclaration]) as any,
stderr: { on: vi.fn() }, );
on: vi.fn(),
};
mockSpawn.mockReturnValue(mockChildProcess as any);
// Simulate stdout data
mockChildProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([
{ function_declarations: [unsanitizedToolDeclaration] },
]),
),
);
}
return mockChildProcess as any;
});
// Simulate process close
mockChildProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
return mockChildProcess as any;
});
await toolRegistry.discoverAllTools(); await toolRegistry.discoverAllTools();
@@ -458,28 +457,9 @@ describe('ToolRegistry', () => {
}; };
const mockSpawn = vi.mocked(spawn); const mockSpawn = vi.mocked(spawn);
// --- Discovery Mock --- mockSpawn.mockReturnValueOnce(
const discoveryProcess = { createDiscoveryProcess([toolDeclaration]) as any,
stdout: { on: vi.fn(), removeListener: vi.fn() }, );
stderr: { on: vi.fn(), removeListener: vi.fn() },
on: vi.fn(),
};
mockSpawn.mockReturnValueOnce(discoveryProcess as any);
discoveryProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([{ functionDeclarations: [toolDeclaration] }]),
),
);
}
});
discoveryProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
});
await toolRegistry.discoverAllTools(); await toolRegistry.discoverAllTools();
const discoveredTool = toolRegistry.getTool( const discoveredTool = toolRegistry.getTool(
@@ -487,28 +467,9 @@ describe('ToolRegistry', () => {
); );
expect(discoveredTool).toBeDefined(); expect(discoveredTool).toBeDefined();
// --- Execution Mock --- mockSpawn.mockReturnValueOnce(
const executionProcess = { createExecutionProcess(1, 'Something went wrong') as any,
stdout: { on: vi.fn(), removeListener: vi.fn() }, );
stderr: { on: vi.fn(), removeListener: vi.fn() },
stdin: { write: vi.fn(), end: vi.fn() },
on: vi.fn(),
connected: true,
disconnect: vi.fn(),
removeListener: vi.fn(),
};
mockSpawn.mockReturnValueOnce(executionProcess as any);
executionProcess.stderr.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(Buffer.from('Something went wrong'));
}
});
executionProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(1); // Non-zero exit code
}
});
const invocation = (discoveredTool as DiscoveredTool).build({}); const invocation = (discoveredTool as DiscoveredTool).build({});
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
@@ -524,7 +485,6 @@ describe('ToolRegistry', () => {
const discoveryCommand = 'my-discovery-command'; const discoveryCommand = 'my-discovery-command';
mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand); mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
// Mock MessageBus
const mockMessageBus = { const mockMessageBus = {
publish: vi.fn(), publish: vi.fn(),
subscribe: vi.fn(), subscribe: vi.fn(),
@@ -539,41 +499,18 @@ describe('ToolRegistry', () => {
}; };
const mockSpawn = vi.mocked(spawn); const mockSpawn = vi.mocked(spawn);
const discoveryProcess = { mockSpawn.mockReturnValueOnce(
stdout: { on: vi.fn(), removeListener: vi.fn() }, createDiscoveryProcess([toolDeclaration]) as any,
stderr: { on: vi.fn(), removeListener: vi.fn() }, );
on: vi.fn(),
kill: vi.fn(),
};
mockSpawn.mockReturnValueOnce(discoveryProcess as any);
discoveryProcess.stdout.on.mockImplementation((event, callback) => {
if (event === 'data') {
callback(
Buffer.from(
JSON.stringify([{ functionDeclarations: [toolDeclaration] }]),
),
);
}
});
discoveryProcess.on.mockImplementation((event, callback) => {
if (event === 'close') {
callback(0);
}
});
await toolRegistry.discoverAllTools(); await toolRegistry.discoverAllTools();
const tool = toolRegistry.getTool( const tool = toolRegistry.getTool(
DISCOVERED_TOOL_PREFIX + 'policy-test-tool', DISCOVERED_TOOL_PREFIX + 'policy-test-tool',
); );
expect(tool).toBeDefined(); expect(tool).toBeDefined();
// Verify DiscoveredTool has the message bus
expect((tool as any).messageBus).toBe(mockMessageBus); expect((tool as any).messageBus).toBe(mockMessageBus);
const invocation = tool!.build({}); const invocation = tool!.build({});
// Verify DiscoveredToolInvocation has the message bus
expect((invocation as any).messageBus).toBe(mockMessageBus); expect((invocation as any).messageBus).toBe(mockMessageBus);
}); });
}); });
+143 -230
View File
@@ -71,46 +71,38 @@ describe('parsePrompt', () => {
expect(validUrls[0]).toBe('https://example.com./'); expect(validUrls[0]).toBe('https://example.com./');
}); });
it('should detect URLs wrapped in punctuation as malformed', () => { it.each([
const prompt = 'Read (https://example.com)'; {
name: 'URLs wrapped in punctuation',
prompt: 'Read (https://example.com)',
expectedErrorContent: ['Malformed URL detected', '(https://example.com)'],
},
{
name: 'unsupported protocols (httpshttps://)',
prompt: 'Summarize httpshttps://github.com/JuliaLang/julia/issues/58346',
expectedErrorContent: [
'Unsupported protocol',
'httpshttps://github.com/JuliaLang/julia/issues/58346',
],
},
{
name: 'unsupported protocols (ftp://)',
prompt: 'ftp://example.com/file.txt',
expectedErrorContent: ['Unsupported protocol'],
},
{
name: 'malformed URLs (http://)',
prompt: 'http://',
expectedErrorContent: ['Malformed URL detected'],
},
])('should detect $name as errors', ({ prompt, expectedErrorContent }) => {
const { validUrls, errors } = parsePrompt(prompt); const { validUrls, errors } = parsePrompt(prompt);
expect(validUrls).toHaveLength(0); expect(validUrls).toHaveLength(0);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
expect(errors[0]).toContain('Malformed URL detected'); expectedErrorContent.forEach((content) => {
expect(errors[0]).toContain('(https://example.com)'); expect(errors[0]).toContain(content);
}); });
it('should detect unsupported protocols (httpshttps://)', () => {
const prompt =
'Summarize httpshttps://github.com/JuliaLang/julia/issues/58346';
const { validUrls, errors } = parsePrompt(prompt);
expect(validUrls).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('Unsupported protocol');
expect(errors[0]).toContain(
'httpshttps://github.com/JuliaLang/julia/issues/58346',
);
});
it('should detect unsupported protocols (ftp://)', () => {
const prompt = 'ftp://example.com/file.txt';
const { validUrls, errors } = parsePrompt(prompt);
expect(validUrls).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('Unsupported protocol');
});
it('should detect malformed URLs', () => {
// http:// is not a valid URL in Node's new URL()
const prompt = 'http://';
const { validUrls, errors } = parsePrompt(prompt);
expect(validUrls).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('Malformed URL detected');
}); });
it('should handle prompts with no URLs', () => { it('should handle prompts with no URLs', () => {
@@ -153,24 +145,25 @@ describe('WebFetchTool', () => {
}); });
describe('validateToolParamValues', () => { describe('validateToolParamValues', () => {
it('should throw if prompt is empty', () => { it.each([
{
name: 'empty prompt',
prompt: '',
expectedError: "The 'prompt' parameter cannot be empty",
},
{
name: 'prompt with no URLs',
prompt: 'hello world',
expectedError: "The 'prompt' must contain at least one valid URL",
},
{
name: 'prompt with malformed URLs',
prompt: 'fetch httpshttps://example.com',
expectedError: 'Error(s) in prompt URLs:',
},
])('should throw if $name', ({ prompt, expectedError }) => {
const tool = new WebFetchTool(mockConfig); const tool = new WebFetchTool(mockConfig);
expect(() => tool.build({ prompt: '' })).toThrow( expect(() => tool.build({ prompt })).toThrow(expectedError);
"The 'prompt' parameter cannot be empty",
);
});
it('should throw if prompt contains no URLs', () => {
const tool = new WebFetchTool(mockConfig);
expect(() => tool.build({ prompt: 'hello world' })).toThrow(
"The 'prompt' must contain at least one valid URL",
);
});
it('should throw if prompt contains malformed URLs (httpshttps://)', () => {
const tool = new WebFetchTool(mockConfig);
const prompt = 'fetch httpshttps://example.com';
expect(() => tool.build({ prompt })).toThrow('Error(s) in prompt URLs:');
}); });
it('should pass if prompt contains at least one valid URL', () => { it('should pass if prompt contains at least one valid URL', () => {
@@ -267,105 +260,71 @@ describe('WebFetchTool', () => {
}); });
}); });
it('should convert HTML content using html-to-text', async () => { it.each([
const htmlContent = '<html><body><h1>Hello</h1></body></html>'; {
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({ name: 'HTML content using html-to-text',
ok: true, content: '<html><body><h1>Hello</h1></body></html>',
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), contentType: 'text/html; charset=utf-8',
text: () => Promise.resolve(htmlContent), shouldConvert: true,
} as Response); },
{
name: 'raw text for JSON content',
content: '{"key": "value"}',
contentType: 'application/json',
shouldConvert: false,
},
{
name: 'raw text for plain text content',
content: 'Just some text.',
contentType: 'text/plain',
shouldConvert: false,
},
{
name: 'content with no Content-Type header as HTML',
content: '<p>No header</p>',
contentType: null,
shouldConvert: true,
},
])(
'should handle $name',
async ({ content, contentType, shouldConvert }) => {
const headers = contentType
? new Headers({ 'content-type': contentType })
: new Headers();
// Mock fallback LLM call to return the content passed to it vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
mockGenerateContent.mockImplementationOnce(async (_, req) => ({ ok: true,
candidates: [{ content: { parts: [{ text: req[0].parts[0].text }] } }], headers,
})); text: () => Promise.resolve(content),
} as Response);
const tool = new WebFetchTool(mockConfig); // Mock fallback LLM call to return the content passed to it
const params = { prompt: 'fetch https://example.com' }; mockGenerateContent.mockImplementationOnce(async (_, req) => ({
const invocation = tool.build(params); candidates: [
const result = await invocation.execute(new AbortController().signal); { content: { parts: [{ text: req[0].parts[0].text }] } },
],
}));
expect(convert).toHaveBeenCalledWith(htmlContent, { const tool = new WebFetchTool(mockConfig);
wordwrap: false, const params = { prompt: 'fetch https://example.com' };
selectors: [ const invocation = tool.build(params);
{ selector: 'a', options: { ignoreHref: true } }, const result = await invocation.execute(new AbortController().signal);
{ selector: 'img', format: 'skip' },
],
});
expect(result.llmContent).toContain(`Converted: ${htmlContent}`);
});
it('should return raw text for JSON content', async () => { if (shouldConvert) {
const jsonContent = '{"key": "value"}'; expect(convert).toHaveBeenCalledWith(content, {
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({ wordwrap: false,
ok: true, selectors: [
headers: new Headers({ 'content-type': 'application/json' }), { selector: 'a', options: { ignoreHref: true } },
text: () => Promise.resolve(jsonContent), { selector: 'img', format: 'skip' },
} as Response); ],
});
// Mock fallback LLM call to return the content passed to it expect(result.llmContent).toContain(`Converted: ${content}`);
mockGenerateContent.mockImplementationOnce(async (_, req) => ({ } else {
candidates: [{ content: { parts: [{ text: req[0].parts[0].text }] } }], expect(convert).not.toHaveBeenCalled();
})); expect(result.llmContent).toContain(content);
}
const tool = new WebFetchTool(mockConfig); },
const params = { prompt: 'fetch https://example.com' }; );
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(convert).not.toHaveBeenCalled();
expect(result.llmContent).toContain(jsonContent);
});
it('should return raw text for plain text content', async () => {
const textContent = 'Just some text.';
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
ok: true,
headers: new Headers({ 'content-type': 'text/plain' }),
text: () => Promise.resolve(textContent),
} as Response);
// Mock fallback LLM call to return the content passed to it
mockGenerateContent.mockImplementationOnce(async (_, req) => ({
candidates: [{ content: { parts: [{ text: req[0].parts[0].text }] } }],
}));
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(convert).not.toHaveBeenCalled();
expect(result.llmContent).toContain(textContent);
});
it('should treat content with no Content-Type header as HTML', async () => {
const content = '<p>No header</p>';
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
ok: true,
headers: new Headers(),
text: () => Promise.resolve(content),
} as Response);
// Mock fallback LLM call to return the content passed to it
mockGenerateContent.mockImplementationOnce(async (_, req) => ({
candidates: [{ content: { parts: [{ text: req[0].parts[0].text }] } }],
}));
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(convert).toHaveBeenCalledWith(content, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' },
],
});
expect(result.llmContent).toContain(`Converted: ${content}`);
});
}); });
describe('shouldConfirmExecute', () => { describe('shouldConfirmExecute', () => {
@@ -452,6 +411,28 @@ describe('WebFetchTool', () => {
let messageBus: MessageBus; let messageBus: MessageBus;
let mockUUID: Mock; let mockUUID: Mock;
const createToolWithMessageBus = (bus?: MessageBus) => {
const tool = new WebFetchTool(mockConfig, bus);
const params = { prompt: 'fetch https://example.com' };
return { tool, invocation: tool.build(params) };
};
const simulateMessageBusResponse = (
subscribeSpy: ReturnType<typeof vi.spyOn>,
confirmed: boolean,
correlationId = 'test-correlation-id',
) => {
const responseHandler = subscribeSpy.mock.calls[0][1] as (
response: ToolConfirmationResponse,
) => void;
const response: ToolConfirmationResponse = {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed,
};
responseHandler(response);
};
beforeEach(() => { beforeEach(() => {
policyEngine = new PolicyEngine(); policyEngine = new PolicyEngine();
messageBus = new MessageBus(policyEngine); messageBus = new MessageBus(policyEngine);
@@ -460,21 +441,15 @@ describe('WebFetchTool', () => {
}); });
it('should use message bus for confirmation when available', async () => { it('should use message bus for confirmation when available', async () => {
const tool = new WebFetchTool(mockConfig, messageBus); const { invocation } = createToolWithMessageBus(messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
// Mock message bus publish and subscribe
const publishSpy = vi.spyOn(messageBus, 'publish'); const publishSpy = vi.spyOn(messageBus, 'publish');
const subscribeSpy = vi.spyOn(messageBus, 'subscribe'); const subscribeSpy = vi.spyOn(messageBus, 'subscribe');
const unsubscribeSpy = vi.spyOn(messageBus, 'unsubscribe'); const unsubscribeSpy = vi.spyOn(messageBus, 'unsubscribe');
// Start confirmation process
const confirmationPromise = invocation.shouldConfirmExecute( const confirmationPromise = invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
// Verify confirmation request was published
expect(publishSpy).toHaveBeenCalledWith({ expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST, type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: { toolCall: {
@@ -484,49 +459,28 @@ describe('WebFetchTool', () => {
correlationId: 'test-correlation-id', correlationId: 'test-correlation-id',
}); });
// Verify subscription to response
expect(subscribeSpy).toHaveBeenCalledWith( expect(subscribeSpy).toHaveBeenCalledWith(
MessageBusType.TOOL_CONFIRMATION_RESPONSE, MessageBusType.TOOL_CONFIRMATION_RESPONSE,
expect.any(Function), expect.any(Function),
); );
// Simulate confirmation response simulateMessageBusResponse(subscribeSpy, true);
const responseHandler = subscribeSpy.mock.calls[0][1];
const response: ToolConfirmationResponse = {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-correlation-id',
confirmed: true,
};
responseHandler(response);
const result = await confirmationPromise; const result = await confirmationPromise;
expect(result).toBe(false); // No further confirmation needed expect(result).toBe(false);
expect(unsubscribeSpy).toHaveBeenCalled(); expect(unsubscribeSpy).toHaveBeenCalled();
}); });
it('should reject promise when confirmation is denied via message bus', async () => { it('should reject promise when confirmation is denied via message bus', async () => {
const tool = new WebFetchTool(mockConfig, messageBus); const { invocation } = createToolWithMessageBus(messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const subscribeSpy = vi.spyOn(messageBus, 'subscribe'); const subscribeSpy = vi.spyOn(messageBus, 'subscribe');
const confirmationPromise = invocation.shouldConfirmExecute( const confirmationPromise = invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
// Simulate denial response simulateMessageBusResponse(subscribeSpy, false);
const responseHandler = subscribeSpy.mock.calls[0][1];
const response: ToolConfirmationResponse = {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-correlation-id',
confirmed: false,
};
responseHandler(response);
// Should reject with error when denied
await expect(confirmationPromise).rejects.toThrow( await expect(confirmationPromise).rejects.toThrow(
'Tool execution for "WebFetch" denied by policy.', 'Tool execution for "WebFetch" denied by policy.',
); );
@@ -534,16 +488,11 @@ describe('WebFetchTool', () => {
it('should handle timeout gracefully', async () => { it('should handle timeout gracefully', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { invocation } = createToolWithMessageBus(messageBus);
const tool = new WebFetchTool(mockConfig, messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const confirmationPromise = invocation.shouldConfirmExecute( const confirmationPromise = invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
// Fast-forward past timeout
await vi.advanceTimersByTimeAsync(30000); await vi.advanceTimersByTimeAsync(30000);
const result = await confirmationPromise; const result = await confirmationPromise;
expect(result).not.toBe(false); expect(result).not.toBe(false);
@@ -553,16 +502,12 @@ describe('WebFetchTool', () => {
}); });
it('should handle abort signal during confirmation', async () => { it('should handle abort signal during confirmation', async () => {
const tool = new WebFetchTool(mockConfig, messageBus); const { invocation } = createToolWithMessageBus(messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const abortController = new AbortController(); const abortController = new AbortController();
const confirmationPromise = invocation.shouldConfirmExecute( const confirmationPromise = invocation.shouldConfirmExecute(
abortController.signal, abortController.signal,
); );
// Abort the operation
abortController.abort(); abortController.abort();
await expect(confirmationPromise).rejects.toThrow( await expect(confirmationPromise).rejects.toThrow(
@@ -571,42 +516,25 @@ describe('WebFetchTool', () => {
}); });
it('should fall back to legacy confirmation when no message bus', async () => { it('should fall back to legacy confirmation when no message bus', async () => {
const tool = new WebFetchTool(mockConfig); // No message bus const { invocation } = createToolWithMessageBus(); // No message bus
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const result = await invocation.shouldConfirmExecute( const result = await invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
// Should use legacy confirmation flow (returns confirmation details, not false)
expect(result).not.toBe(false); expect(result).not.toBe(false);
expect(result).toHaveProperty('type', 'info'); expect(result).toHaveProperty('type', 'info');
}); });
it('should ignore responses with wrong correlation ID', async () => { it('should ignore responses with wrong correlation ID', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { invocation } = createToolWithMessageBus(messageBus);
const tool = new WebFetchTool(mockConfig, messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const subscribeSpy = vi.spyOn(messageBus, 'subscribe'); const subscribeSpy = vi.spyOn(messageBus, 'subscribe');
const confirmationPromise = invocation.shouldConfirmExecute( const confirmationPromise = invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
// Send response with wrong correlation ID simulateMessageBusResponse(subscribeSpy, true, 'wrong-id');
const responseHandler = subscribeSpy.mock.calls[0][1];
const wrongResponse: ToolConfirmationResponse = {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'wrong-id',
confirmed: true,
};
responseHandler(wrongResponse);
// Should timeout since correct response wasn't received
await vi.advanceTimersByTimeAsync(30000); await vi.advanceTimersByTimeAsync(30000);
const result = await confirmationPromise; const result = await confirmationPromise;
expect(result).not.toBe(false); expect(result).not.toBe(false);
@@ -616,11 +544,7 @@ describe('WebFetchTool', () => {
}); });
it('should handle message bus publish errors gracefully', async () => { it('should handle message bus publish errors gracefully', async () => {
const tool = new WebFetchTool(mockConfig, messageBus); const { invocation } = createToolWithMessageBus(messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
// Mock publish to throw error
vi.spyOn(messageBus, 'publish').mockImplementation(() => { vi.spyOn(messageBus, 'publish').mockImplementation(() => {
throw new Error('Message bus error'); throw new Error('Message bus error');
}); });
@@ -628,7 +552,7 @@ describe('WebFetchTool', () => {
const result = await invocation.shouldConfirmExecute( const result = await invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
expect(result).toBe(false); // Should gracefully fall back expect(result).toBe(false);
}); });
it('should execute normally after confirmation approval', async () => { it('should execute normally after confirmation approval', async () => {
@@ -644,28 +568,17 @@ describe('WebFetchTool', () => {
], ],
}); });
const tool = new WebFetchTool(mockConfig, messageBus); const { invocation } = createToolWithMessageBus(messageBus);
const params = { prompt: 'fetch https://example.com' };
const invocation = tool.build(params);
const subscribeSpy = vi.spyOn(messageBus, 'subscribe'); const subscribeSpy = vi.spyOn(messageBus, 'subscribe');
// Start confirmation
const confirmationPromise = invocation.shouldConfirmExecute( const confirmationPromise = invocation.shouldConfirmExecute(
new AbortController().signal, new AbortController().signal,
); );
// Approve via message bus simulateMessageBusResponse(subscribeSpy, true);
const responseHandler = subscribeSpy.mock.calls[0][1];
responseHandler({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-correlation-id',
confirmed: true,
});
await confirmationPromise; await confirmationPromise;
// Execute the tool
const result = await invocation.execute(new AbortController().signal); const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Fetched content'); expect(result.llmContent).toContain('Fetched content');
+140 -171
View File
@@ -16,7 +16,12 @@ import {
import type { WriteFileToolParams } from './write-file.js'; import type { WriteFileToolParams } from './write-file.js';
import { getCorrectedFileContent, WriteFileTool } from './write-file.js'; import { getCorrectedFileContent, WriteFileTool } from './write-file.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import type { FileDiff, ToolEditConfirmationDetails } from './tools.js'; import type {
FileDiff,
ToolEditConfirmationDetails,
ToolInvocation,
ToolResult,
} from './tools.js';
import { ToolConfirmationOutcome } from './tools.js'; import { ToolConfirmationOutcome } from './tools.js';
import { type EditToolParams } from './edit.js'; import { type EditToolParams } from './edit.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
@@ -538,6 +543,7 @@ describe('WriteFileTool', () => {
}); });
it('should not await ideConfirmation promise', async () => { it('should not await ideConfirmation promise', async () => {
const IDE_DIFF_DELAY_MS = 50;
const filePath = path.join(rootDir, 'ide_no_await_file.txt'); const filePath = path.join(rootDir, 'ide_no_await_file.txt');
const params = { file_path: filePath, content: 'test' }; const params = { file_path: filePath, content: 'test' };
const invocation = tool.build(params); const invocation = tool.build(params);
@@ -547,7 +553,7 @@ describe('WriteFileTool', () => {
setTimeout(() => { setTimeout(() => {
diffPromiseResolved = true; diffPromiseResolved = true;
resolve({ status: 'accepted', content: 'ide-modified-content' }); resolve({ status: 'accepted', content: 'ide-modified-content' });
}, 50); // A small delay to ensure the check happens before resolution }, IDE_DIFF_DELAY_MS);
}); });
mockIdeClient.openDiff.mockReturnValue(diffPromise); mockIdeClient.openDiff.mockReturnValue(diffPromise);
@@ -571,6 +577,20 @@ describe('WriteFileTool', () => {
describe('execute', () => { describe('execute', () => {
const abortSignal = new AbortController().signal; const abortSignal = new AbortController().signal;
async function confirmExecution(
invocation: ToolInvocation<WriteFileToolParams, ToolResult>,
signal: AbortSignal = abortSignal,
) {
const confirmDetails = await invocation.shouldConfirmExecute(signal);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&
confirmDetails.onConfirm
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
}
it('should write a new file with a relative path', async () => { it('should write a new file with a relative path', async () => {
const relativePath = 'execute_relative_new_file.txt'; const relativePath = 'execute_relative_new_file.txt';
const filePath = path.join(rootDir, relativePath); const filePath = path.join(rootDir, relativePath);
@@ -624,14 +644,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: proposedContent }; const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params); const invocation = tool.build(params);
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); await confirmExecution(invocation);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&
confirmDetails.onConfirm
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
@@ -681,14 +694,7 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content: proposedContent }; const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params); const invocation = tool.build(params);
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); await confirmExecution(invocation);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&
confirmDetails.onConfirm
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
const result = await invocation.execute(abortSignal); const result = await invocation.execute(abortSignal);
@@ -725,15 +731,8 @@ describe('WriteFileTool', () => {
const params = { file_path: filePath, content }; const params = { file_path: filePath, content };
const invocation = tool.build(params); const invocation = tool.build(params);
// Simulate confirmation if your logic requires it before execute, or remove if not needed for this path
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); await confirmExecution(invocation);
if (
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails &&
confirmDetails.onConfirm
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
await invocation.execute(abortSignal); await invocation.execute(abortSignal);
@@ -743,52 +742,44 @@ describe('WriteFileTool', () => {
expect(fs.readFileSync(filePath, 'utf8')).toBe(content); expect(fs.readFileSync(filePath, 'utf8')).toBe(content);
}); });
it('should include modification message when proposed content is modified', async () => { it.each([
const filePath = path.join(rootDir, 'new_file_modified.txt'); {
const content = 'New file content modified by user';
mockEnsureCorrectFileContent.mockResolvedValue(content);
const params = {
file_path: filePath,
content,
modified_by_user: true, modified_by_user: true,
}; shouldIncludeMessage: true,
const invocation = tool.build(params); testCase: 'when modified_by_user is true',
const result = await invocation.execute(abortSignal); },
{
expect(result.llmContent).toMatch(/User modified the `content`/);
});
it('should not include modification message when proposed content is not modified', async () => {
const filePath = path.join(rootDir, 'new_file_unmodified.txt');
const content = 'New file content not modified';
mockEnsureCorrectFileContent.mockResolvedValue(content);
const params = {
file_path: filePath,
content,
modified_by_user: false, modified_by_user: false,
}; shouldIncludeMessage: false,
const invocation = tool.build(params); testCase: 'when modified_by_user is false',
const result = await invocation.execute(abortSignal); },
{
modified_by_user: undefined,
shouldIncludeMessage: false,
testCase: 'when modified_by_user is not provided',
},
])(
'should $testCase include modification message',
async ({ modified_by_user, shouldIncludeMessage }) => {
const filePath = path.join(rootDir, `new_file_${modified_by_user}.txt`);
const content = 'New file content';
mockEnsureCorrectFileContent.mockResolvedValue(content);
expect(result.llmContent).not.toMatch(/User modified the `content`/); const params: WriteFileToolParams = {
}); file_path: filePath,
content,
...(modified_by_user !== undefined && { modified_by_user }),
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
it('should not include modification message when modified_by_user is not provided', async () => { if (shouldIncludeMessage) {
const filePath = path.join(rootDir, 'new_file_unmodified.txt'); expect(result.llmContent).toMatch(/User modified the `content`/);
const content = 'New file content not modified'; } else {
mockEnsureCorrectFileContent.mockResolvedValue(content); expect(result.llmContent).not.toMatch(/User modified the `content`/);
}
const params = { },
file_path: filePath, );
content,
};
const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).not.toMatch(/User modified the `content`/);
});
}); });
describe('workspace boundary validation', () => { describe('workspace boundary validation', () => {
@@ -814,114 +805,92 @@ describe('WriteFileTool', () => {
describe('specific error types for write failures', () => { describe('specific error types for write failures', () => {
const abortSignal = new AbortController().signal; const abortSignal = new AbortController().signal;
it('should return PERMISSION_DENIED error when write fails with EACCES', async () => { it.each([
const filePath = path.join(rootDir, 'permission_denied_file.txt'); {
const content = 'test content'; errorCode: 'EACCES',
errorType: ToolErrorType.PERMISSION_DENIED,
errorMessage: 'Permission denied',
expectedMessagePrefix: 'Permission denied writing to file',
mockFsExistsSync: false,
restoreAllMocks: false,
},
{
errorCode: 'ENOSPC',
errorType: ToolErrorType.NO_SPACE_LEFT,
errorMessage: 'No space left on device',
expectedMessagePrefix: 'No space left on device',
mockFsExistsSync: false,
restoreAllMocks: false,
},
{
errorCode: 'EISDIR',
errorType: ToolErrorType.TARGET_IS_DIRECTORY,
errorMessage: 'Is a directory',
expectedMessagePrefix: 'Target is a directory, not a file',
mockFsExistsSync: true,
restoreAllMocks: false,
},
{
errorCode: undefined,
errorType: ToolErrorType.FILE_WRITE_FAILURE,
errorMessage: 'Generic write error',
expectedMessagePrefix: 'Error writing to file',
mockFsExistsSync: false,
restoreAllMocks: true,
},
])(
'should return $errorType error when write fails with $errorCode',
async ({
errorCode,
errorType,
errorMessage,
expectedMessagePrefix,
mockFsExistsSync,
restoreAllMocks,
}) => {
const filePath = path.join(rootDir, `${errorType}_file.txt`);
const content = 'test content';
// Mock FileSystemService writeTextFile to throw EACCES error if (restoreAllMocks) {
vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => { vi.restoreAllMocks();
const error = new Error('Permission denied') as NodeJS.ErrnoException;
error.code = 'EACCES';
return Promise.reject(error);
});
const params = { file_path: filePath, content };
const invocation = tool.build(params);
const result = await invocation.execute(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 writing to file: ${filePath} (EACCES)`,
);
});
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 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';
return Promise.reject(error);
});
const params = { file_path: filePath, content };
const invocation = tool.build(params);
const result = await invocation.execute(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 on device: ${filePath} (ENOSPC)`,
);
});
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 FileSystemService writeTextFile to throw EISDIR error // eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => { let existsSyncSpy: any;
const error = new Error('Is a directory') as NodeJS.ErrnoException;
error.code = 'EISDIR';
return Promise.reject(error);
});
const params = { file_path: dirPath, content }; try {
const invocation = tool.build(params); if (mockFsExistsSync) {
const result = await invocation.execute(abortSignal); const originalExistsSync = fs.existsSync;
existsSyncSpy = vi
.spyOn(fs, 'existsSync')
.mockImplementation((path) =>
path === filePath ? false : originalExistsSync(path as string),
);
}
expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY); vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {
expect(result.llmContent).toContain( const error = new Error(errorMessage) as NodeJS.ErrnoException;
`Target is a directory, not a file: ${dirPath} (EISDIR)`, if (errorCode) error.code = errorCode;
); return Promise.reject(error);
expect(result.returnDisplay).toContain( });
`Target is a directory, not a file: ${dirPath} (EISDIR)`,
);
vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync); const params = { file_path: filePath, content };
}); const invocation = tool.build(params);
const result = await invocation.execute(abortSignal);
it('should return FILE_WRITE_FAILURE for generic write errors', async () => { expect(result.error?.type).toBe(errorType);
const filePath = path.join(rootDir, 'generic_error_file.txt'); const errorSuffix = errorCode ? ` (${errorCode})` : '';
const content = 'test content'; const expectedMessage = errorCode
? `${expectedMessagePrefix}: ${filePath}${errorSuffix}`
// Ensure fs.existsSync is not mocked for this test : `${expectedMessagePrefix}: ${errorMessage}`;
vi.restoreAllMocks(); expect(result.llmContent).toContain(expectedMessage);
expect(result.returnDisplay).toContain(expectedMessage);
// Mock FileSystemService writeTextFile to throw generic error } finally {
vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => if (existsSyncSpy) {
Promise.reject(new Error('Generic write error')), existsSyncSpy.mockRestore();
); }
}
const params = { file_path: filePath, content }; },
const invocation = tool.build(params); );
const result = await invocation.execute(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(
'Error writing to file: Generic write error',
);
});
}); });
}); });