Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0

This commit is contained in:
mingholy.lmh
2025-09-10 21:01:40 +08:00
583 changed files with 30160 additions and 10770 deletions

View File

@@ -1,8 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShellTool > getDescription > should return the non-windows description when not on windows 1`] = `
"
This tool executes a given shell command as \`bash -c <command>\`.
"This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
@@ -21,8 +20,6 @@ exports[`ShellTool > getDescription > should return the non-windows description
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.
The following information is returned:
Command: Executed command.
@@ -37,8 +34,7 @@ exports[`ShellTool > getDescription > should return the non-windows description
`;
exports[`ShellTool > getDescription > should return the windows description when on windows 1`] = `
"
This tool executes a given shell command as \`cmd.exe /c <command>\`.
"This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
@@ -57,8 +53,6 @@ exports[`ShellTool > getDescription > should return the windows description when
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
The following information is returned:
Command: Executed command.

View File

@@ -5,7 +5,7 @@
*/
import * as Diff from 'diff';
import { DiffStat } from './tools.js';
import type { DiffStat } from './tools.js';
export const DEFAULT_DIFF_OPTIONS: Diff.PatchOptions = {
context: 3,

View File

@@ -26,15 +26,23 @@ vi.mock('../utils/editor.js', () => ({
openDiff: mockOpenDiff,
}));
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest';
import { applyReplacement, EditTool, EditToolParams } from './edit.js';
import { FileDiff, ToolConfirmationOutcome } from './tools.js';
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
import type { Mock } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { EditToolParams } from './edit.js';
import { applyReplacement, EditTool } from './edit.js';
import type { FileDiff } from './tools.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { ApprovalMode, Config } from '../config/config.js';
import { Content, Part, SchemaUnion } from '@google/genai';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import type { Content, Part, SchemaUnion } from '@google/genai';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
@@ -628,6 +636,33 @@ describe('EditTool', () => {
expect(result.llmContent).toMatch(/No changes to apply/);
expect(result.returnDisplay).toMatch(/No changes to apply/);
});
it('should return EDIT_NO_CHANGE error if replacement results in identical content', async () => {
// This can happen if ensureCorrectEdit finds a fuzzy match, but the literal
// string replacement with `replaceAll` results in no change.
const initialContent = 'line 1\nline 2\nline 3'; // Note the double space
fs.writeFileSync(filePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
// old_string has a single space, so it won't be found by replaceAll
old_string: 'line 1\nline 2\nline 3',
new_string: 'line 1\nnew line 2\nline 3',
};
// Mock ensureCorrectEdit to simulate it finding a match (e.g., via fuzzy matching)
// but it doesn't correct the old_string to the literal content.
mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 1 });
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_CHANGE);
expect(result.returnDisplay).toMatch(
/No changes to apply. The new content is identical to the current content./,
);
// Ensure the file was not actually changed
expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent);
});
});
describe('Error Scenarios', () => {

View File

@@ -4,29 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as Diff from 'diff';
import {
BaseDeclarativeTool,
Kind,
import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
ToolInvocation,
ToolLocation,
ToolResult,
ToolResultDisplay,
} from './tools.js';
import { BaseDeclarativeTool, Kind, ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js';
import { Config, ApprovalMode } from '../config/config.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { ensureCorrectEdit } from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import { ReadFileTool } from './read-file.js';
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
import type {
ModifiableDeclarativeTool,
ModifyContext,
} from './modifiable-tool.js';
import { IDEConnectionStatus } from '../ide/ide-client.js';
import { FileOperation } from '../telemetry/metrics.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
import { getSpecificMimeType } from '../utils/fileUtils.js';
export function applyReplacement(
currentContent: string | null,
@@ -199,12 +206,23 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
};
}
const newContent = applyReplacement(
currentContent,
finalOldString,
finalNewString,
isNewFile,
);
const newContent = !error
? applyReplacement(
currentContent,
finalOldString,
finalNewString,
isNewFile,
)
: (currentContent ?? '');
if (!error && fileExists && currentContent === newContent) {
error = {
display:
'No changes to apply. The new content is identical to the current content.',
raw: `No changes to apply. The new content is identical to the current content in file: ${params.file_path}`,
type: ToolErrorType.EDIT_NO_CHANGE,
};
}
return {
currentContent,
@@ -345,12 +363,21 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
.writeTextFile(this.params.file_path, editData.newContent);
let displayResult: ToolResultDisplay;
const fileName = path.basename(this.params.file_path);
const originallyProposedContent =
this.params.ai_proposed_string || this.params.new_string;
const diffStat = getDiffStat(
fileName,
editData.currentContent ?? '',
originallyProposedContent,
this.params.new_string,
);
if (editData.isNewFile) {
displayResult = `Created ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`;
} else {
// Generate diff for display, even though core logic doesn't technically need it
// The CLI wrapper will use this part of the ToolResult
const fileName = path.basename(this.params.file_path);
const fileDiff = Diff.createPatch(
fileName,
editData.currentContent ?? '', // Should not be null here if not isNewFile
@@ -359,14 +386,6 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
const originallyProposedContent =
this.params.ai_proposed_string || this.params.new_string;
const diffStat = getDiffStat(
fileName,
editData.currentContent ?? '',
originallyProposedContent,
this.params.new_string,
);
displayResult = {
fileDiff,
fileName,
@@ -387,6 +406,26 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
);
}
const lines = editData.newContent.split('\n').length;
const mimetype = getSpecificMimeType(this.params.file_path);
const extension = path.extname(this.params.file_path);
const programming_language = getProgrammingLanguage({
file_path: this.params.file_path,
});
logFileOperation(
this.config,
new FileOperationEvent(
EditTool.Name,
editData.isNewFile ? FileOperation.CREATE : FileOperation.UPDATE,
lines,
mimetype,
extension,
diffStat,
programming_language,
),
);
return {
llmContent: llmSuccessMessageParts.join(' '),
returnDisplay: displayResult,

View File

@@ -4,15 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { GlobTool, GlobToolParams, GlobPath, sortFileEntries } from './glob.js';
import type { GlobToolParams, GlobPath } from './glob.js';
import { GlobTool, sortFileEntries } from './glob.js';
import { partListUnionToString } from '../core/geminiRequest.js';
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { Config } from '../config/config.js';
import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
describe('GlobTool', () => {
let tempRootDir: string; // This will be the rootDirectory for the GlobTool instance
@@ -25,6 +30,9 @@ describe('GlobTool', () => {
getFileFilteringRespectGitIgnore: () => true,
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
beforeEach(async () => {
@@ -203,6 +211,29 @@ describe('GlobTool', () => {
path.resolve(tempRootDir, 'older.sortme'),
);
});
it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => {
// Bypassing validation to test execute method directly
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);
const params: GlobToolParams = { pattern: '*.txt', path: '/etc' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
expect(result.returnDisplay).toBe('Path is not within workspace');
});
it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {
vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = globTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.error?.type).toBe(ToolErrorType.GLOB_EXECUTION_ERROR);
expect(result.llmContent).toContain(
'Error during glob search operation: Glob failed',
);
// Reset glob.
vi.mocked(glob.glob).mockReset();
});
});
describe('validateToolParams', () => {

View File

@@ -4,18 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
import { glob, escape } from 'glob';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { shortenPath, makeRelative } from '../utils/paths.js';
import { Config } from '../config/config.js';
import type { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
export interface GlobPath {
@@ -115,9 +111,14 @@ class GlobToolInvocation extends BaseToolInvocation<
this.params.path,
);
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
return {
llmContent: `Error: Path "${this.params.path}" is not within any workspace directory`,
llmContent: rawError,
returnDisplay: `Path is not within workspace`,
error: {
message: rawError,
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
},
};
}
searchDirectories = [searchDirAbsolute];
@@ -149,7 +150,7 @@ class GlobToolInvocation extends BaseToolInvocation<
stat: true,
nocase: !this.params.case_sensitive,
dot: true,
ignore: ['**/node_modules/**', '**/.git/**'],
ignore: this.config.getFileExclusions().getGlobExcludes(),
follow: false,
signal,
})) as GlobPath[];
@@ -234,9 +235,14 @@ class GlobToolInvocation extends BaseToolInvocation<
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`GlobLogic execute Error: ${errorMessage}`, error);
const rawError = `Error during glob search operation: ${errorMessage}`;
return {
llmContent: `Error during glob search operation: ${errorMessage}`,
llmContent: rawError,
returnDisplay: `Error: An unexpected error occurred.`,
error: {
message: rawError,
type: ToolErrorType.GLOB_EXECUTION_ERROR,
},
};
}
}

View File

@@ -5,13 +5,17 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { GrepTool, GrepToolParams } from './grep.js';
import path from 'path';
import fs from 'fs/promises';
import os from 'os';
import { Config } from '../config/config.js';
import type { GrepToolParams } from './grep.js';
import { GrepTool } from './grep.js';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { ToolErrorType } from './tool-error.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
// Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({
@@ -33,14 +37,12 @@ describe('GrepTool', () => {
let grepTool: GrepTool;
const abortSignal = new AbortController().signal;
const mockFileService = {
getGeminiIgnorePatterns: () => [],
} as unknown as FileDiscoveryService;
const mockConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
getFileService: () => mockFileService,
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
beforeEach(async () => {
@@ -230,42 +232,13 @@ describe('GrepTool', () => {
);
});
it('should exclude files matching geminiIgnorePatterns', async () => {
// Create a file that should be ignored
await fs.writeFile(
path.join(tempRootDir, 'ignored-file.txt'),
'this file should be ignored\nit contains the word world',
);
// Update the mock file service to return ignore patterns
mockFileService.getGeminiIgnorePatterns = () => ['ignored-file.txt'];
// Re-create the grep tool with the updated mock
const grepToolWithIgnore = new GrepTool(mockConfig);
// Search for 'world' which exists in both the regular file and the ignored file
const params: GrepToolParams = { pattern: 'world' };
const invocation = grepToolWithIgnore.build(params);
it('should return a GREP_EXECUTION_ERROR on failure', async () => {
vi.mocked(glob.globStream).mockRejectedValue(new Error('Glob failed'));
const params: GrepToolParams = { pattern: 'hello' };
const invocation = grepTool.build(params);
const result = await invocation.execute(abortSignal);
// Should only find matches in the non-ignored files (3 matches)
expect(result.llmContent).toContain(
'Found 3 matches for pattern "world" in the workspace directory',
);
// Should find matches in the regular files
expect(result.llmContent).toContain('File: fileA.txt');
expect(result.llmContent).toContain('L1: hello world');
expect(result.llmContent).toContain('L2: second line with world');
expect(result.llmContent).toContain(
`File: ${path.join('sub', 'fileC.txt')}`,
);
expect(result.llmContent).toContain('L1: another world in sub dir');
// Should NOT find matches in the ignored file
expect(result.llmContent).not.toContain('ignored-file.txt');
expect(result.returnDisplay).toBe('Found 3 matches');
expect(result.error?.type).toBe(ToolErrorType.GREP_EXECUTION_ERROR);
vi.mocked(glob.globStream).mockReset();
});
});
@@ -285,15 +258,13 @@ describe('GrepTool', () => {
);
// Create a mock config with multiple directories
const multiDirFileService = {
getGeminiIgnorePatterns: () => [],
};
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getFileService: () => multiDirFileService,
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);
@@ -340,15 +311,13 @@ describe('GrepTool', () => {
);
// Create a mock config with multiple directories
const multiDirFileService = {
getGeminiIgnorePatterns: () => [],
};
const multiDirConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, [secondDir]),
getFileService: () => multiDirFileService,
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);
@@ -408,6 +377,9 @@ describe('GrepTool', () => {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
getFileExclusions: () => ({
getGlobExcludes: () => [],
}),
} as unknown as Config;
const multiDirGrepTool = new GrepTool(multiDirConfig);

View File

@@ -4,23 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import fsPromises from 'fs/promises';
import path from 'path';
import { EOL } from 'os';
import { spawn } from 'child_process';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import { globStream } from 'glob';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import { isGitRepository } from '../utils/gitUtils.js';
import { Config } from '../config/config.js';
import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
// --- Interfaces ---
@@ -62,11 +59,14 @@ class GrepToolInvocation extends BaseToolInvocation<
GrepToolParams,
ToolResult
> {
private readonly fileExclusions: FileExclusions;
constructor(
private readonly config: Config,
params: GrepToolParams,
) {
super(params);
this.fileExclusions = config.getFileExclusions();
}
/**
@@ -243,6 +243,10 @@ class GrepToolInvocation extends BaseToolInvocation<
return {
llmContent: `Error during grep search operation: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.GREP_EXECUTION_ERROR,
},
};
}
}
@@ -321,7 +325,6 @@ class GrepToolInvocation extends BaseToolInvocation<
/**
* Gets a description of the grep operation
* @param params Parameters for the grep operation
* @returns A string describing the grep
*/
getDescription(): string {
@@ -431,7 +434,27 @@ class GrepToolInvocation extends BaseToolInvocation<
if (grepAvailable) {
strategyUsed = 'system grep';
const grepArgs = ['-r', '-n', '-H', '-E'];
const commonExcludes = ['.git', 'node_modules', 'bower_components'];
// Extract directory names from exclusion patterns for grep --exclude-dir
const globExcludes = this.fileExclusions.getGlobExcludes();
const commonExcludes = globExcludes
.map((pattern) => {
let dir = pattern;
if (dir.startsWith('**/')) {
dir = dir.substring(3);
}
if (dir.endsWith('/**')) {
dir = dir.slice(0, -3);
} else if (dir.endsWith('/')) {
dir = dir.slice(0, -1);
}
// Only consider patterns that are likely directories. This filters out file patterns.
if (dir && !dir.includes('/') && !dir.includes('*')) {
return dir;
}
return null;
})
.filter((dir): dir is string => !!dir);
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
if (include) {
grepArgs.push(`--include=${include}`);
@@ -514,19 +537,7 @@ class GrepToolInvocation extends BaseToolInvocation<
);
strategyUsed = 'javascript fallback';
const globPattern = include ? include : '**/*';
// Get the file discovery service to check ignore patterns
const fileDiscovery = this.config.getFileService();
// Basic ignore patterns
const ignorePatterns = [
'.git/**',
'node_modules/**',
'bower_components/**',
'.svn/**',
'.hg/**',
...fileDiscovery.getGeminiIgnorePatterns(),
]; // Use glob patterns for ignores here
const ignorePatterns = this.fileExclusions.getGlobExcludes();
const filesIterator = globStream(globPattern, {
cwd: absolutePath,

View File

@@ -7,8 +7,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('fs', () => ({
default: {
@@ -17,11 +17,13 @@ vi.mock('fs', () => ({
},
statSync: vi.fn(),
readdirSync: vi.fn(),
mkdirSync: vi.fn(),
}));
import { LSTool } from './ls.js';
import { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import type { Config } from '../config/config.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { ToolErrorType } from './tool-error.js';
describe('LSTool', () => {
let lsTool: LSTool;
@@ -287,6 +289,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Path is not a directory');
expect(result.returnDisplay).toBe('Error: Path is not a directory.');
expect(result.error?.type).toBe(ToolErrorType.PATH_IS_NOT_A_DIRECTORY);
});
it('should handle non-existent paths', async () => {
@@ -301,6 +304,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Error listing directory');
expect(result.returnDisplay).toBe('Error: Failed to list directory.');
expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR);
});
it('should sort directories first, then files alphabetically', async () => {
@@ -356,6 +360,7 @@ describe('LSTool', () => {
expect(result.llmContent).toContain('Error listing directory');
expect(result.llmContent).toContain('permission denied');
expect(result.returnDisplay).toBe('Error: Failed to list directory.');
expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR);
});
it('should throw for invalid params at build time', async () => {

View File

@@ -4,17 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
import fs from 'node:fs';
import path from 'node:path';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import type { Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
/**
* Parameters for the LS tool
@@ -114,11 +111,19 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
}
// Helper for consistent error formatting
private errorResult(llmContent: string, returnDisplay: string): ToolResult {
private errorResult(
llmContent: string,
returnDisplay: string,
type: ToolErrorType,
): ToolResult {
return {
llmContent,
// Keep returnDisplay simpler in core logic
returnDisplay: `Error: ${returnDisplay}`,
error: {
message: llmContent,
type,
},
};
}
@@ -135,12 +140,14 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
return this.errorResult(
`Error: Directory not found or inaccessible: ${this.params.path}`,
`Directory not found or inaccessible.`,
ToolErrorType.FILE_NOT_FOUND,
);
}
if (!stats.isDirectory()) {
return this.errorResult(
`Error: Path is not a directory: ${this.params.path}`,
`Path is not a directory.`,
ToolErrorType.PATH_IS_NOT_A_DIRECTORY,
);
}
@@ -253,7 +260,11 @@ class LSToolInvocation extends BaseToolInvocation<LSToolParams, ToolResult> {
};
} catch (error) {
const errorMsg = `Error listing directory: ${error instanceof Error ? error.message : String(error)}`;
return this.errorResult(errorMsg, 'Failed to list directory.');
return this.errorResult(
errorMsg,
'Failed to list directory.',
ToolErrorType.LS_EXECUTION_ERROR,
);
}
}
}

View File

@@ -7,9 +7,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { McpClientManager } from './mcp-client-manager.js';
import { McpClient } from './mcp-client.js';
import { ToolRegistry } from './tool-registry.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import type { ToolRegistry } from './tool-registry.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
vi.mock('./mcp-client.js', async () => {
const originalModule = await vi.importActual('./mcp-client.js');

View File

@@ -4,16 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MCPServerConfig } from '../config/config.js';
import { ToolRegistry } from './tool-registry.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import type { MCPServerConfig } from '../config/config.js';
import type { ToolRegistry } from './tool-registry.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import {
McpClient,
MCPDiscoveryState,
populateMcpServerCommand,
} from './mcp-client.js';
import { getErrorMessage } from '../utils/errors.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
/**
* Manages the lifecycle of multiple MCP clients, including local child processes.

View File

@@ -12,6 +12,7 @@ import {
isEnabled,
hasValidTypes,
McpClient,
hasNetworkTransport,
} from './mcp-client.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -19,9 +20,9 @@ import * as ClientLib from '@modelcontextprotocol/sdk/client/index.js';
import * as GenAiLib from '@google/genai';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { AuthProviderType } from '../config/config.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import { ToolRegistry } from './tool-registry.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import type { ToolRegistry } from './tool-registry.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
@@ -566,4 +567,34 @@ describe('mcp-client', () => {
expect(hasValidTypes(schema)).toBe(true);
});
});
describe('hasNetworkTransport', () => {
it('should return true if only url is provided', () => {
const config = { url: 'http://example.com' };
expect(hasNetworkTransport(config)).toBe(true);
});
it('should return true if only httpUrl is provided', () => {
const config = { httpUrl: 'http://example.com' };
expect(hasNetworkTransport(config)).toBe(true);
});
it('should return true if both url and httpUrl are provided', () => {
const config = {
url: 'http://example.com/sse',
httpUrl: 'http://example.com/http',
};
expect(hasNetworkTransport(config)).toBe(true);
});
it('should return false if neither url nor httpUrl is provided', () => {
const config = { command: 'do-something' };
expect(hasNetworkTransport(config)).toBe(false);
});
it('should return false for an empty config object', () => {
const config = {};
expect(hasNetworkTransport(config)).toBe(false);
});
});
});

View File

@@ -5,38 +5,41 @@
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import {
SSEClientTransport,
SSEClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/sse.js';
import {
StreamableHTTPClientTransport,
StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import type { StreamableHTTPClientTransportOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type {
Prompt,
ListPromptsResultSchema,
GetPromptResult,
} from '@modelcontextprotocol/sdk/types.js';
import {
ListPromptsResultSchema,
GetPromptResultSchema,
ListRootsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { parse } from 'shell-quote';
import { AuthProviderType, MCPServerConfig } from '../config/config.js';
import type { MCPServerConfig } from '../config/config.js';
import { AuthProviderType } from '../config/config.js';
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { FunctionDeclaration, mcpToTool } from '@google/genai';
import { ToolRegistry } from './tool-registry.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import type { FunctionDeclaration } from '@google/genai';
import { mcpToTool } from '@google/genai';
import type { ToolRegistry } from './tool-registry.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import { MCPOAuthProvider } from '../mcp/oauth-provider.js';
import { OAuthUtils } from '../mcp/oauth-utils.js';
import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js';
import { getErrorMessage } from '../utils/errors.js';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { Unsubscribe, WorkspaceContext } from '../utils/workspaceContext.js';
import type {
Unsubscribe,
WorkspaceContext,
} from '../utils/workspaceContext.js';
export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes
@@ -325,15 +328,12 @@ async function handleAutomaticOAuth(
OAuthUtils.parseWWWAuthenticateHeader(wwwAuthenticate);
if (resourceMetadataUri) {
oauthConfig = await OAuthUtils.discoverOAuthConfig(resourceMetadataUri);
} else if (mcpServerConfig.url) {
// Fallback: try to discover OAuth config from the base URL for SSE
const sseUrl = new URL(mcpServerConfig.url);
const baseUrl = `${sseUrl.protocol}//${sseUrl.host}`;
oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl);
} else if (mcpServerConfig.httpUrl) {
// Fallback: try to discover OAuth config from the base URL for HTTP
const httpUrl = new URL(mcpServerConfig.httpUrl);
const baseUrl = `${httpUrl.protocol}//${httpUrl.host}`;
} else if (hasNetworkTransport(mcpServerConfig)) {
// Fallback: try to discover OAuth config from the base URL
const serverUrl = new URL(
mcpServerConfig.httpUrl || mcpServerConfig.url!,
);
const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`;
oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl);
}
@@ -783,6 +783,16 @@ export async function invokeMcpPrompt(
}
}
/**
* @visiblefortesting
* Checks if the MCP server configuration has a network transport URL (SSE or HTTP).
* @param config The MCP server configuration.
* @returns True if a `url` or `httpUrl` is present, false otherwise.
*/
export function hasNetworkTransport(config: MCPServerConfig): boolean {
return !!(config.url || config.httpUrl);
}
/**
* Creates and connects an MCP client to a server based on the provided configuration.
* It determines the appropriate transport (Stdio, SSE, or Streamable HTTP) and
@@ -879,10 +889,7 @@ export async function connectToMcpServer(
} catch (error) {
// Check if this is a 401 error that might indicate OAuth is required
const errorString = String(error);
if (
errorString.includes('401') &&
(mcpServerConfig.httpUrl || mcpServerConfig.url)
) {
if (errorString.includes('401') && hasNetworkTransport(mcpServerConfig)) {
mcpServerRequiresOAuth.set(mcpServerName, true);
// Only trigger automatic OAuth discovery for HTTP servers or when OAuth is explicitly configured
// For SSE servers, we should not trigger new OAuth flows automatically
@@ -922,15 +929,18 @@ export async function connectToMcpServer(
let wwwAuthenticate = extractWWWAuthenticateHeader(errorString);
// If we didn't get the header from the error string, try to get it from the server
if (!wwwAuthenticate && mcpServerConfig.url) {
if (!wwwAuthenticate && hasNetworkTransport(mcpServerConfig)) {
console.log(
`No www-authenticate header in error, trying to fetch it from server...`,
);
try {
const response = await fetch(mcpServerConfig.url, {
const urlToFetch = mcpServerConfig.httpUrl || mcpServerConfig.url!;
const response = await fetch(urlToFetch, {
method: 'HEAD',
headers: {
Accept: 'text/event-stream',
Accept: mcpServerConfig.httpUrl
? 'application/json'
: 'text/event-stream',
},
signal: AbortSignal.timeout(5000),
});
@@ -945,7 +955,9 @@ export async function connectToMcpServer(
}
} catch (fetchError) {
console.debug(
`Failed to fetch www-authenticate header: ${getErrorMessage(fetchError)}`,
`Failed to fetch www-authenticate header: ${getErrorMessage(
fetchError,
)}`,
);
}
}
@@ -1071,12 +1083,14 @@ export async function connectToMcpServer(
);
}
// For SSE servers, try to discover OAuth configuration from the base URL
// For SSE/HTTP servers, try to discover OAuth configuration from the base URL
console.log(`🔍 Attempting OAuth discovery for '${mcpServerName}'...`);
if (mcpServerConfig.url) {
const sseUrl = new URL(mcpServerConfig.url);
const baseUrl = `${sseUrl.protocol}//${sseUrl.host}`;
if (hasNetworkTransport(mcpServerConfig)) {
const serverUrl = new URL(
mcpServerConfig.httpUrl || mcpServerConfig.url!,
);
const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`;
try {
// Try to discover OAuth configuration from the base URL
@@ -1096,14 +1110,15 @@ export async function connectToMcpServer(
// Perform OAuth authentication
// Pass the server URL for proper discovery
const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url;
const authServerUrl =
mcpServerConfig.httpUrl || mcpServerConfig.url;
console.log(
`Starting OAuth authentication for server '${mcpServerName}'...`,
);
await MCPOAuthProvider.authenticate(
mcpServerName,
oauthAuthConfig,
serverUrl,
authServerUrl,
);
// Retry connection with OAuth token

View File

@@ -5,18 +5,14 @@
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
Mocked,
} from 'vitest';
import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import { ToolResult, ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
import { CallableTool, Part } from '@google/genai';
import type { ToolResult } from './tools.js';
import { ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome
import type { CallableTool, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
// Mock @google/genai mcpToTool and CallableTool
// We only need to mock the parts of CallableTool that DiscoveredMCPTool uses.
@@ -189,7 +185,7 @@ describe('DiscoveredMCPTool', () => {
{ isErrorValue: true, description: 'true (bool)' },
{ isErrorValue: 'true', description: '"true" (str)' },
])(
'should consider a ToolResult with isError $description to be a failure',
'should return a structured error if MCP tool reports an error',
async ({ isErrorValue }) => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
@@ -199,6 +195,10 @@ describe('DiscoveredMCPTool', () => {
inputSchema,
);
const params = { param: 'isErrorTrueCase' };
const functionCall = {
name: serverToolName,
args: params,
};
const errorResponse = { isError: isErrorValue };
const mockMcpToolResponseParts: Part[] = [
@@ -210,16 +210,19 @@ describe('DiscoveredMCPTool', () => {
},
];
mockCallTool.mockResolvedValue(mockMcpToolResponseParts);
const expectedError = new Error(
`MCP tool '${serverToolName}' reported tool error with response: ${JSON.stringify(
mockMcpToolResponseParts,
)}`,
);
const expectedErrorMessage = `MCP tool '${
serverToolName
}' reported tool error for function call: ${safeJsonStringify(
functionCall,
)} with response: ${safeJsonStringify(mockMcpToolResponseParts)}`;
const invocation = tool.build(params);
await expect(
invocation.execute(new AbortController().signal),
).rejects.toThrow(expectedError);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
expect(result.llmContent).toBe(expectedErrorMessage);
expect(result.returnDisplay).toContain(
`Error: MCP tool '${serverToolName}' reported an error.`,
);
},
);
@@ -743,4 +746,13 @@ describe('DiscoveredMCPTool', () => {
}
});
});
describe('DiscoveredMCPToolInvocation', () => {
it('should return the stringified params from getDescription', () => {
const params = { param: 'testValue', param2: 'anotherOne' };
const invocation = tool.build(params);
const description = invocation.getDescription();
expect(description).toBe('{"param":"testValue","param2":"anotherOne"}');
});
});
});

View File

@@ -4,17 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolInvocation,
ToolMcpConfirmationDetails,
ToolResult,
} from './tools.js';
import { CallableTool, FunctionCall, Part } from '@google/genai';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
} from './tools.js';
import type { CallableTool, FunctionCall, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
type ToolParams = Record<string, unknown>;
@@ -138,9 +142,19 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
// Ensure the response is not an error
if (this.isMCPToolError(rawResponseParts)) {
throw new Error(
`MCP tool '${this.serverToolName}' reported tool error with response: ${JSON.stringify(rawResponseParts)}`,
);
const errorMessage = `MCP tool '${
this.serverToolName
}' reported tool error for function call: ${safeJsonStringify(
functionCalls[0],
)} with response: ${safeJsonStringify(rawResponseParts)}`;
return {
llmContent: errorMessage,
returnDisplay: `Error: MCP tool '${this.serverToolName}' reported an error.`,
error: {
message: errorMessage,
type: ToolErrorType.MCP_TOOL_ERROR,
},
};
}
const transformedParts = transformMcpContentToParts(rawResponseParts);
@@ -152,7 +166,7 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
}
getDescription(): string {
return this.displayName;
return safeJsonStringify(this.params);
}
}

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
MemoryTool,
setGeminiMdFilename,
@@ -12,13 +13,26 @@ import {
getAllGeminiMdFilenames,
DEFAULT_CONTEXT_FILENAME,
} from './memoryTool.js';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
// Mock dependencies
vi.mock('fs/promises');
vi.mock(import('node:fs/promises'), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
mkdir: vi.fn(),
readFile: vi.fn(),
};
});
vi.mock('fs', () => ({
mkdirSync: vi.fn(),
}));
vi.mock('os');
const MEMORY_SECTION_HEADER = '## Qwen Added Memories';
@@ -292,6 +306,9 @@ describe('MemoryTool', () => {
expect(result.returnDisplay).toBe(
`Error saving memory: ${underlyingError.message}`,
);
expect(result.error?.type).toBe(
ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
);
});
it('should return error when executing without scope parameter', async () => {

View File

@@ -4,22 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolEditConfirmationDetails, ToolResult } from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolResult,
ToolEditConfirmationDetails,
ToolConfirmationOutcome,
} from './tools.js';
import { FunctionDeclaration } from '@google/genai';
import * as fs from 'fs/promises';
import * as path from 'path';
import { homedir } from 'os';
import type { FunctionDeclaration } from '@google/genai';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { tildeifyPath } from '../utils/paths.js';
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
import type {
ModifiableDeclarativeTool,
ModifyContext,
} from './modifiable-tool.js';
import { ToolErrorType } from './tool-error.js';
const memoryToolSchemaData: FunctionDeclaration = {
name: 'save_memory',
@@ -107,7 +110,7 @@ interface SaveMemoryParams {
}
function getGlobalMemoryFilePath(): string {
return path.join(homedir(), GEMINI_CONFIG_DIR, getCurrentGeminiMdFilename());
return path.join(Storage.getGlobalGeminiDir(), getCurrentGeminiMdFilename());
}
function getProjectMemoryFilePath(): string {
@@ -375,6 +378,10 @@ Project: ${projectPath} (current project only)`;
error: `Failed to save memory. Detail: ${errorMessage}`,
}),
returnDisplay: `Error saving memory: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.MEMORY_TOOL_EXECUTION_ERROR,
},
};
}
}

View File

@@ -5,17 +5,19 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
modifyWithEditor,
import type {
ModifyContext,
ModifiableDeclarativeTool,
} from './modifiable-tool.js';
import {
modifyWithEditor,
isModifiableDeclarativeTool,
} from './modifiable-tool.js';
import { EditorType } from '../utils/editor.js';
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import * as path from 'path';
import type { EditorType } from '../utils/editor.js';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import * as path from 'node:path';
// Mock dependencies
const mockOpenDiff = vi.hoisted(() => vi.fn());

View File

@@ -4,14 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { EditorType, openDiff } from '../utils/editor.js';
import os from 'os';
import path from 'path';
import fs from 'fs';
import type { EditorType } from '../utils/editor.js';
import { openDiff } from '../utils/editor.js';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import * as Diff from 'diff';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { isNodeError } from '../utils/errors.js';
import { AnyDeclarativeTool, DeclarativeTool, ToolResult } from './tools.js';
import type {
AnyDeclarativeTool,
DeclarativeTool,
ToolResult,
} from './tools.js';
/**
* A declarative tool that supports a modify operation.

View File

@@ -4,18 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ReadFileTool, ReadFileToolParams } from './read-file.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { ReadFileToolParams } from './read-file.js';
import { ReadFileTool } from './read-file.js';
import { ToolErrorType } from './tool-error.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
import fsp from 'fs/promises';
import { Config } from '../config/config.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import type { Config } from '../config/config.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { ToolInvocation, ToolResult } from './tools.js';
import type { ToolInvocation, ToolResult } from './tools.js';
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
describe('ReadFileTool', () => {
let tempRootDir: string;
@@ -219,7 +224,7 @@ describe('ReadFileTool', () => {
returnDisplay: 'Path is a directory.',
error: {
message: `Path is a directory, not a file: ${dirPath}`,
type: ToolErrorType.INVALID_TOOL_PARAMS,
type: ToolErrorType.TARGET_IS_DIRECTORY,
},
});
});

View File

@@ -4,27 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'path';
import path from 'node:path';
import { makeRelative, shortenPath } from '../utils/paths.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolInvocation,
ToolLocation,
ToolResult,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { PartUnion } from '@google/genai';
import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import type { PartUnion } from '@google/genai';
import {
processSingleFileContent,
getSpecificMimeType,
} from '../utils/fileUtils.js';
import { Config } from '../config/config.js';
import {
recordFileOperationMetric,
FileOperation,
} from '../telemetry/metrics.js';
import type { Config } from '../config/config.js';
import { FileOperation } from '../telemetry/metrics.js';
import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
/**
* Parameters for the ReadFile tool
@@ -79,44 +73,12 @@ class ReadFileToolInvocation extends BaseToolInvocation<
);
if (result.error) {
// Map error messages to ToolErrorType
let errorType: ToolErrorType;
let llmContent: string;
// Check error message patterns to determine error type
if (
result.error.includes('File not found') ||
result.error.includes('does not exist') ||
result.error.includes('ENOENT')
) {
errorType = ToolErrorType.FILE_NOT_FOUND;
llmContent =
'Could not read file because no file was found at the specified path.';
} else if (
result.error.includes('is a directory') ||
result.error.includes('EISDIR')
) {
errorType = ToolErrorType.INVALID_TOOL_PARAMS;
llmContent =
'Could not read file because the provided path is a directory, not a file.';
} else if (
result.error.includes('too large') ||
result.error.includes('File size exceeds')
) {
errorType = ToolErrorType.FILE_TOO_LARGE;
llmContent = `Could not read file. ${result.error}`;
} else {
// Other read errors map to READ_CONTENT_FAILURE
errorType = ToolErrorType.READ_CONTENT_FAILURE;
llmContent = `Could not read file. ${result.error}`;
}
return {
llmContent,
llmContent: result.llmContent,
returnDisplay: result.returnDisplay || 'Error reading file',
error: {
message: result.error,
type: errorType,
type: result.errorType,
},
};
}
@@ -144,12 +106,20 @@ ${result.llmContent}`;
? result.llmContent.split('\n').length
: undefined;
const mimetype = getSpecificMimeType(this.params.absolute_path);
recordFileOperationMetric(
const programming_language = getProgrammingLanguage({
absolute_path: this.params.absolute_path,
});
logFileOperation(
this.config,
FileOperation.READ,
lines,
mimetype,
path.extname(this.params.absolute_path),
new FileOperationEvent(
ReadFileTool.Name,
FileOperation.READ,
lines,
mimetype,
path.extname(this.params.absolute_path),
undefined,
programming_language,
),
);
return {

View File

@@ -9,12 +9,20 @@ import type { Mock } from 'vitest';
import { mockControl } from '../__mocks__/fs/promises.js';
import { ReadManyFilesTool } from './read-many-files.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import path from 'path';
import fs from 'fs'; // Actual fs for setup
import os from 'os';
import { Config } from '../config/config.js';
import path from 'node:path';
import fs from 'node:fs'; // Actual fs for setup
import os from 'node:os';
import type { Config } from '../config/config.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { ToolErrorType } from './tool-error.js';
import {
COMMON_IGNORE_PATTERNS,
DEFAULT_FILE_EXCLUDES,
} from '../utils/ignorePatterns.js';
import * as glob from 'glob';
vi.mock('glob', { spy: true });
vi.mock('mime-types', () => {
const lookup = (filename: string) => {
@@ -43,6 +51,10 @@ vi.mock('mime-types', () => {
};
});
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
describe('ReadManyFilesTool', () => {
let tool: ReadManyFilesTool;
let tempRootDir: string;
@@ -69,6 +81,13 @@ describe('ReadManyFilesTool', () => {
getTargetDir: () => tempRootDir,
getWorkspaceDirs: () => [tempRootDir],
getWorkspaceContext: () => new WorkspaceContext(tempRootDir),
getFileExclusions: () => ({
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
getDefaultExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
getGlobExcludes: () => COMMON_IGNORE_PATTERNS,
buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES,
}),
} as Partial<Config> as Config;
tool = new ReadManyFilesTool(mockConfig);
@@ -213,6 +232,7 @@ describe('ReadManyFilesTool', () => {
const expectedPath = path.join(tempRootDir, 'file1.txt');
expect(result.llmContent).toEqual([
`--- ${expectedPath} ---\n\nContent of file1\n\n`,
`\n--- End of content ---`,
]);
expect(result.returnDisplay).toContain(
'Successfully read and concatenated content from **1 file(s)**',
@@ -277,7 +297,10 @@ describe('ReadManyFilesTool', () => {
const result = await invocation.execute(new AbortController().signal);
const content = result.llmContent as string[];
const expectedPath = path.join(tempRootDir, 'src/main.ts');
expect(content).toEqual([`--- ${expectedPath} ---\n\nMain content\n\n`]);
expect(content).toEqual([
`--- ${expectedPath} ---\n\nMain content\n\n`,
`\n--- End of content ---`,
]);
expect(
content.find((c) => c.includes('src/main.test.ts')),
).toBeUndefined();
@@ -306,7 +329,10 @@ describe('ReadManyFilesTool', () => {
const result = await invocation.execute(new AbortController().signal);
const content = result.llmContent as string[];
const expectedPath = path.join(tempRootDir, 'src/app.js');
expect(content).toEqual([`--- ${expectedPath} ---\n\napp code\n\n`]);
expect(content).toEqual([
`--- ${expectedPath} ---\n\napp code\n\n`,
`\n--- End of content ---`,
]);
expect(
content.find((c) => c.includes('node_modules/some-lib/index.js')),
).toBeUndefined();
@@ -359,6 +385,7 @@ describe('ReadManyFilesTool', () => {
mimeType: 'image/png',
},
},
'\n--- End of content ---',
]);
expect(result.returnDisplay).toContain(
'Successfully read and concatenated content from **1 file(s)**',
@@ -382,6 +409,7 @@ describe('ReadManyFilesTool', () => {
mimeType: 'image/png',
},
},
'\n--- End of content ---',
]);
});
@@ -418,6 +446,7 @@ describe('ReadManyFilesTool', () => {
mimeType: 'application/pdf',
},
},
'\n--- End of content ---',
]);
});
@@ -433,6 +462,7 @@ describe('ReadManyFilesTool', () => {
mimeType: 'application/pdf',
},
},
'\n--- End of content ---',
]);
});
@@ -465,6 +495,13 @@ describe('ReadManyFilesTool', () => {
}),
getWorkspaceContext: () => new WorkspaceContext(tempDir1, [tempDir2]),
getTargetDir: () => tempDir1,
getFileExclusions: () => ({
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
getDefaultExcludePatterns: () => [],
getGlobExcludes: () => COMMON_IGNORE_PATTERNS,
buildExcludePatterns: () => [],
getReadManyFilesExcludes: () => [],
}),
} as Partial<Config> as Config;
tool = new ReadManyFilesTool(mockConfig);
@@ -541,6 +578,7 @@ describe('ReadManyFilesTool', () => {
Content of receive-detail
`,
`\n--- End of content ---`,
]);
expect(result.returnDisplay).toContain(
'Successfully read and concatenated content from **1 file(s)**',
@@ -559,6 +597,7 @@ Content of receive-detail
Content of file[1]
`,
`\n--- End of content ---`,
]);
expect(result.returnDisplay).toContain(
'Successfully read and concatenated content from **1 file(s)**',
@@ -566,6 +605,28 @@ Content of file[1]
});
});
describe('Error handling', () => {
it('should return an INVALID_TOOL_PARAMS error if no paths are provided', async () => {
const params = { paths: [], include: [] };
expect(() => {
tool.build(params);
}).toThrow('params/paths must NOT have fewer than 1 items');
});
it('should return a READ_MANY_FILES_SEARCH_ERROR on glob failure', async () => {
vi.mocked(glob.glob).mockRejectedValue(new Error('Glob failed'));
const params = { paths: ['*.txt'] };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
);
expect(result.llmContent).toBe('Error during file search: Glob failed');
// Reset glob.
vi.mocked(glob.glob).mockReset();
});
});
describe('Batch Processing', () => {
const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
const files: string[] = [];
@@ -604,9 +665,10 @@ Content of file[1]
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
// Verify all files were processed
// Verify all files were processed. The content should have fileCount
// entries + 1 for the output terminator.
const content = result.llmContent as string[];
expect(content).toHaveLength(fileCount);
expect(content).toHaveLength(fileCount + 1);
for (let i = 0; i < fileCount; i++) {
expect(content.join('')).toContain(`Batch test ${i}`);
}

View File

@@ -4,30 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob, escape } from 'glob';
import { getCurrentGeminiMdFilename } from './memoryTool.js';
import type { ProcessedFileReadResult } from '../utils/fileUtils.js';
import {
detectFileType,
processSingleFileContent,
DEFAULT_ENCODING,
getSpecificMimeType,
} from '../utils/fileUtils.js';
import { PartListUnion } from '@google/genai';
import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import {
recordFileOperationMetric,
FileOperation,
} from '../telemetry/metrics.js';
import type { PartListUnion } from '@google/genai';
import type { Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/config.js';
import { FileOperation } from '../telemetry/metrics.js';
import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
import { ToolErrorType } from './tool-error.js';
/**
* Parameters for the ReadManyFilesTool.
@@ -84,9 +81,7 @@ type FileProcessingResult =
success: true;
filePath: string;
relativePathForDisplay: string;
fileReadResult: NonNullable<
Awaited<ReturnType<typeof processSingleFileContent>>
>;
fileReadResult: ProcessedFileReadResult;
reason?: undefined;
}
| {
@@ -98,51 +93,16 @@ type FileProcessingResult =
};
/**
* Default exclusion patterns for commonly ignored directories and binary file types.
* These are compatible with glob ignore patterns.
* Creates the default exclusion patterns including dynamic patterns.
* This combines the shared patterns with dynamic patterns like GEMINI.md.
* TODO(adh): Consider making this configurable or extendable through a command line argument.
* TODO(adh): Look into sharing this list with the glob tool.
*/
const DEFAULT_EXCLUDES: string[] = [
'**/node_modules/**',
'**/.git/**',
'**/.vscode/**',
'**/.idea/**',
'**/dist/**',
'**/build/**',
'**/coverage/**',
'**/__pycache__/**',
'**/*.pyc',
'**/*.pyo',
'**/*.bin',
'**/*.exe',
'**/*.dll',
'**/*.so',
'**/*.dylib',
'**/*.class',
'**/*.jar',
'**/*.war',
'**/*.zip',
'**/*.tar',
'**/*.gz',
'**/*.bz2',
'**/*.rar',
'**/*.7z',
'**/*.doc',
'**/*.docx',
'**/*.xls',
'**/*.xlsx',
'**/*.ppt',
'**/*.pptx',
'**/*.odt',
'**/*.ods',
'**/*.odp',
'**/*.DS_Store',
'**/.env',
`**/${getCurrentGeminiMdFilename()}`,
];
function getDefaultExcludes(config?: Config): string[] {
return config?.getFileExclusions().getReadManyFilesExcludes() ?? [];
}
const DEFAULT_OUTPUT_SEPARATOR_FORMAT = '--- {filePath} ---';
const DEFAULT_OUTPUT_TERMINATOR = '\n--- End of content ---';
class ReadManyFilesToolInvocation extends BaseToolInvocation<
ReadManyFilesParams,
@@ -171,7 +131,11 @@ ${this.config.getTargetDir()}
.getGeminiIgnorePatterns();
const finalExclusionPatternsForDescription: string[] =
paramUseDefaultExcludes
? [...DEFAULT_EXCLUDES, ...paramExcludes, ...geminiIgnorePatterns]
? [
...getDefaultExcludes(this.config),
...paramExcludes,
...geminiIgnorePatterns,
]
: [...paramExcludes, ...geminiIgnorePatterns];
let excludeDesc = `Excluding: ${
@@ -229,17 +193,10 @@ ${finalExclusionPatternsForDescription
const contentParts: PartListUnion = [];
const effectiveExcludes = useDefaultExcludes
? [...DEFAULT_EXCLUDES, ...exclude]
? [...getDefaultExcludes(this.config), ...exclude]
: [...exclude];
const searchPatterns = [...inputPatterns, ...include];
if (searchPatterns.length === 0) {
return {
llmContent: 'No search paths or include patterns provided.',
returnDisplay: `## Information\n\nNo search paths or include patterns were specified. Nothing to read or concatenate.`,
};
}
try {
const allEntries = new Set<string>();
const workspaceDirs = this.config.getWorkspaceContext().getDirectories();
@@ -353,9 +310,14 @@ ${finalExclusionPatternsForDescription
});
}
} catch (error) {
const errorMessage = `Error during file search: ${getErrorMessage(error)}`;
return {
llmContent: `Error during file search: ${getErrorMessage(error)}`,
llmContent: errorMessage,
returnDisplay: `## File Search Error\n\nAn error occurred while searching for files:\n\`\`\`\n${getErrorMessage(error)}\n\`\`\``,
error: {
message: errorMessage,
type: ToolErrorType.READ_MANY_FILES_SEARCH_ERROR,
},
};
}
@@ -470,12 +432,20 @@ ${finalExclusionPatternsForDescription
? fileReadResult.llmContent.split('\n').length
: undefined;
const mimetype = getSpecificMimeType(filePath);
recordFileOperationMetric(
const programming_language = getProgrammingLanguage({
absolute_path: filePath,
});
logFileOperation(
this.config,
FileOperation.READ,
lines,
mimetype,
path.extname(filePath),
new FileOperationEvent(
ReadManyFilesTool.Name,
FileOperation.READ,
lines,
mimetype,
path.extname(filePath),
undefined,
programming_language,
),
);
}
} else {
@@ -528,7 +498,9 @@ ${finalExclusionPatternsForDescription
displayMessage += `No files were read and concatenated based on the criteria.\n`;
}
if (contentParts.length === 0) {
if (contentParts.length > 0) {
contentParts.push(DEFAULT_OUTPUT_TERMINATOR);
} else {
contentParts.push(
'No files matching the criteria were found or all were skipped.',
);
@@ -630,7 +602,7 @@ This tool is useful when you need to understand or analyze a collection of files
- Gathering context from multiple configuration files.
- When the user asks to "read all files in X directory" or "show me the content of all Y files".
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`,
Kind.Read,
parameterSchema,
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,497 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import { rgPath } from '@lvce-editor/ripgrep';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js';
const DEFAULT_TOTAL_MAX_MATCHES = 20000;
/**
* Parameters for the GrepTool
*/
export interface RipGrepToolParams {
/**
* The regular expression pattern to search for in file contents
*/
pattern: string;
/**
* The directory to search in (optional, defaults to current directory relative to root)
*/
path?: string;
/**
* File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
*/
include?: string;
}
/**
* Result object for a single grep match
*/
interface GrepMatch {
filePath: string;
lineNumber: number;
line: string;
}
class GrepToolInvocation extends BaseToolInvocation<
RipGrepToolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: RipGrepToolParams,
) {
super(params);
}
/**
* Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root).
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/
private resolveAndValidatePath(relativePath?: string): string | null {
// If no path specified, return null to indicate searching all workspace directories
if (!relativePath) {
return null;
}
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
// Security Check: Ensure the resolved path is within workspace boundaries
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories();
throw new Error(
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
);
}
// Check existence and type after resolving
try {
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code !== 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`);
}
throw new Error(
`Failed to access path stats for ${targetPath}: ${error}`,
);
}
return targetPath;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
try {
const workspaceContext = this.config.getWorkspaceContext();
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
const searchDirDisplay = this.params.path || '.';
// Determine which directories to search
let searchDirectories: readonly string[];
if (searchDirAbs === null) {
// No path specified - search all workspace directories
searchDirectories = workspaceContext.getDirectories();
} else {
// Specific path provided - search only that directory
searchDirectories = [searchDirAbs];
}
let allMatches: GrepMatch[] = [];
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES;
if (this.config.getDebugMode()) {
console.log(`[GrepTool] Total result limit: ${totalMaxMatches}`);
}
for (const searchDir of searchDirectories) {
const searchResult = await this.performRipgrepSearch({
pattern: this.params.pattern,
path: searchDir,
include: this.params.include,
signal,
});
if (searchDirectories.length > 1) {
const dirName = path.basename(searchDir);
searchResult.forEach((match) => {
match.filePath = path.join(dirName, match.filePath);
});
}
allMatches = allMatches.concat(searchResult);
if (allMatches.length >= totalMaxMatches) {
allMatches = allMatches.slice(0, totalMaxMatches);
break;
}
}
let searchLocationDescription: string;
if (searchDirAbs === null) {
const numDirs = workspaceContext.getDirectories().length;
searchLocationDescription =
numDirs > 1
? `across ${numDirs} workspace directories`
: `in the workspace directory`;
} else {
searchLocationDescription = `in path "${searchDirDisplay}"`;
}
if (allMatches.length === 0) {
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`;
return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
}
const wasTruncated = allMatches.length >= totalMaxMatches;
const matchesByFile = allMatches.reduce(
(acc, match) => {
const fileKey = match.filePath;
if (!acc[fileKey]) {
acc[fileKey] = [];
}
acc[fileKey].push(match);
acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber);
return acc;
},
{} as Record<string, GrepMatch[]>,
);
const matchCount = allMatches.length;
const matchTerm = matchCount === 1 ? 'match' : 'matches';
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;
if (wasTruncated) {
llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`;
}
llmContent += `:\n---\n`;
for (const filePath in matchesByFile) {
llmContent += `File: ${filePath}\n`;
matchesByFile[filePath].forEach((match) => {
const trimmedLine = match.line.trim();
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
});
llmContent += '---\n';
}
let displayMessage = `Found ${matchCount} ${matchTerm}`;
if (wasTruncated) {
displayMessage += ` (limited)`;
}
return {
llmContent: llmContent.trim(),
returnDisplay: displayMessage,
};
} catch (error) {
console.error(`Error during GrepLogic execution: ${error}`);
const errorMessage = getErrorMessage(error);
return {
llmContent: `Error during grep search operation: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
}
private parseRipgrepOutput(output: string, basePath: string): GrepMatch[] {
const results: GrepMatch[] = [];
if (!output) return results;
const lines = output.split(EOL);
for (const line of lines) {
if (!line.trim()) continue;
const firstColonIndex = line.indexOf(':');
if (firstColonIndex === -1) continue;
const secondColonIndex = line.indexOf(':', firstColonIndex + 1);
if (secondColonIndex === -1) continue;
const filePathRaw = line.substring(0, firstColonIndex);
const lineNumberStr = line.substring(
firstColonIndex + 1,
secondColonIndex,
);
const lineContent = line.substring(secondColonIndex + 1);
const lineNumber = parseInt(lineNumberStr, 10);
if (!isNaN(lineNumber)) {
const absoluteFilePath = path.resolve(basePath, filePathRaw);
const relativeFilePath = path.relative(basePath, absoluteFilePath);
results.push({
filePath: relativeFilePath || path.basename(absoluteFilePath),
lineNumber,
line: lineContent,
});
}
}
return results;
}
private async performRipgrepSearch(options: {
pattern: string;
path: string;
include?: string;
signal: AbortSignal;
}): Promise<GrepMatch[]> {
const { pattern, path: absolutePath, include } = options;
const rgArgs = [
'--line-number',
'--no-heading',
'--with-filename',
'--ignore-case',
'--regexp',
pattern,
];
if (include) {
rgArgs.push('--glob', include);
}
const excludes = [
'.git',
'node_modules',
'bower_components',
'*.log',
'*.tmp',
'build',
'dist',
'coverage',
];
excludes.forEach((exclude) => {
rgArgs.push('--glob', `!${exclude}`);
});
rgArgs.push('--threads', '4');
rgArgs.push(absolutePath);
try {
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, rgArgs, {
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const cleanup = () => {
if (options.signal.aborted) {
child.kill();
}
};
options.signal.addEventListener('abort', cleanup, { once: true });
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
child.on('error', (err) => {
options.signal.removeEventListener('abort', cleanup);
reject(
new Error(
`Failed to start ripgrep: ${err.message}. Please ensure @lvce-editor/ripgrep is properly installed.`,
),
);
});
child.on('close', (code) => {
options.signal.removeEventListener('abort', cleanup);
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
const stderrData = Buffer.concat(stderrChunks).toString('utf8');
if (code === 0) {
resolve(stdoutData);
} else if (code === 1) {
resolve(''); // No matches found
} else {
reject(
new Error(`ripgrep exited with code ${code}: ${stderrData}`),
);
}
});
});
return this.parseRipgrepOutput(output, absolutePath);
} catch (error: unknown) {
console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Gets a description of the grep operation
* @param params Parameters for the grep operation
* @returns A string describing the grep
*/
getDescription(): string {
let description = `'${this.params.pattern}'`;
if (this.params.include) {
description += ` in ${this.params.include}`;
}
if (this.params.path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.path,
);
if (
resolvedPath === this.config.getTargetDir() ||
this.params.path === '.'
) {
description += ` within ./`;
} else {
const relativePath = makeRelative(
resolvedPath,
this.config.getTargetDir(),
);
description += ` within ${shortenPath(relativePath)}`;
}
} else {
// When no path is specified, indicate searching all workspace directories
const workspaceContext = this.config.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
if (directories.length > 1) {
description += ` across all workspace directories`;
}
}
return description;
}
}
/**
* Implementation of the Grep tool logic (moved from CLI)
*/
export class RipGrepTool extends BaseDeclarativeTool<
RipGrepToolParams,
ToolResult
> {
static readonly Name = 'search_file_content';
constructor(private readonly config: Config) {
super(
RipGrepTool.Name,
'SearchText',
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers. Total results limited to 20,000 matches like VSCode.',
Kind.Search,
{
properties: {
pattern: {
description:
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
type: 'string',
},
path: {
description:
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
type: 'string',
},
include: {
description:
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
type: 'string',
},
},
required: ['pattern'],
type: 'object',
},
);
}
/**
* Checks if a path is within the root directory and resolves it.
* @param relativePath Path relative to the root directory (or undefined for root).
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
*/
private resolveAndValidatePath(relativePath?: string): string | null {
// If no path specified, return null to indicate searching all workspace directories
if (!relativePath) {
return null;
}
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
// Security Check: Ensure the resolved path is within workspace boundaries
const workspaceContext = this.config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
const directories = workspaceContext.getDirectories();
throw new Error(
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
);
}
// Check existence and type after resolving
try {
const stats = fs.statSync(targetPath);
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${targetPath}`);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code !== 'ENOENT') {
throw new Error(`Path does not exist: ${targetPath}`);
}
throw new Error(
`Failed to access path stats for ${targetPath}: ${error}`,
);
}
return targetPath;
}
/**
* Validates the parameters for the tool
* @param params Parameters to validate
* @returns An error message string if invalid, null otherwise
*/
override validateToolParams(params: RipGrepToolParams): string | null {
const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
);
if (errors) {
return errors;
}
// Only validate path if one is provided
if (params.path) {
try {
this.resolveAndValidatePath(params.path);
} catch (error) {
return getErrorMessage(error);
}
}
return null; // Parameters are valid
}
protected createInvocation(
params: RipGrepToolParams,
): ToolInvocation<RipGrepToolParams, ToolResult> {
return new GrepToolInvocation(this.config, params);
}
}

View File

@@ -30,11 +30,13 @@ import {
type ShellExecutionResult,
type ShellOutputEvent,
} from '../services/shellExecutionService.js';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { EOL } from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import * as summarizer from '../utils/summarizer.js';
import { ToolErrorType } from './tool-error.js';
import { ToolConfirmationOutcome } from './tools.js';
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
@@ -172,7 +174,7 @@ describe('ShellTool', () => {
resolveShellExecution({ pid: 54321 });
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); // Service PID and background PID
vi.mocked(fs.readFileSync).mockReturnValue(`54321${EOL}54322${EOL}`); // Service PID and background PID
const result = await promise;
@@ -321,6 +323,25 @@ describe('ShellTool', () => {
expect(result.llmContent).not.toContain('pgrep');
});
it('should return a SHELL_EXECUTE_ERROR for a command failure', async () => {
const error = new Error('command failed');
const invocation = shellTool.build({
command: 'user-command',
is_background: false,
});
const promise = invocation.execute(mockAbortSignal);
resolveShellExecution({
error,
exitCode: 1,
});
const result = await promise;
expect(result.error).toBeDefined();
expect(result.error?.type).toBe(ToolErrorType.SHELL_EXECUTE_ERROR);
expect(result.error?.message).toBe('command failed');
});
it('should throw an error for invalid parameters', () => {
expect(() =>
shellTool.build({ command: '', is_background: false }),

View File

@@ -4,27 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { Config } from '../config/config.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
import fs from 'node:fs';
import path from 'node:path';
import os, { EOL } from 'node:os';
import crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { ToolErrorType } from './tool-error.js';
import type {
ToolInvocation,
ToolResult,
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
} from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
ToolConfirmationOutcome,
Kind,
} from './tools.js';
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import {
ShellExecutionService,
ShellOutputEvent,
} from '../services/shellExecutionService.js';
import type { ShellOutputEvent } from '../services/shellExecutionService.js';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import {
getCommandRoots,
@@ -208,7 +209,7 @@ class ShellToolInvocation extends BaseToolInvocation<
if (fs.existsSync(tempFilePath)) {
const pgrepLines = fs
.readFileSync(tempFilePath, 'utf8')
.split('\n')
.split(EOL)
.filter(Boolean);
for (const line of pgrepLines) {
if (!/^\d+$/.test(line)) {
@@ -279,6 +280,14 @@ class ShellToolInvocation extends BaseToolInvocation<
}
const summarizeConfig = this.config.getSummarizeToolOutputConfig();
const executionError = result.error
? {
error: {
message: result.error.message,
type: ToolErrorType.SHELL_EXECUTE_ERROR,
},
}
: {};
if (summarizeConfig && summarizeConfig[ShellTool.Name]) {
const summary = await summarizeToolOutput(
llmContent,
@@ -289,12 +298,14 @@ class ShellToolInvocation extends BaseToolInvocation<
return {
llmContent: summary,
returnDisplay: returnDisplayMessage,
...executionError,
};
}
return {
llmContent,
returnDisplay: returnDisplayMessage,
...executionError,
};
} finally {
if (fs.existsSync(tempFilePath)) {
@@ -341,9 +352,7 @@ Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
}
function getShellToolDescription(): string {
const platform = os.platform();
const toolDescription = `
${platform === 'win32' ? 'This tool executes a given shell command as `cmd.exe /c <command>`.' : 'This tool executes a given shell command as `bash -c <command>`. '}
**Background vs Foreground Execution:**
You should decide whether commands should run in background or foreground based on their nature:
@@ -362,8 +371,6 @@ function getShellToolDescription(): string {
- Git operations: \`git commit\`, \`git push\`
- Test runs: \`npm test\`, \`pytest\`
${platform === 'win32' ? '' : 'Command is executed as a subprocess that leads its own process group. Command process group can be terminated as `kill -- -PGID` or signaled as `kill -s SIGNAL -- -PGID`.'}
The following information is returned:
Command: Executed command.
@@ -376,7 +383,11 @@ function getShellToolDescription(): string {
Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\``;
return toolDescription;
if (os.platform() === 'win32') {
return `This tool executes a given shell command as \`cmd.exe /c <command>\`. Command can start background processes using \`start /b\`.${toolDescription}`;
} else {
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${toolDescription}`;
}
}
function getCommandDescription(): string {
@@ -407,11 +418,6 @@ export class ShellTool extends BaseDeclarativeTool<
type: 'string',
description: getCommandDescription(),
},
is_background: {
type: 'boolean',
description:
'Whether to run the command in background. Default is false. Set to true for long-running processes like development servers, watchers, or daemons that should continue running without blocking further commands.',
},
description: {
type: 'string',
description:

View File

@@ -5,10 +5,11 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TodoWriteTool, TodoWriteParams, TodoItem } from './todoWrite.js';
import type { TodoWriteParams, TodoItem } from './todoWrite.js';
import { TodoWriteTool } from './todoWrite.js';
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import { Config } from '../config/config.js';
import type { Config } from '../config/config.js';
// Mock fs modules
vi.mock('fs/promises');

View File

@@ -4,20 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolResult,
} from './tools.js';
import { FunctionDeclaration } from '@google/genai';
import type { ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import type { FunctionDeclaration } from '@google/genai';
import * as fs from 'fs/promises';
import * as fsSync from 'fs';
import * as path from 'path';
import * as process from 'process';
import { QWEN_DIR } from '../utils/paths.js';
import { Config } from '../config/config.js';
import type { Config } from '../config/config.js';
export interface TodoItem {
id: string;

View File

@@ -24,10 +24,46 @@ export enum ToolErrorType {
PERMISSION_DENIED = 'permission_denied',
NO_SPACE_LEFT = 'no_space_left',
TARGET_IS_DIRECTORY = 'target_is_directory',
PATH_NOT_IN_WORKSPACE = 'path_not_in_workspace',
SEARCH_PATH_NOT_FOUND = 'search_path_not_found',
SEARCH_PATH_NOT_A_DIRECTORY = 'search_path_not_a_directory',
// Edit-specific Errors
EDIT_PREPARATION_FAILURE = 'edit_preparation_failure',
EDIT_NO_OCCURRENCE_FOUND = 'edit_no_occurrence_found',
EDIT_EXPECTED_OCCURRENCE_MISMATCH = 'edit_expected_occurrence_mismatch',
EDIT_NO_CHANGE = 'edit_no_change',
// Glob-specific Errors
GLOB_EXECUTION_ERROR = 'glob_execution_error',
// Grep-specific Errors
GREP_EXECUTION_ERROR = 'grep_execution_error',
// Ls-specific Errors
LS_EXECUTION_ERROR = 'ls_execution_error',
PATH_IS_NOT_A_DIRECTORY = 'path_is_not_a_directory',
// MCP-specific Errors
MCP_TOOL_ERROR = 'mcp_tool_error',
// Memory-specific Errors
MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error',
// ReadManyFiles-specific Errors
READ_MANY_FILES_SEARCH_ERROR = 'read_many_files_search_error',
// Shell errors
SHELL_EXECUTE_ERROR = 'shell_execute_error',
// DiscoveredTool-specific Errors
DISCOVERED_TOOL_EXECUTION_ERROR = 'discovered_tool_execution_error',
// WebFetch-specific Errors
WEB_FETCH_NO_URL_IN_PROMPT = 'web_fetch_no_url_in_prompt',
WEB_FETCH_FALLBACK_FAILED = 'web_fetch_fallback_failed',
WEB_FETCH_PROCESSING_ERROR = 'web_fetch_processing_error',
// WebSearch-specific Errors
WEB_SEARCH_FAILED = 'web_search_failed',
}

View File

@@ -5,24 +5,20 @@
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
Mocked,
} from 'vitest';
import { Config, ConfigParameters, ApprovalMode } from '../config/config.js';
import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { ConfigParameters } from '../config/config.js';
import { Config, ApprovalMode } from '../config/config.js';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { FunctionDeclaration, CallableTool, mcpToTool } from '@google/genai';
import type { FunctionDeclaration, CallableTool } from '@google/genai';
import { mcpToTool } from '@google/genai';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import { MockTool } from '../test-utils/tools.js';
import { McpClientManager } from './mcp-client-manager.js';
import { ToolErrorType } from './tool-error.js';
vi.mock('node:fs');
@@ -177,6 +173,24 @@ describe('ToolRegistry', () => {
});
});
describe('getAllToolNames', () => {
it('should return all registered tool names', () => {
// Register tools with displayNames in non-alphabetical order
const toolC = new MockTool('c-tool', 'Tool C');
const toolA = new MockTool('a-tool', 'Tool A');
const toolB = new MockTool('b-tool', 'Tool B');
toolRegistry.registerTool(toolC);
toolRegistry.registerTool(toolA);
toolRegistry.registerTool(toolB);
const toolNames = toolRegistry.getAllToolNames();
// Assert that the returned array contains all tool names
expect(toolNames).toEqual(['c-tool', 'a-tool', 'b-tool']);
});
});
describe('getToolsByServer', () => {
it('should return an empty array if no tools match the server name', () => {
toolRegistry.registerTool(new MockTool());
@@ -310,6 +324,81 @@ describe('ToolRegistry', () => {
});
});
it('should return a DISCOVERED_TOOL_EXECUTION_ERROR on tool failure', async () => {
const discoveryCommand = 'my-discovery-command';
mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand);
vi.spyOn(config, 'getToolCallCommand').mockReturnValue('my-call-command');
const toolDeclaration: FunctionDeclaration = {
name: 'failing-tool',
description: 'A tool that fails',
parametersJsonSchema: {
type: 'object',
properties: {},
},
};
const mockSpawn = vi.mocked(spawn);
// --- Discovery Mock ---
const discoveryProcess = {
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();
const discoveredTool = toolRegistry.getTool('failing-tool');
expect(discoveredTool).toBeDefined();
// --- Execution Mock ---
const executionProcess = {
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 result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(
ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
);
expect(result.llmContent).toContain('Stderr: Something went wrong');
expect(result.llmContent).toContain('Exit Code: 1');
});
it('should discover tools using MCP servers defined in getMcpServers', async () => {
const discoverSpy = vi.spyOn(
McpClientManager.prototype,
@@ -331,4 +420,14 @@ describe('ToolRegistry', () => {
expect(discoverSpy).toHaveBeenCalled();
});
});
describe('DiscoveredToolInvocation', () => {
it('should return the stringified params from getDescription', () => {
const tool = new DiscoveredTool(config, 'test-tool', 'A test tool', {});
const params = { param: 'testValue' };
const invocation = tool.build(params);
const description = invocation.getDescription();
expect(description).toBe(JSON.stringify(params));
});
});
});

View File

@@ -4,22 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { FunctionDeclaration } from '@google/genai';
import {
import type { FunctionDeclaration } from '@google/genai';
import type {
AnyDeclarativeTool,
Kind,
ToolResult,
BaseDeclarativeTool,
BaseToolInvocation,
ToolInvocation,
} from './tools.js';
import { Config } from '../config/config.js';
import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js';
import type { Config } from '../config/config.js';
import { spawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import { connectAndDiscover } from './mcp-client.js';
import { McpClientManager } from './mcp-client-manager.js';
import { DiscoveredMCPTool } from './mcp-tool.js';
import { parse } from 'shell-quote';
import { ToolErrorType } from './tool-error.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
type ToolParams = Record<string, unknown>;
@@ -36,7 +36,7 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
}
getDescription(): string {
return `Calling discovered tool: ${this.toolName}`;
return safeJsonStringify(this.params);
}
async execute(
@@ -105,6 +105,10 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
return {
llmContent,
returnDisplay: llmContent,
error: {
message: llmContent,
type: ToolErrorType.DISCOVERED_TOOL_EXECUTION_ERROR,
},
};
}
@@ -433,6 +437,13 @@ export class ToolRegistry {
return declarations;
}
/**
* Returns an array of all registered and discovered tool names.
*/
getAllToolNames(): string[] {
return Array.from(this.tools.keys());
}
/**
* Returns an array of all registered and discovered tool instances.
*/

View File

@@ -5,13 +5,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import {
DeclarativeTool,
hasCycleInSchema,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
import type { ToolInvocation, ToolResult } from './tools.js';
import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
class TestToolInvocation implements ToolInvocation<object, ToolResult> {
@@ -101,7 +96,6 @@ describe('DeclarativeTool', () => {
const successResult: ToolResult = {
llmContent: 'Success!',
returnDisplay: 'Success!',
summary: 'Tool executed successfully',
};
const executeFn = vi.fn().mockResolvedValue(successResult);
const invocation = new TestToolInvocation({}, executeFn);

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { FunctionDeclaration, PartListUnion } from '@google/genai';
import type { FunctionDeclaration, PartListUnion } from '@google/genai';
import { ToolErrorType } from './tool-error.js';
import { DiffUpdateResult } from '../ide/ideContext.js';
import type { DiffUpdateResult } from '../ide/ideContext.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
/**
@@ -24,6 +24,7 @@ export interface ToolInvocation<
/**
* Gets a pre-execution description of the tool operation.
*
* @returns A markdown string describing what the tool will do.
*/
getDescription(): string;
@@ -306,12 +307,22 @@ export abstract class BaseDeclarativeTool<
*/
export type AnyDeclarativeTool = DeclarativeTool<object, ToolResult>;
/**
* Type guard to check if an object is a Tool.
* @param obj The object to check.
* @returns True if the object is a Tool, false otherwise.
*/
export function isTool(obj: unknown): obj is AnyDeclarativeTool {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'build' in obj &&
typeof (obj as AnyDeclarativeTool).build === 'function'
);
}
export interface ToolResult {
/**
* A short, one-line summary of the tool's action and result.
* e.g., "Read 5 files", "Wrote 256 bytes to foo.txt"
*/
summary?: string;
/**
* Content meant to be included in LLM history.
* This should represent the factual outcome of the tool execution.

View File

@@ -4,17 +4,77 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WebFetchTool } from './web-fetch.js';
import { Config, ApprovalMode } from '../config/config.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import * as fetchUtils from '../utils/fetch.js';
const mockGenerateContent = vi.fn();
const mockGetGeminiClient = vi.fn(() => ({
generateContent: mockGenerateContent,
}));
vi.mock('../utils/fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof fetchUtils>();
return {
...actual,
fetchWithTimeout: vi.fn(),
isPrivateIp: vi.fn(),
};
});
describe('WebFetchTool', () => {
const mockConfig = {
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getProxy: vi.fn(),
} as unknown as Config;
let mockConfig: Config;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {
getApprovalMode: vi.fn(),
setApprovalMode: vi.fn(),
getProxy: vi.fn(),
getGeminiClient: mockGetGeminiClient,
} as unknown as Config;
});
describe('execute', () => {
it('should throw validation error when url parameter is missing', async () => {
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'no url here' };
/* @ts-expect-error - we are testing validation */
expect(() => tool.build(params)).toThrow(
"params must have required property 'url'",
);
});
it('should return WEB_FETCH_FALLBACK_FAILED on fetch failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
new Error('fetch failed'),
);
const tool = new WebFetchTool(mockConfig);
const params = { url: 'https://private.ip', prompt: 'summarize this' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
});
it('should return WEB_FETCH_FALLBACK_FAILED on API processing failure', async () => {
vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({
ok: true,
text: () => Promise.resolve('<html><body>Test content</body></html>'),
} as Response);
mockGenerateContent.mockRejectedValue(new Error('API error'));
const tool = new WebFetchTool(mockConfig);
const params = { url: 'https://public.ip', prompt: 'summarize this' };
const invocation = tool.build(params);
const result = await invocation.execute(new AbortController().signal);
expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);
});
});
describe('shouldConfirmExecute', () => {
it('should return confirmation details with the correct prompt and urls', async () => {
@@ -78,10 +138,11 @@ describe('WebFetchTool', () => {
it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => {
const setApprovalMode = vi.fn();
const tool = new WebFetchTool({
const testConfig = {
...mockConfig,
setApprovalMode,
} as unknown as Config);
} as unknown as Config;
const tool = new WebFetchTool(testConfig);
const params = {
url: 'https://example.com',
prompt: 'summarize this page',

View File

@@ -4,22 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { getResponseText } from '../utils/partUtils.js';
import { ToolErrorType } from './tool-error.js';
import type {
ToolCallConfirmationDetails,
ToolInvocation,
ToolResult,
} from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolInvocation,
ToolResult,
} from './tools.js';
import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
@@ -123,6 +126,10 @@ ${textContent}
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
},
};
}
}

View File

@@ -5,8 +5,8 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WebSearchTool, WebSearchToolParams } from './web-search.js';
import { Config } from '../config/config.js';
import { WebSearchTool, type WebSearchToolParams } from './web-search.js';
import type { Config } from '../config/config.js';
import { GeminiClient } from '../core/client.js';
// Mock GeminiClient and Config constructor

View File

@@ -8,12 +8,12 @@ import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolInvocation,
ToolResult,
type ToolInvocation,
type ToolResult,
} from './tools.js';
import type { Config } from '../config/config.js';
import { getErrorMessage } from '../utils/errors.js';
import { Config } from '../config/config.js';
interface TavilyResultItem {
title: string;

View File

@@ -13,28 +13,23 @@ import {
vi,
type Mocked,
} from 'vitest';
import {
getCorrectedFileContent,
WriteFileTool,
WriteFileToolParams,
} from './write-file.js';
import type { WriteFileToolParams } from './write-file.js';
import { getCorrectedFileContent, WriteFileTool } from './write-file.js';
import { ToolErrorType } from './tool-error.js';
import {
FileDiff,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
} from './tools.js';
import type { FileDiff, ToolEditConfirmationDetails } from './tools.js';
import { ToolConfirmationOutcome } from './tools.js';
import { type EditToolParams } from './edit.js';
import { ApprovalMode, Config } from '../config/config.js';
import { ToolRegistry } from './tool-registry.js';
import path from 'path';
import fs from 'fs';
import os from 'os';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import type { ToolRegistry } from './tool-registry.js';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { GeminiClient } from '../core/client.js';
import type { CorrectedEditResult } from '../utils/editCorrector.js';
import {
ensureCorrectEdit,
ensureCorrectFileContent,
CorrectedEditResult,
} from '../utils/editCorrector.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
@@ -88,6 +83,11 @@ const mockConfigInternal = {
}) as unknown as ToolRegistry,
};
const mockConfig = mockConfigInternal as unknown as Config;
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
}));
// --- END MOCKS ---
describe('WriteFileTool', () => {

View File

@@ -4,22 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import path from 'path';
import fs from 'node:fs';
import path from 'node:path';
import * as Diff from 'diff';
import { Config, ApprovalMode } from '../config/config.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import type {
FileDiff,
Kind,
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
ToolInvocation,
ToolLocation,
ToolResult,
} from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
@@ -28,13 +31,16 @@ import {
ensureCorrectFileContent,
} from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
import type {
ModifiableDeclarativeTool,
ModifyContext,
} from './modifiable-tool.js';
import { getSpecificMimeType } from '../utils/fileUtils.js';
import {
recordFileOperationMetric,
FileOperation,
} from '../telemetry/metrics.js';
import { FileOperation } from '../telemetry/metrics.js';
import { IDEConnectionStatus } from '../ide/ide-client.js';
import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js';
/**
* Parameters for the WriteFile tool
@@ -314,23 +320,32 @@ class WriteFileToolInvocation extends BaseToolInvocation<
const lines = fileContent.split('\n').length;
const mimetype = getSpecificMimeType(file_path);
const extension = path.extname(file_path); // Get extension
const programming_language = getProgrammingLanguage({ file_path });
if (isNewFile) {
recordFileOperationMetric(
logFileOperation(
this.config,
FileOperation.CREATE,
lines,
mimetype,
extension,
diffStat,
new FileOperationEvent(
WriteFileTool.Name,
FileOperation.CREATE,
lines,
mimetype,
extension,
diffStat,
programming_language,
),
);
} else {
recordFileOperationMetric(
logFileOperation(
this.config,
FileOperation.UPDATE,
lines,
mimetype,
extension,
diffStat,
new FileOperationEvent(
WriteFileTool.Name,
FileOperation.UPDATE,
lines,
mimetype,
extension,
diffStat,
programming_language,
),
);
}