mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
1226
packages/core/src/tools/ripGrep.test.ts
Normal file
1226
packages/core/src/tools/ripGrep.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
497
packages/core/src/tools/ripGrep.ts
Normal file
497
packages/core/src/tools/ripGrep.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user