mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
Refactored 4 files of tools package (#13235)
Co-authored-by: riddhi <duttariddhi@google.com>
This commit is contained in:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user