mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 16:50:59 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e7ed1ed2 | |||
| 85ae41535f | |||
| 64b3c6d60f | |||
| ad83541f7b | |||
| 95377789d5 | |||
| 90d4d79f29 | |||
| a7457b0156 | |||
| 7091cef045 | |||
| 8cb2bfe995 | |||
| 5a1f0cbc49 | |||
| 259c4d57b5 | |||
| 047a57dee8 |
@@ -156,6 +156,11 @@ function validateHistory(history: Content[]) {
|
||||
* filters or recitation). Extracting valid turns from the history
|
||||
* ensures that subsequent requests could be accepted by the model.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prunes the history to remove large tool outputs (like Greedy Grep context)
|
||||
* after they have been seen by the model for one turn.
|
||||
*/
|
||||
function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
|
||||
if (comprehensiveHistory === undefined || comprehensiveHistory.length === 0) {
|
||||
return [];
|
||||
@@ -684,9 +689,8 @@ export class GeminiChat {
|
||||
* chat session.
|
||||
*/
|
||||
getHistory(curated: boolean = false): Content[] {
|
||||
const history = curated
|
||||
? extractCuratedHistory(this.history)
|
||||
: this.history;
|
||||
const history = curated ? extractCuratedHistory(this.history) : this.history;
|
||||
|
||||
// Deep copy the history to avoid mutating the history outside of the
|
||||
// chat session.
|
||||
return structuredClone(history);
|
||||
|
||||
@@ -5,20 +5,21 @@
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { MessageBusType, type Message } from '../confirmation-bus/types.js';
|
||||
|
||||
/**
|
||||
* Mock MessageBus for testing hook execution through MessageBus
|
||||
*/
|
||||
export class MockMessageBus {
|
||||
private subscriptions = new Map<
|
||||
MessageBusType,
|
||||
Set<(message: Message) => void>
|
||||
>();
|
||||
export class MockMessageBus extends EventEmitter {
|
||||
publishedMessages: Message[] = [];
|
||||
defaultToolDecision: 'allow' | 'deny' | 'ask_user' = 'allow';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock publish method that captures messages and simulates responses
|
||||
*/
|
||||
@@ -52,6 +53,7 @@ export class MockMessageBus {
|
||||
|
||||
// Emit the message to subscribers (mimicking real MessageBus behavior)
|
||||
this.emit(message.type, message);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -59,11 +61,7 @@ export class MockMessageBus {
|
||||
*/
|
||||
subscribe = vi.fn(
|
||||
<T extends Message>(type: T['type'], listener: (message: T) => void) => {
|
||||
if (!this.subscriptions.has(type)) {
|
||||
this.subscriptions.set(type, new Set());
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
this.subscriptions.get(type)!.add(listener as (message: Message) => void);
|
||||
this.on(type, listener);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -72,30 +70,16 @@ export class MockMessageBus {
|
||||
*/
|
||||
unsubscribe = vi.fn(
|
||||
<T extends Message>(type: T['type'], listener: (message: T) => void) => {
|
||||
const listeners = this.subscriptions.get(type);
|
||||
if (listeners) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
listeners.delete(listener as (message: Message) => void);
|
||||
}
|
||||
this.off(type, listener);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Emit a message to subscribers (for testing)
|
||||
*/
|
||||
private emit(type: MessageBusType, message: Message) {
|
||||
const listeners = this.subscriptions.get(type);
|
||||
if (listeners) {
|
||||
listeners.forEach((listener) => listener(message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all captured messages (for test isolation)
|
||||
*/
|
||||
clear() {
|
||||
this.publishedMessages = [];
|
||||
this.subscriptions.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -410,6 +410,57 @@ interface CalculatedEdit {
|
||||
matchRanges?: Array<{ start: number; end: number }>;
|
||||
}
|
||||
|
||||
function getDiffContextSnippet(
|
||||
originalContent: string,
|
||||
newContent: string,
|
||||
contextLines = 5,
|
||||
): string {
|
||||
if (!originalContent) return newContent;
|
||||
const changes = Diff.diffLines(originalContent, newContent);
|
||||
const newLines = newContent.split(/\r?\n/);
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let newLineIdx = 0;
|
||||
for (const change of changes) {
|
||||
if (change.added) {
|
||||
ranges.push({ start: newLineIdx, end: newLineIdx + (change.count ?? 0) });
|
||||
newLineIdx += change.count ?? 0;
|
||||
} else if (change.removed) {
|
||||
ranges.push({ start: newLineIdx, end: newLineIdx });
|
||||
} else {
|
||||
newLineIdx += change.count ?? 0;
|
||||
}
|
||||
}
|
||||
if (ranges.length === 0) return newContent;
|
||||
const expandedRanges = ranges.map((r) => ({
|
||||
start: Math.max(0, r.start - contextLines),
|
||||
end: Math.min(newLines.length, r.end + contextLines),
|
||||
}));
|
||||
expandedRanges.sort((a, b) => a.start - b.start);
|
||||
const mergedRanges: Array<{ start: number; end: number }> = [];
|
||||
if (expandedRanges.length > 0) {
|
||||
let current = expandedRanges[0];
|
||||
for (let i = 1; i < expandedRanges.length; i++) {
|
||||
const next = expandedRanges[i];
|
||||
if (next.start <= current.end) {
|
||||
current.end = Math.max(current.end, next.end);
|
||||
} else {
|
||||
mergedRanges.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
mergedRanges.push(current);
|
||||
}
|
||||
const outputParts: string[] = [];
|
||||
let lastEnd = 0;
|
||||
for (const range of mergedRanges) {
|
||||
if (range.start > lastEnd) outputParts.push('...');
|
||||
outputParts.push(newLines.slice(range.start, range.end).join('\n'));
|
||||
lastEnd = range.end;
|
||||
}
|
||||
if (lastEnd < newLines.length) outputParts.push('...');
|
||||
return outputParts.join('\n');
|
||||
}
|
||||
|
||||
class EditToolInvocation
|
||||
extends BaseToolInvocation<EditToolParams, ToolResult>
|
||||
implements ToolInvocation<EditToolParams, ToolResult>
|
||||
@@ -876,6 +927,13 @@ class EditToolInvocation
|
||||
? `Created new file: ${this.params.file_path} with provided content.`
|
||||
: `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`,
|
||||
];
|
||||
const snippet = getDiffContextSnippet(
|
||||
editData.currentContent ?? '',
|
||||
finalContent,
|
||||
5,
|
||||
);
|
||||
llmSuccessMessageParts.push(`Here is the updated code:
|
||||
${snippet}`);
|
||||
const fuzzyFeedback = getFuzzyMatchFeedback(editData);
|
||||
if (fuzzyFeedback) {
|
||||
llmSuccessMessageParts.push(fuzzyFeedback);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,62 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
llmContent += `:\n---\n`;
|
||||
|
||||
for (const filePath in matchesByFile) {
|
||||
llmContent += `File: ${filePath}
|
||||
if (matchCount >= 1 && matchCount <= 3 && !this.params.names_only) {
|
||||
const contextBudget = matchCount === 1 ? 50 : 15;
|
||||
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 - contextBudget);
|
||||
const endLine = Math.min(
|
||||
fileLines.length,
|
||||
match.lineNumber - 1 + contextBudget + 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 +619,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
filePath:
|
||||
path.relative(absolutePath, fileAbsolutePath) ||
|
||||
path.basename(fileAbsolutePath),
|
||||
absolutePath: fileAbsolutePath,
|
||||
lineNumber: index + 1,
|
||||
line,
|
||||
});
|
||||
|
||||
@@ -160,6 +160,9 @@ ${result.llmContent}`;
|
||||
/**
|
||||
* Implementation of the ReadFile tool logic
|
||||
*/
|
||||
const readCache = new Map<string, ToolResult>();
|
||||
|
||||
|
||||
export class ReadFileTool extends BaseDeclarativeTool<
|
||||
ReadFileToolParams,
|
||||
ToolResult
|
||||
@@ -185,6 +188,12 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
config.getTargetDir(),
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
// Clear cache on any potential file modification
|
||||
messageBus.on('tool_call_confirm', (details) => {
|
||||
if (details.type === 'edit' || details.title.toLowerCase().includes('write')) {
|
||||
readCache.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
@@ -233,13 +242,34 @@ export class ReadFileTool extends BaseDeclarativeTool<
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
): ToolInvocation<ReadFileToolParams, ToolResult> {
|
||||
return new ReadFileToolInvocation(
|
||||
this.config,
|
||||
const key = JSON.stringify({
|
||||
path: path.resolve(this.config.getTargetDir(), params.file_path),
|
||||
offset: params.offset,
|
||||
limit: params.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
toolLocations: () => [{ path: path.resolve(this.config.getTargetDir(), params.file_path), line: params.offset }],
|
||||
getDescription: () => shortenPath(makeRelative(path.resolve(this.config.getTargetDir(), params.file_path), this.config.getTargetDir())),
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
execute: async () => {
|
||||
if (readCache.has(key)) {
|
||||
return readCache.get(key)!;
|
||||
}
|
||||
const invocation = new ReadFileToolInvocation(
|
||||
this.config,
|
||||
params,
|
||||
messageBus,
|
||||
_toolName,
|
||||
_toolDisplayName,
|
||||
);
|
||||
const result = await invocation.execute();
|
||||
if (!result.error) {
|
||||
readCache.set(key, result);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
} as unknown as ToolInvocation<ReadFileToolParams, ToolResult>;
|
||||
}
|
||||
|
||||
override getSchema(modelId?: string) {
|
||||
|
||||
@@ -160,6 +160,7 @@ export interface RipGrepToolParams {
|
||||
*/
|
||||
interface GrepMatch {
|
||||
filePath: string;
|
||||
absolutePath: string;
|
||||
lineNumber: number;
|
||||
line: string;
|
||||
isContext?: boolean;
|
||||
@@ -310,6 +311,68 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
const matchCount = matchesOnly.length;
|
||||
const matchTerm = matchCount === 1 ? 'match' : 'matches';
|
||||
|
||||
// Greedy Grep: If match count is low and no context was requested, automatically return context.
|
||||
if (
|
||||
matchCount >= 1 &&
|
||||
matchCount <= 3 &&
|
||||
!this.params.names_only &&
|
||||
!this.params.context &&
|
||||
!this.params.before &&
|
||||
!this.params.after
|
||||
) {
|
||||
const contextLines = matchCount === 1 ? 50 : 15;
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if (fileLines) {
|
||||
const newFileMatches: GrepMatch[] = [];
|
||||
const seenLines = new Set<number>();
|
||||
for (const match of fileMatches) {
|
||||
const startLine = Math.max(0, match.lineNumber - 1 - contextLines);
|
||||
const endLine = Math.min(
|
||||
fileLines.length,
|
||||
match.lineNumber - 1 + contextLines + 1,
|
||||
);
|
||||
for (let i = startLine; i < endLine; i++) {
|
||||
if (!seenLines.has(i + 1)) {
|
||||
newFileMatches.push({
|
||||
absolutePath: match.absolutePath,
|
||||
filePath: match.filePath,
|
||||
lineNumber: i + 1,
|
||||
line: fileLines[i],
|
||||
isContext: i + 1 !== match.lineNumber,
|
||||
});
|
||||
seenLines.add(i + 1);
|
||||
} else if (i + 1 === match.lineNumber) {
|
||||
const index = newFileMatches.findIndex(
|
||||
(m) => m.lineNumber === i + 1,
|
||||
);
|
||||
if (index !== -1) {
|
||||
newFileMatches[index].isContext = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
matchesByFile[filePath] = newFileMatches.sort(
|
||||
(a, b) => a.lineNumber - b.lineNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wasTruncated = matchCount >= totalMaxMatches;
|
||||
|
||||
if (this.params.names_only) {
|
||||
@@ -500,6 +563,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
const relativeFilePath = path.relative(basePath, absoluteFilePath);
|
||||
|
||||
return {
|
||||
absolutePath: absoluteFilePath,
|
||||
filePath: relativeFilePath || path.basename(absoluteFilePath),
|
||||
lineNumber: data.line_number,
|
||||
line: data.lines.text.trimEnd(),
|
||||
|
||||
@@ -831,6 +831,63 @@ describe('WriteFileTool', () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('should include the file content in llmContent', async () => {
|
||||
const filePath = path.join(rootDir, 'content_check.txt');
|
||||
const content = 'This is the content that should be returned.';
|
||||
mockEnsureCorrectFileContent.mockResolvedValue(content);
|
||||
|
||||
const params = { file_path: filePath, content };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Here is the updated code:');
|
||||
expect(result.llmContent).toContain(content);
|
||||
});
|
||||
|
||||
it('should return only changed lines plus context for large updates', async () => {
|
||||
const filePath = path.join(rootDir, 'large_update.txt');
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
|
||||
const originalContent = lines.join('\n');
|
||||
fs.writeFileSync(filePath, originalContent, 'utf8');
|
||||
|
||||
const newLines = [...lines];
|
||||
newLines[50] = 'Line 51 Modified'; // Modify one line in the middle
|
||||
|
||||
const newContent = newLines.join('\n');
|
||||
mockEnsureCorrectEdit.mockResolvedValue({
|
||||
params: {
|
||||
file_path: filePath,
|
||||
old_string: originalContent,
|
||||
new_string: newContent,
|
||||
},
|
||||
occurrences: 1,
|
||||
});
|
||||
|
||||
const params = { file_path: filePath, content: newContent };
|
||||
const invocation = tool.build(params);
|
||||
|
||||
// Confirm execution first
|
||||
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
|
||||
if (confirmDetails && 'onConfirm' in confirmDetails) {
|
||||
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
|
||||
}
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Here is the updated code:');
|
||||
// Should contain the modified line
|
||||
expect(result.llmContent).toContain('Line 51 Modified');
|
||||
// Should contain context lines (e.g. Line 46, Line 56)
|
||||
expect(result.llmContent).toContain('Line 46');
|
||||
expect(result.llmContent).toContain('Line 56');
|
||||
// Should NOT contain far away lines (e.g. Line 1, Line 100)
|
||||
expect(result.llmContent).not.toContain('Line 1\n');
|
||||
expect(result.llmContent).not.toContain('Line 100');
|
||||
// Should indicate truncation
|
||||
expect(result.llmContent).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace boundary validation', () => {
|
||||
|
||||
@@ -147,6 +147,93 @@ export async function getCorrectedFileContent(
|
||||
return { originalContent, correctedContent, fileExists };
|
||||
}
|
||||
|
||||
function getDiffContextSnippet(
|
||||
originalContent: string,
|
||||
newContent: string,
|
||||
contextLines = 5,
|
||||
): string {
|
||||
// If no original content, return the whole new content
|
||||
if (!originalContent) {
|
||||
return newContent;
|
||||
}
|
||||
|
||||
const changes = Diff.diffLines(originalContent, newContent);
|
||||
const newLines = newContent.split(/\r?\n/);
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let newLineIdx = 0;
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.added) {
|
||||
// For added lines, the range covers the new lines
|
||||
ranges.push({
|
||||
start: newLineIdx,
|
||||
end: newLineIdx + change.count,
|
||||
});
|
||||
newLineIdx += change.count;
|
||||
} else if (change.removed) {
|
||||
// For removed lines, we mark the point in the new file where they were removed
|
||||
ranges.push({
|
||||
start: newLineIdx,
|
||||
end: newLineIdx,
|
||||
});
|
||||
// Removed lines don't advance the new file index
|
||||
} else {
|
||||
// Unchanged lines advance the index
|
||||
newLineIdx += change.count;
|
||||
}
|
||||
}
|
||||
|
||||
// If no changes were detected (e.g. only whitespace if diffLines is loose, or identical),
|
||||
// but we are here, we might just return the whole thing or say "no changes".
|
||||
// However, write_file implies we wrote something. If identical, ranges is empty.
|
||||
if (ranges.length === 0) {
|
||||
return newContent;
|
||||
}
|
||||
|
||||
// Expand ranges and clamp
|
||||
const expandedRanges = ranges.map((r) => ({
|
||||
start: Math.max(0, r.start - contextLines),
|
||||
end: Math.min(newLines.length, r.end + contextLines),
|
||||
}));
|
||||
|
||||
// Merge overlapping ranges
|
||||
expandedRanges.sort((a, b) => a.start - b.start);
|
||||
const mergedRanges: Array<{ start: number; end: number }> = [];
|
||||
if (expandedRanges.length > 0) {
|
||||
let current = expandedRanges[0];
|
||||
for (let i = 1; i < expandedRanges.length; i++) {
|
||||
const next = expandedRanges[i];
|
||||
if (next.start <= current.end) {
|
||||
current.end = Math.max(current.end, next.end);
|
||||
} else {
|
||||
mergedRanges.push(current);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
mergedRanges.push(current);
|
||||
}
|
||||
|
||||
// Build output
|
||||
const outputParts: string[] = [];
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const range of mergedRanges) {
|
||||
if (range.start > lastEnd) {
|
||||
outputParts.push('...');
|
||||
}
|
||||
// Slice is exclusive on end, but our 'end' is usually "line index + count", which works for slice.
|
||||
// However, for single lines or correct inclusivity, let's verify.
|
||||
// change.count is number of lines. index 0 + 1 line -> index 1. slice(0, 1) returns line 0. Correct.
|
||||
outputParts.push(newLines.slice(range.start, range.end).join('\n'));
|
||||
lastEnd = range.end;
|
||||
}
|
||||
if (lastEnd < newLines.length) {
|
||||
outputParts.push('...');
|
||||
}
|
||||
|
||||
return outputParts.join('\n');
|
||||
}
|
||||
|
||||
class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
WriteFileToolParams,
|
||||
ToolResult
|
||||
@@ -356,6 +443,13 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
);
|
||||
}
|
||||
|
||||
const snippet = getDiffContextSnippet(
|
||||
isNewFile ? '' : originalContent,
|
||||
finalContent,
|
||||
5,
|
||||
);
|
||||
llmSuccessMessageParts.push(`Here is the updated code:\n${snippet}`);
|
||||
|
||||
// Log file operation for telemetry (without diff_stat to avoid double-counting)
|
||||
const mimetype = getSpecificMimeType(this.resolvedPath);
|
||||
const programmingLanguage = getLanguageFromFilePath(this.resolvedPath);
|
||||
|
||||
Reference in New Issue
Block a user