From 259c4d57b5a7d3e89897f768dc8ab4b8e4a89c74 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Tue, 17 Feb 2026 19:36:59 -0800 Subject: [PATCH] feat(core): Include 50 lines of context in grep_search when matches <= 3 --- packages/core/src/tools/grep.test.ts | 40 ++++++++++++++--- packages/core/src/tools/grep.ts | 64 +++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index cecc32d5f1..9d4395f2fc 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -493,10 +493,12 @@ describe('GrepTool', () => { // sub/fileC.txt has 1 world, so total matches = 2. expect(result.llmContent).toContain('Found 2 matches'); expect(result.llmContent).toContain('File: fileA.txt'); - expect(result.llmContent).toContain('L1: hello world'); - expect(result.llmContent).not.toContain('L2: second line with world'); + // Should be a match + expect(result.llmContent).toContain('> L1: hello world'); + // Should NOT be a match (but might be in context) + expect(result.llmContent).not.toContain('> L2: second line with world'); expect(result.llmContent).toContain('File: sub/fileC.txt'); - expect(result.llmContent).toContain('L1: another world in sub dir'); + expect(result.llmContent).toContain('> L1: another world in sub dir'); }); it('should return only file paths when names_only is true', async () => { @@ -530,8 +532,36 @@ describe('GrepTool', () => { expect(result.llmContent).toContain('Found 1 match'); expect(result.llmContent).toContain('copyright.txt'); - expect(result.llmContent).toContain('Copyright 2025 Google LLC'); - expect(result.llmContent).not.toContain('Copyright 2026 Google LLC'); + // Should be a match + expect(result.llmContent).toContain('> L1: Copyright 2025 Google LLC'); + // Should NOT be a match (but might be in context) + expect(result.llmContent).not.toContain( + '> L2: Copyright 2026 Google LLC', + ); + }); + + it('should include context when matches are <= 3', async () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + lines[50] = 'Target match'; + await fs.writeFile( + path.join(tempRootDir, 'context.txt'), + lines.join('\n'), + ); + + const params: GrepToolParams = { pattern: 'Target match' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain( + 'Found 1 match for pattern "Target match"', + ); + expect(result.llmContent).toContain('Match at line 51:'); + // Verify context before + expect(result.llmContent).toContain(' L40: Line 40'); + // Verify match line + expect(result.llmContent).toContain('> L51: Target match'); + // Verify context after + expect(result.llmContent).toContain(' L60: Line 60'); }); }); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index b1fdb9474c..e802b0279d 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -75,6 +75,7 @@ export interface GrepToolParams { */ interface GrepMatch { filePath: string; + absolutePath: string; lineNumber: number; line: string; } @@ -130,6 +131,7 @@ class GrepToolInvocation extends BaseToolInvocation< return { filePath: relativeFilePath || path.basename(absoluteFilePath), + absolutePath: absoluteFilePath, lineNumber, line: lineContent, }; @@ -309,14 +311,61 @@ class GrepToolInvocation extends BaseToolInvocation< llmContent += `:\n---\n`; - for (const filePath in matchesByFile) { - llmContent += `File: ${filePath} + if (matchCount <= 3 && matchCount > 0 && !this.params.names_only) { + for (const filePath in matchesByFile) { + const fileMatches = matchesByFile[filePath]; + let fileLines: string[] | null = null; + try { + const content = await fsPromises.readFile( + fileMatches[0].absolutePath, + 'utf8', + ); + fileLines = content.split(/\r?\n/); + } catch (err) { + debugLogger.warn( + `Failed to read file for context: ${fileMatches[0].absolutePath}`, + err, + ); + } + + llmContent += `File: ${filePath}\n`; + + for (const match of fileMatches) { + if (fileLines) { + const startLine = Math.max(0, match.lineNumber - 1 - 50); + const endLine = Math.min( + fileLines.length, + match.lineNumber - 1 + 50 + 1, + ); + const contextLines = fileLines.slice(startLine, endLine); + + llmContent += `Match at line ${match.lineNumber}:\n`; + contextLines.forEach((line, index) => { + const currentLineNumber = startLine + index + 1; + if (currentLineNumber === match.lineNumber) { + llmContent += `> L${currentLineNumber}: ${line}\n`; + } else { + llmContent += ` L${currentLineNumber}: ${line}\n`; + } + }); + llmContent += '\n'; + } else { + const trimmedLine = match.line.trim(); + llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; + } + } + llmContent += '---\n'; + } + } else { + for (const filePath in matchesByFile) { + llmContent += `File: ${filePath} `; - matchesByFile[filePath].forEach((match) => { - const trimmedLine = match.line.trim(); - llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; - }); - llmContent += '---\n'; + matchesByFile[filePath].forEach((match) => { + const trimmedLine = match.line.trim(); + llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; + }); + llmContent += '---\n'; + } } return { @@ -569,6 +618,7 @@ class GrepToolInvocation extends BaseToolInvocation< filePath: path.relative(absolutePath, fileAbsolutePath) || path.basename(fileAbsolutePath), + absolutePath: fileAbsolutePath, lineNumber: index + 1, line, });