mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
feat(core): Download ripgrep at runtime, if enabled. (#7818)
This commit is contained in:
132
package-lock.json
generated
132
package-lock.json
generated
@@ -11,7 +11,6 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1684,6 +1683,28 @@
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.2.tgz",
|
||||
"integrity": "sha512-cSHA+H+HEkOXeiCxrNvGj/pgv2Y0bfp4GbH3R87zr7Vob2pDUZV3BkUL9ucHMoDFID4GteSy5z5niN/lF9QeuQ==",
|
||||
"dependencies": {
|
||||
"@lvce-editor/verror": "^1.6.0",
|
||||
"execa": "^9.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"got": "^14.4.5",
|
||||
"path-exists": "^5.0.0",
|
||||
"xdg-basedir": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
@@ -1819,32 +1840,6 @@
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lvce-editor/ripgrep": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@lvce-editor/ripgrep/-/ripgrep-1.6.0.tgz",
|
||||
"integrity": "sha512-880taWBVULNXmcPHXdxnFUI0FvLErBOjY9OigMXEsLZ2Q1rjcm6LixOkaccKWC8qFMpzm/ldkO7WOMK+ZRfk5Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lvce-editor/verror": "^1.6.0",
|
||||
"execa": "^9.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"got": "^14.4.5",
|
||||
"path-exists": "^5.0.0",
|
||||
"tempy": "^3.1.0",
|
||||
"xdg-basedir": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lvce-editor/ripgrep/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lvce-editor/verror": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
|
||||
@@ -6475,33 +6470,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
|
||||
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string/node_modules/type-fest": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
|
||||
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -14353,45 +14321,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz",
|
||||
"integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/tempy": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz",
|
||||
"integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-stream": "^3.0.0",
|
||||
"temp-dir": "^3.0.0",
|
||||
"type-fest": "^2.12.2",
|
||||
"unique-string": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tempy/node_modules/is-stream": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
||||
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/terminal-link": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz",
|
||||
@@ -15027,21 +14956,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unique-string": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
|
||||
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-random-string": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
@@ -16572,7 +16486,7 @@
|
||||
"version": "0.3.4",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
|
||||
@@ -25,6 +25,11 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'
|
||||
|
||||
import { ShellTool } from '../tools/shell.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js';
|
||||
import { logRipgrepFallback } from '../telemetry/loggers.js';
|
||||
import { RipgrepFallbackEvent } from '../telemetry/types.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -56,7 +61,11 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
|
||||
// Mock individual tools if their constructors are complex or have side effects
|
||||
vi.mock('../tools/ls');
|
||||
vi.mock('../tools/read-file');
|
||||
vi.mock('../tools/grep');
|
||||
vi.mock('../tools/grep.js');
|
||||
vi.mock('../tools/ripGrep.js', () => ({
|
||||
canUseRipgrep: vi.fn(),
|
||||
RipGrepTool: class MockRipGrepTool {},
|
||||
}));
|
||||
vi.mock('../tools/glob');
|
||||
vi.mock('../tools/edit');
|
||||
vi.mock('../tools/shell');
|
||||
@@ -88,6 +97,15 @@ vi.mock('../telemetry/index.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../telemetry/loggers.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../telemetry/loggers.js')>();
|
||||
return {
|
||||
...actual,
|
||||
logRipgrepFallback: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/gitService.js', () => {
|
||||
const GitServiceMock = vi.fn();
|
||||
GitServiceMock.prototype.initialize = vi.fn();
|
||||
@@ -666,4 +684,93 @@ describe('setApprovalMode with folder trust', () => {
|
||||
expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow();
|
||||
expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow();
|
||||
});
|
||||
|
||||
describe('registerCoreTools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register RipGrepTool when useRipgrep is true and it is available', async () => {
|
||||
(canUseRipgrep as Mock).mockResolvedValue(true);
|
||||
const config = new Config({ ...baseParams, useRipgrep: true });
|
||||
await config.initialize();
|
||||
|
||||
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
|
||||
const wasRipGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(RipGrepTool),
|
||||
);
|
||||
const wasGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(GrepTool),
|
||||
);
|
||||
|
||||
expect(wasRipGrepRegistered).toBe(true);
|
||||
expect(wasGrepRegistered).toBe(false);
|
||||
expect(logRipgrepFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register GrepTool as a fallback when useRipgrep is true but it is not available', async () => {
|
||||
(canUseRipgrep as Mock).mockResolvedValue(false);
|
||||
const config = new Config({ ...baseParams, useRipgrep: true });
|
||||
await config.initialize();
|
||||
|
||||
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
|
||||
const wasRipGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(RipGrepTool),
|
||||
);
|
||||
const wasGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(GrepTool),
|
||||
);
|
||||
|
||||
expect(wasRipGrepRegistered).toBe(false);
|
||||
expect(wasGrepRegistered).toBe(true);
|
||||
expect(logRipgrepFallback).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.any(RipgrepFallbackEvent),
|
||||
);
|
||||
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
|
||||
expect(event.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should register GrepTool as a fallback when canUseRipgrep throws an error', async () => {
|
||||
const error = new Error('ripGrep check failed');
|
||||
(canUseRipgrep as Mock).mockRejectedValue(error);
|
||||
const config = new Config({ ...baseParams, useRipgrep: true });
|
||||
await config.initialize();
|
||||
|
||||
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
|
||||
const wasRipGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(RipGrepTool),
|
||||
);
|
||||
const wasGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(GrepTool),
|
||||
);
|
||||
|
||||
expect(wasRipGrepRegistered).toBe(false);
|
||||
expect(wasGrepRegistered).toBe(true);
|
||||
expect(logRipgrepFallback).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.any(RipgrepFallbackEvent),
|
||||
);
|
||||
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
|
||||
expect(event.error).toBe(String(error));
|
||||
});
|
||||
|
||||
it('should register GrepTool when useRipgrep is false', async () => {
|
||||
const config = new Config({ ...baseParams, useRipgrep: false });
|
||||
await config.initialize();
|
||||
|
||||
const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls;
|
||||
const wasRipGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(RipGrepTool),
|
||||
);
|
||||
const wasGrepRegistered = calls.some(
|
||||
(call) => call[0] instanceof vi.mocked(GrepTool),
|
||||
);
|
||||
|
||||
expect(wasRipGrepRegistered).toBe(false);
|
||||
expect(wasGrepRegistered).toBe(true);
|
||||
expect(canUseRipgrep).not.toHaveBeenCalled();
|
||||
expect(logRipgrepFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { RipGrepTool } from '../tools/ripGrep.js';
|
||||
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
|
||||
import { GlobTool } from '../tools/glob.js';
|
||||
import { EditTool } from '../tools/edit.js';
|
||||
import { SmartEditTool } from '../tools/smart-edit.js';
|
||||
@@ -50,8 +50,16 @@ import { IdeClient } from '../ide/ide-client.js';
|
||||
import { ideContext } from '../ide/ideContext.js';
|
||||
import type { FileSystemService } from '../services/fileSystemService.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js';
|
||||
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
|
||||
import {
|
||||
logCliConfiguration,
|
||||
logIdeConnection,
|
||||
logRipgrepFallback,
|
||||
} from '../telemetry/loggers.js';
|
||||
import {
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
RipgrepFallbackEvent,
|
||||
} from '../telemetry/types.js';
|
||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||
|
||||
// Re-export OAuth config type
|
||||
@@ -892,7 +900,19 @@ export class Config {
|
||||
registerCoreTool(ReadFileTool, this);
|
||||
|
||||
if (this.getUseRipgrep()) {
|
||||
registerCoreTool(RipGrepTool, this);
|
||||
let useRipgrep = false;
|
||||
let errorString: undefined | string = undefined;
|
||||
try {
|
||||
useRipgrep = await canUseRipgrep();
|
||||
} catch (error: unknown) {
|
||||
errorString = String(error);
|
||||
}
|
||||
if (useRipgrep) {
|
||||
registerCoreTool(RipGrepTool, this);
|
||||
} else {
|
||||
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
|
||||
registerCoreTool(GrepTool, this);
|
||||
}
|
||||
} else {
|
||||
registerCoreTool(GrepTool, this);
|
||||
}
|
||||
|
||||
@@ -52,4 +52,9 @@ describe('Storage – additional helpers', () => {
|
||||
);
|
||||
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
|
||||
});
|
||||
|
||||
it('getGlobalBinDir returns ~/.gemini/tmp/bin', () => {
|
||||
const expected = path.join(os.homedir(), '.gemini', 'tmp', 'bin');
|
||||
expect(Storage.getGlobalBinDir()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export const GEMINI_DIR = '.gemini';
|
||||
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
|
||||
export class Storage {
|
||||
private readonly targetDir: string;
|
||||
@@ -57,6 +58,10 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalGeminiDir(), TMP_DIR_NAME);
|
||||
}
|
||||
|
||||
static getGlobalBinDir(): string {
|
||||
return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME);
|
||||
}
|
||||
|
||||
getGeminiDir(): string {
|
||||
return path.join(this.targetDir, GEMINI_DIR);
|
||||
}
|
||||
|
||||
@@ -392,6 +392,24 @@ describe('ClearcutLogger', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('logRipgrepFallbackEvent', () => {
|
||||
it('logs an event with the proper name', () => {
|
||||
const { logger } = setup();
|
||||
// Spy on flushToClearcut to prevent it from clearing the queue
|
||||
const flushSpy = vi
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.spyOn(logger!, 'flushToClearcut' as any)
|
||||
.mockResolvedValue({ nextRequestWaitMs: 0 });
|
||||
|
||||
logger?.logRipgrepFallbackEvent();
|
||||
|
||||
const events = getEvents(logger!);
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toHaveEventName(EventNames.RIPGREP_FALLBACK);
|
||||
expect(flushSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enqueueLogEvent', () => {
|
||||
it('should add events to the queue', () => {
|
||||
const { logger } = setup();
|
||||
|
||||
@@ -44,6 +44,7 @@ export enum EventNames {
|
||||
API_ERROR = 'api_error',
|
||||
END_SESSION = 'end_session',
|
||||
FLASH_FALLBACK = 'flash_fallback',
|
||||
RIPGREP_FALLBACK = 'ripgrep_fallback',
|
||||
LOOP_DETECTED = 'loop_detected',
|
||||
NEXT_SPEAKER_CHECK = 'next_speaker_check',
|
||||
SLASH_COMMAND = 'slash_command',
|
||||
@@ -631,6 +632,13 @@ export class ClearcutLogger {
|
||||
});
|
||||
}
|
||||
|
||||
logRipgrepFallbackEvent(): void {
|
||||
this.enqueueLogEvent(this.createLogEvent(EventNames.RIPGREP_FALLBACK, []));
|
||||
this.flushToClearcut().catch((error) => {
|
||||
console.debug('Error flushing to Clearcut:', error);
|
||||
});
|
||||
}
|
||||
|
||||
logLoopDetectedEvent(event: LoopDetectedEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ export const EVENT_API_ERROR = 'gemini_cli.api_error';
|
||||
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
|
||||
export const EVENT_CLI_CONFIG = 'gemini_cli.config';
|
||||
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
|
||||
export const EVENT_RIPGREP_FALLBACK = 'gemini_cli.ripgrep_fallback';
|
||||
export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
|
||||
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
|
||||
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
EVENT_FLASH_FALLBACK,
|
||||
EVENT_MALFORMED_JSON_RESPONSE,
|
||||
EVENT_FILE_OPERATION,
|
||||
EVENT_RIPGREP_FALLBACK,
|
||||
} from './constants.js';
|
||||
import {
|
||||
logApiRequest,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
logChatCompression,
|
||||
logMalformedJsonResponse,
|
||||
logFileOperation,
|
||||
logRipgrepFallback,
|
||||
} from './loggers.js';
|
||||
import { ToolCallDecision } from './tool-call-decision.js';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
ToolCallEvent,
|
||||
UserPromptEvent,
|
||||
FlashFallbackEvent,
|
||||
RipgrepFallbackEvent,
|
||||
MalformedJsonResponseEvent,
|
||||
makeChatCompressionEvent,
|
||||
FileOperationEvent,
|
||||
@@ -453,6 +456,59 @@ describe('loggers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('logRipgrepFallback', () => {
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ClearcutLogger.prototype, 'logRipgrepFallbackEvent');
|
||||
});
|
||||
|
||||
it('should log ripgrep fallback event', () => {
|
||||
const event = new RipgrepFallbackEvent();
|
||||
|
||||
logRipgrepFallback(mockConfig, event);
|
||||
|
||||
expect(
|
||||
ClearcutLogger.prototype.logRipgrepFallbackEvent,
|
||||
).toHaveBeenCalled();
|
||||
|
||||
const emittedEvent = mockLogger.emit.mock.calls[0][0];
|
||||
expect(emittedEvent.body).toBe('Switching to grep as fallback.');
|
||||
expect(emittedEvent.attributes).toEqual(
|
||||
expect.objectContaining({
|
||||
'session.id': 'test-session-id',
|
||||
'user.email': 'test-user@example.com',
|
||||
'event.name': EVENT_RIPGREP_FALLBACK,
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log ripgrep fallback event with an error', () => {
|
||||
const event = new RipgrepFallbackEvent('rg not found');
|
||||
|
||||
logRipgrepFallback(mockConfig, event);
|
||||
|
||||
expect(
|
||||
ClearcutLogger.prototype.logRipgrepFallbackEvent,
|
||||
).toHaveBeenCalled();
|
||||
|
||||
const emittedEvent = mockLogger.emit.mock.calls[0][0];
|
||||
expect(emittedEvent.body).toBe('Switching to grep as fallback.');
|
||||
expect(emittedEvent.attributes).toEqual(
|
||||
expect.objectContaining({
|
||||
'session.id': 'test-session-id',
|
||||
'user.email': 'test-user@example.com',
|
||||
'event.name': EVENT_RIPGREP_FALLBACK,
|
||||
error: 'rg not found',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logToolCall', () => {
|
||||
const cfg1 = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
EVENT_CONTENT_RETRY,
|
||||
EVENT_CONTENT_RETRY_FAILURE,
|
||||
EVENT_FILE_OPERATION,
|
||||
EVENT_RIPGREP_FALLBACK,
|
||||
} from './constants.js';
|
||||
import type {
|
||||
ApiErrorEvent,
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
InvalidChunkEvent,
|
||||
ContentRetryEvent,
|
||||
ContentRetryFailureEvent,
|
||||
RipgrepFallbackEvent,
|
||||
} from './types.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -268,6 +270,28 @@ export function logFlashFallback(
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logRipgrepFallback(
|
||||
config: Config,
|
||||
event: RipgrepFallbackEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logRipgrepFallbackEvent();
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_RIPGREP_FALLBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `Switching to grep as fallback.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logApiError(config: Config, event: ApiErrorEvent): void {
|
||||
const uiEvent = {
|
||||
...event,
|
||||
|
||||
@@ -281,6 +281,16 @@ export class FlashFallbackEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class RipgrepFallbackEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'ripgrep_fallback';
|
||||
'event.timestamp': string;
|
||||
|
||||
constructor(public error?: string) {
|
||||
this['event.name'] = 'ripgrep_fallback';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export enum LoopType {
|
||||
CONSECUTIVE_IDENTICAL_TOOL_CALLS = 'consecutive_identical_tool_calls',
|
||||
CHANTING_IDENTICAL_SENTENCES = 'chanting_identical_sentences',
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import type { RipGrepToolParams } from './ripGrep.js';
|
||||
import { RipGrepTool } from './ripGrep.js';
|
||||
import { canUseRipgrep, RipGrepTool } from './ripGrep.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import os, { EOL } from 'node:os';
|
||||
@@ -14,10 +22,24 @@ import type { Config } from '../config/config.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
|
||||
// Mock @lvce-editor/ripgrep for testing
|
||||
vi.mock('@lvce-editor/ripgrep', () => ({
|
||||
rgPath: '/mock/rg/path',
|
||||
// Mock dependencies for canUseRipgrep
|
||||
vi.mock('@joshua.litt/get-ripgrep', () => ({
|
||||
downloadRipGrep: vi.fn(),
|
||||
}));
|
||||
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
fileExists: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../config/storage.js', () => ({
|
||||
Storage: {
|
||||
getGlobalBinDir: vi.fn().mockReturnValue('/mock/bin/dir'),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock child_process for ripgrep calls
|
||||
@@ -27,6 +49,54 @@ vi.mock('child_process', () => ({
|
||||
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
|
||||
describe('canUseRipgrep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return true if ripgrep already exists', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(true);
|
||||
const result = await canUseRipgrep();
|
||||
expect(result).toBe(true);
|
||||
expect(fileExists).toHaveBeenCalledWith(path.join('/mock/bin/dir', 'rg'));
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download ripgrep and return true if it does not exist initially', async () => {
|
||||
(fileExists as Mock)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
(downloadRipGrep as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fileExists).toHaveBeenCalledTimes(2);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
|
||||
});
|
||||
|
||||
it('should return false if download fails and file does not exist', async () => {
|
||||
(fileExists as Mock).mockResolvedValue(false);
|
||||
(downloadRipGrep as Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fileExists).toHaveBeenCalledTimes(2);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
(fileExists as Mock).mockResolvedValue(false);
|
||||
(downloadRipGrep as Mock).mockRejectedValue(error);
|
||||
|
||||
await expect(canUseRipgrep()).rejects.toThrow(error);
|
||||
expect(fileExists).toHaveBeenCalledTimes(1);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create mock spawn implementations
|
||||
function createMockSpawn(
|
||||
options: {
|
||||
|
||||
@@ -8,16 +8,34 @@ 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 { downloadRipGrep } from '@joshua.litt/get-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';
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
|
||||
const DEFAULT_TOTAL_MAX_MATCHES = 20000;
|
||||
|
||||
function getRgPath() {
|
||||
return path.join(Storage.getGlobalBinDir(), 'rg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if `rg` exists, if not then attempt to download it.
|
||||
*/
|
||||
export async function canUseRipgrep(): Promise<boolean> {
|
||||
if (await fileExists(getRgPath())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await downloadRipGrep(Storage.getGlobalBinDir());
|
||||
return await fileExists(getRgPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the GrepTool
|
||||
*/
|
||||
@@ -293,7 +311,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
try {
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(rgPath, rgArgs, {
|
||||
const child = spawn(getRgPath(), rgArgs, {
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
processSingleFileContent,
|
||||
detectBOM,
|
||||
readFileWithEncoding,
|
||||
fileExists,
|
||||
} from './fileUtils.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
|
||||
@@ -133,6 +134,25 @@ describe('fileUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileExists', () => {
|
||||
it('should return true if the file exists', async () => {
|
||||
const testFile = path.join(tempRootDir, 'exists.txt');
|
||||
actualNodeFs.writeFileSync(testFile, 'content');
|
||||
await expect(fileExists(testFile)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the file does not exist', async () => {
|
||||
const testFile = path.join(tempRootDir, 'does-not-exist.txt');
|
||||
await expect(fileExists(testFile)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a directory that exists', async () => {
|
||||
const testDir = path.join(tempRootDir, 'exists-dir');
|
||||
actualNodeFs.mkdirSync(testDir);
|
||||
await expect(fileExists(testDir)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBinaryFile', () => {
|
||||
let filePathForBinaryTest: string;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartUnion } from '@google/genai';
|
||||
// eslint-disable-next-line import/no-internal-modules
|
||||
@@ -467,3 +468,12 @@ export async function processSingleFileContent(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsPromises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_: unknown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user