Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/core/src/tools/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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';
import { FileReadCache } from '../services/fileReadCache.js';

vi.mock('glob', { spy: true });

Expand Down Expand Up @@ -84,6 +85,7 @@ vi.mock('child_process', async (importOriginal) => {
describe('GrepTool', () => {
let tempRootDir: string;
let grepTool: GrepTool;
let fileReadCache: FileReadCache;
const abortSignal = new AbortController().signal;

const mockConfig = {
Expand All @@ -100,6 +102,11 @@ describe('GrepTool', () => {
Object.assign(mockConfig, {
getTruncateToolOutputThreshold: () => 25000,
});
fileReadCache = new FileReadCache();
Object.assign(mockConfig, {
getFileReadCache: () => fileReadCache,
getFileReadCacheDisabled: () => false,
});
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
grepTool = new GrepTool(mockConfig);

Expand Down Expand Up @@ -214,6 +221,23 @@ describe('GrepTool', () => {
path.join(tempRootDir, 'fileA.txt'),
path.join(tempRootDir, 'sub', 'fileC.txt'),
]);

const fileAStats = await fs.stat(path.join(tempRootDir, 'fileA.txt'));
const fileCStats = await fs.stat(
path.join(tempRootDir, 'sub', 'fileC.txt'),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] These assertions only verify state === 'fresh' but do not check lastReadWasFull === false or lastReadCacheable === true. The {full: false, cacheable: true} contract — which is the entire semantic point of this PR — is unverified. If someone accidentally flipped full to true, these tests would still pass but the Read tool would incorrectly return file_unchanged placeholders.

Suggested change
);
const fileACheck = fileReadCache.check(fileAStats);
expect(fileACheck.state).toBe('fresh');
if (fileACheck.state === 'fresh') {
expect(fileACheck.entry.lastReadWasFull).toBe(false);
expect(fileACheck.entry.lastReadCacheable).toBe(true);
}
const fileCCheck = fileReadCache.check(fileCStats);
expect(fileCCheck.state).toBe('fresh');
if (fileCCheck.state === 'fresh') {
expect(fileCCheck.entry.lastReadWasFull).toBe(false);
expect(fileCCheck.entry.lastReadCacheable).toBe(true);
}

— qwen3.7-max via Qwen Code /review

const fileARead = fileReadCache.check(fileAStats);
const fileCRead = fileReadCache.check(fileCStats);
expect(fileARead.state).toBe('fresh');
expect(fileCRead.state).toBe('fresh');
if (fileARead.state === 'fresh') {
expect(fileARead.entry.lastReadWasFull).toBe(false);
expect(fileARead.entry.lastReadCacheable).toBe(true);
}
if (fileCRead.state === 'fresh') {
expect(fileCRead.entry.lastReadWasFull).toBe(false);
expect(fileCRead.entry.lastReadCacheable).toBe(true);
}
});

it('normalizes CRLF fallback grep output without dropping result paths', () => {
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/tools/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { PermissionDecision } from '../permissions/types.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
import { recordGrepResultFileReads } from './grepReadTracking.js';

const debugLogger = createDebugLogger('GREP');

Expand Down Expand Up @@ -268,16 +269,19 @@ class GrepToolInvocation extends BaseToolInvocation<
displayMessage += ` (truncated)`;
}

const resultFilePaths = Array.from(
new Set(
visibleMatches
.map((match) => match.absoluteFilePath)
.filter((filePath) => filePath !== ''),
),
);
await recordGrepResultFileReads(this.config, resultFilePaths);

return {
llmContent: llmContent.trim(),
returnDisplay: displayMessage,
resultFilePaths: Array.from(
new Set(
visibleMatches
.map((match) => match.absoluteFilePath)
.filter((filePath) => filePath !== ''),
),
),
resultFilePaths,
};
} catch (error) {
debugLogger.error(`Error during GrepLogic execution: ${error}`);
Expand Down
125 changes: 125 additions & 0 deletions packages/core/src/tools/grepReadTracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { FileReadCache } from '../services/fileReadCache.js';
import { recordGrepResultFileReads } from './grepReadTracking.js';

describe('recordGrepResultFileReads', () => {
let tempRootDir: string;
let fileReadCache: FileReadCache;

beforeEach(async () => {
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-read-cache-'));
fileReadCache = new FileReadCache();
});

afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tempRootDir, { recursive: true, force: true });
});

function mockConfig(
cache: FileReadCache | undefined = fileReadCache,
disabled = false,
): Config {
return {
getFileReadCacheDisabled: () => disabled,
getFileReadCache: () => cache,
} as unknown as Config;
}

it('does nothing when the cache is disabled', async () => {
const filePath = path.join(tempRootDir, 'result.ts');
await fs.writeFile(filePath, 'match');
const stats = await fs.stat(filePath);

await recordGrepResultFileReads(mockConfig(fileReadCache, true), [
filePath,
]);

expect(fileReadCache.check(stats).state).toBe('unknown');
});

it('does nothing when the config has no cache', async () => {
const filePath = path.join(tempRootDir, 'result.ts');
await fs.writeFile(filePath, 'match');

await expect(
recordGrepResultFileReads(mockConfig(undefined), [filePath]),
).resolves.toBeUndefined();
});

it('records text grep results as partial cacheable reads', async () => {
const filePath = path.join(tempRootDir, 'result.ts');
await fs.writeFile(filePath, 'const value = "match";');
const stats = await fs.stat(filePath);

await recordGrepResultFileReads(mockConfig(), [filePath]);

const result = fileReadCache.check(stats);
expect(result.state).toBe('fresh');
if (result.state === 'fresh') {
expect(result.entry.lastReadWasFull).toBe(false);
expect(result.entry.lastReadCacheable).toBe(true);
}
});

it('does not make notebook grep results cacheable', async () => {
const filePath = path.join(tempRootDir, 'notebook.ipynb');
await fs.writeFile(filePath, '{"cells":[{"source":"match"}]}');
const stats = await fs.stat(filePath);

await recordGrepResultFileReads(mockConfig(), [filePath]);

const result = fileReadCache.check(stats);
expect(result.state).toBe('fresh');
if (result.state === 'fresh') {
expect(result.entry.lastReadWasFull).toBe(false);
expect(result.entry.lastReadCacheable).toBe(false);
}
});

it('ignores non-file grep result paths', async () => {
const dirPath = path.join(tempRootDir, 'nested');
await fs.mkdir(dirPath);
const stats = await fs.stat(dirPath);

await recordGrepResultFileReads(mockConfig(), [dirPath]);

expect(fileReadCache.check(stats).state).toBe('unknown');
});

it('ignores result paths that disappear before stat', async () => {
await expect(
recordGrepResultFileReads(mockConfig(), [
path.join(tempRootDir, 'missing.ts'),
]),
).resolves.toBeUndefined();
});

it('continues after a stat failure', async () => {
const filePath = path.join(tempRootDir, 'result.ts');
const blockedPath = path.join(tempRootDir, 'blocked.ts');
await fs.writeFile(filePath, 'match');
const stats = await fs.stat(filePath);
const actualStat = fs.stat.bind(fs);
vi.spyOn(fs, 'stat').mockImplementation(async (target) => {
if (target === blockedPath) {
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
return actualStat(target);
});

await recordGrepResultFileReads(mockConfig(), [blockedPath, filePath]);

expect(fileReadCache.check(stats).state).toBe('fresh');
});
});
60 changes: 60 additions & 0 deletions packages/core/src/tools/grepReadTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import fs from 'node:fs/promises';
import path from 'node:path';
import type { Config } from '../config/config.js';
import { createDebugLogger } from '../utils/debugLogger.js';

const STAT_BATCH_SIZE = 50;
const NON_CACHEABLE_GREP_EXTENSIONS = new Set(['.ipynb']);
const debugLogger = createDebugLogger('GREP_READ_TRACKING');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No dedicated grepReadTracking.test.ts for this new module. The function has 5 code paths (cache disabled, no cache, happy path, stat failure, non-file result) but only the happy path is exercised indirectly through grep/ripGrep tests. The early-return guards (getFileReadCacheDisabled, getFileReadCache?.() returning undefined) and the error catch-all have zero test coverage.

— qwen3.7-max via Qwen Code /review

export async function recordGrepResultFileReads(
config: Config,
filePaths: string[],
): Promise<void> {
if (config.getFileReadCacheDisabled?.()) {
return;
}
const cache = config.getFileReadCache?.();
if (!cache) {
return;
}

const uniqueFilePaths = Array.from(new Set(filePaths));
for (let i = 0; i < uniqueFilePaths.length; i += STAT_BATCH_SIZE) {
const batch = uniqueFilePaths.slice(i, i + STAT_BATCH_SIZE);
await Promise.all(
batch.map(async (filePath) => {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
return;
}
cache.recordRead(filePath, stats, {
full: false,
cacheable: isGrepResultCacheable(filePath),
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
debugLogger.debug(
'Failed to stat grep result path',
filePath,
error,
);
}
}
}),
);
}
}

function isGrepResultCacheable(filePath: string): boolean {
return !NON_CACHEABLE_GREP_EXTENSIONS.has(
path.extname(filePath).toLowerCase(),
);
}
20 changes: 20 additions & 0 deletions packages/core/src/tools/ripGrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.j
import { spawn } from 'node:child_process';
import { runRipgrep } from '../utils/ripgrepUtils.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { FileReadCache } from '../services/fileReadCache.js';

// Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({
Expand All @@ -40,6 +41,7 @@ describe('RipGrepTool', () => {
let tempRootDir: string;
let grepTool: RipGrepTool;
let fileExclusionsMock: { getGlobExcludes: () => string[] };
let fileReadCache: FileReadCache;
const abortSignal = new AbortController().signal;
const sep = '\x1f';

Expand All @@ -63,9 +65,12 @@ describe('RipGrepTool', () => {
fileExclusionsMock = {
getGlobExcludes: vi.fn().mockReturnValue([]),
};
fileReadCache = new FileReadCache();
Object.assign(mockConfig, {
getFileExclusions: () => fileExclusionsMock,
getFileFilteringOptions: () => DEFAULT_FILE_FILTERING_OPTIONS,
getFileReadCache: () => fileReadCache,
getFileReadCacheDisabled: () => false,
});
grepTool = new RipGrepTool(mockConfig);

Expand Down Expand Up @@ -185,6 +190,21 @@ describe('RipGrepTool', () => {
path.join(tempRootDir, 'fileA.txt'),
path.join(tempRootDir, 'sub/fileC.txt'),
]);

const fileAStats = await fs.stat(path.join(tempRootDir, 'fileA.txt'));
const fileCStats = await fs.stat(path.join(tempRootDir, 'sub/fileC.txt'));
const fileARead = fileReadCache.check(fileAStats);
const fileCRead = fileReadCache.check(fileCStats);
expect(fileARead.state).toBe('fresh');
expect(fileCRead.state).toBe('fresh');
if (fileARead.state === 'fresh') {
expect(fileARead.entry.lastReadWasFull).toBe(false);
expect(fileARead.entry.lastReadCacheable).toBe(true);
}
if (fileCRead.state === 'fresh') {
expect(fileCRead.entry.lastReadWasFull).toBe(false);
expect(fileCRead.entry.lastReadCacheable).toBe(true);
}
});

it('should treat summary-only JSON output as no matches', async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tools/ripGrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import type { PermissionDecision } from '../permissions/types.js';
import { recordGrepResultFileReads } from './grepReadTracking.js';

const debugLogger = createDebugLogger('RIPGREP');
const RIPGREP_FIELD_SEPARATOR = '';
Expand Down Expand Up @@ -366,6 +367,7 @@ class GrepToolInvocation extends BaseToolInvocation<
),
),
);
await recordGrepResultFileReads(this.config, resultFilePaths);

return {
llmContent: llmContent.trim(),
Expand Down
Loading