mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
feat: implement file system reversion utilities for rewind (#15715)
This commit is contained in:
473
packages/cli/src/ui/utils/rewindFileOps.test.ts
Normal file
473
packages/cli/src/ui/utils/rewindFileOps.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
calculateTurnStats,
|
||||
calculateRewindImpact,
|
||||
revertFileChanges,
|
||||
} from './rewindFileOps.js';
|
||||
import type {
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
ToolCallRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('rewindFileOps', () => {
|
||||
const mockConversation: ConversationRecord = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'hash',
|
||||
startTime: 'time',
|
||||
lastUpdated: 'time',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('calculateTurnStats', () => {
|
||||
it('returns null if no edits found after user message', () => {
|
||||
const userMsg: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'hello',
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
};
|
||||
const geminiMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
content: 'hi',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
};
|
||||
mockConversation.messages = [userMsg, geminiMsg];
|
||||
|
||||
const stats = calculateTurnStats(mockConversation, userMsg);
|
||||
expect(stats).toBeNull();
|
||||
});
|
||||
|
||||
it('calculates stats for single turn correctly', () => {
|
||||
const userMsg: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'hello',
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
};
|
||||
const toolMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-1',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file1.ts',
|
||||
filePath: '/file1.ts',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
isNewFile: false,
|
||||
diffStat: {
|
||||
model_added_lines: 5,
|
||||
model_removed_lines: 2,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
model_added_chars: 100,
|
||||
model_removed_chars: 20,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
const userMsg2: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'next',
|
||||
id: '3',
|
||||
timestamp: '3',
|
||||
};
|
||||
|
||||
mockConversation.messages = [userMsg, toolMsg, userMsg2];
|
||||
|
||||
const stats = calculateTurnStats(mockConversation, userMsg);
|
||||
expect(stats).toEqual({
|
||||
addedLines: 5,
|
||||
removedLines: 2,
|
||||
fileCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRewindImpact', () => {
|
||||
it('calculates cumulative stats across multiple turns', () => {
|
||||
const userMsg1: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'start',
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
};
|
||||
const toolMsg1: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-1',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file1.ts',
|
||||
filePath: '/file1.ts',
|
||||
fileDiff: 'diff1',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
isNewFile: false,
|
||||
diffStat: {
|
||||
model_added_lines: 5,
|
||||
model_removed_lines: 2,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
const userMsg2: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'next',
|
||||
id: '3',
|
||||
timestamp: '3',
|
||||
};
|
||||
|
||||
const toolMsg2: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '4',
|
||||
timestamp: '4',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-2',
|
||||
status: 'success',
|
||||
timestamp: '4',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file2.ts',
|
||||
filePath: '/file2.ts',
|
||||
fileDiff: 'diff2',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
isNewFile: false,
|
||||
diffStat: {
|
||||
model_added_lines: 3,
|
||||
model_removed_lines: 1,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
mockConversation.messages = [userMsg1, toolMsg1, userMsg2, toolMsg2];
|
||||
|
||||
const stats = calculateRewindImpact(mockConversation, userMsg1);
|
||||
|
||||
expect(stats).toEqual({
|
||||
addedLines: 8, // 5 + 3
|
||||
removedLines: 3, // 2 + 1
|
||||
fileCount: 2,
|
||||
details: [
|
||||
{ fileName: 'file1.ts', diff: 'diff1' },
|
||||
{ fileName: 'file2.ts', diff: 'diff2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('revertFileChanges', () => {
|
||||
const mockDiffStat = {
|
||||
model_added_lines: 1,
|
||||
model_removed_lines: 1,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
model_added_chars: 1,
|
||||
model_removed_chars: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
};
|
||||
|
||||
it('does nothing if message not found', async () => {
|
||||
mockConversation.messages = [];
|
||||
await revertFileChanges(mockConversation, 'missing-id');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reverts exact match', async () => {
|
||||
const userMsg: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'start',
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
};
|
||||
const toolMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-1',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file.txt',
|
||||
filePath: path.resolve('/root/file.txt'),
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
isNewFile: false,
|
||||
diffStat: mockDiffStat,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
mockConversation.messages = [userMsg, toolMsg];
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue('new');
|
||||
|
||||
await revertFileChanges(mockConversation, '1');
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.resolve('/root/file.txt'),
|
||||
'old',
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes new file on revert', async () => {
|
||||
const userMsg: MessageRecord = {
|
||||
type: 'user',
|
||||
content: 'start',
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
};
|
||||
const toolMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'write_file',
|
||||
id: 'tool-call-2',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file.txt',
|
||||
filePath: path.resolve('/root/file.txt'),
|
||||
originalContent: null,
|
||||
newContent: 'content',
|
||||
isNewFile: true,
|
||||
diffStat: mockDiffStat,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
mockConversation.messages = [userMsg, toolMsg];
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue('content');
|
||||
|
||||
await revertFileChanges(mockConversation, '1');
|
||||
|
||||
expect(fs.unlink).toHaveBeenCalledWith(path.resolve('/root/file.txt'));
|
||||
});
|
||||
|
||||
it('handles smart revert (patching) successfully', async () => {
|
||||
const original = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `line${i + 1}`,
|
||||
).join('\n');
|
||||
// Agent changes line 2
|
||||
const agentModifiedLines = original.split('\n');
|
||||
agentModifiedLines[1] = 'line2-modified';
|
||||
const agentModified = agentModifiedLines.join('\n');
|
||||
|
||||
// User changes line 18 (far away from line 2)
|
||||
const userModifiedLines = [...agentModifiedLines];
|
||||
userModifiedLines[17] = 'line18-modified';
|
||||
const userModified = userModifiedLines.join('\n');
|
||||
|
||||
const toolMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-1',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file.txt',
|
||||
filePath: path.resolve('/root/file.txt'),
|
||||
originalContent: original,
|
||||
newContent: agentModified,
|
||||
isNewFile: false,
|
||||
diffStat: mockDiffStat,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
mockConversation.messages = [
|
||||
{ type: 'user', content: 'start', id: '1', timestamp: '1' },
|
||||
toolMsg,
|
||||
];
|
||||
vi.mocked(fs.readFile).mockResolvedValue(userModified);
|
||||
|
||||
await revertFileChanges(mockConversation, '1');
|
||||
|
||||
// Expect line 2 to be reverted to original, but line 18 to keep user modification
|
||||
const expectedLines = original.split('\n');
|
||||
expectedLines[17] = 'line18-modified';
|
||||
const expectedContent = expectedLines.join('\n');
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.resolve('/root/file.txt'),
|
||||
expectedContent,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits warning on smart revert failure', async () => {
|
||||
const original = 'line1\nline2\nline3';
|
||||
const agentModified = 'line1\nline2-modified\nline3';
|
||||
// User modification conflicts with the agent's change.
|
||||
const userModified = 'line1\nline2-usermodified\nline3';
|
||||
|
||||
const toolMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-1',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file.txt',
|
||||
filePath: path.resolve('/root/file.txt'),
|
||||
originalContent: original,
|
||||
newContent: agentModified,
|
||||
isNewFile: false,
|
||||
diffStat: mockDiffStat,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
mockConversation.messages = [
|
||||
{ type: 'user', content: 'start', id: '1', timestamp: '1' },
|
||||
toolMsg,
|
||||
];
|
||||
vi.mocked(fs.readFile).mockResolvedValue(userModified);
|
||||
|
||||
await revertFileChanges(mockConversation, '1');
|
||||
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Smart revert for file.txt failed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits error if fs.readFile fails with a generic error', async () => {
|
||||
const toolMsg: MessageRecord = {
|
||||
type: 'gemini',
|
||||
id: '2',
|
||||
timestamp: '2',
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'replace',
|
||||
id: 'tool-call-1',
|
||||
status: 'success',
|
||||
timestamp: '2',
|
||||
args: {},
|
||||
resultDisplay: {
|
||||
fileName: 'file.txt',
|
||||
filePath: path.resolve('/root/file.txt'),
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
isNewFile: false,
|
||||
diffStat: mockDiffStat,
|
||||
},
|
||||
},
|
||||
] as unknown as ToolCallRecord[],
|
||||
};
|
||||
|
||||
mockConversation.messages = [
|
||||
{ type: 'user', content: 'start', id: '1', timestamp: '1' },
|
||||
toolMsg,
|
||||
];
|
||||
// Simulate a generic file read error
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await revertFileChanges(mockConversation, '1');
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Error reading file.txt during revert: Permission denied',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
250
packages/cli/src/ui/utils/rewindFileOps.ts
Normal file
250
packages/cli/src/ui/utils/rewindFileOps.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import fs from 'node:fs/promises';
|
||||
import * as Diff from 'diff';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getFileDiffFromResultDisplay,
|
||||
computeAddedAndRemovedLines,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface FileChangeDetail {
|
||||
fileName: string;
|
||||
diff: string;
|
||||
}
|
||||
|
||||
export interface FileChangeStats {
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
fileCount: number;
|
||||
details?: FileChangeDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates file change statistics for a single turn.
|
||||
* A turn is defined as the sequence of messages starting after the given user message
|
||||
* and continuing until the next user message or the end of the conversation.
|
||||
*
|
||||
* @param conversation The full conversation record.
|
||||
* @param userMessage The starting user message for the turn.
|
||||
* @returns Statistics about lines added/removed and files touched, or null if no edits occurred.
|
||||
*/
|
||||
export function calculateTurnStats(
|
||||
conversation: ConversationRecord,
|
||||
userMessage: MessageRecord,
|
||||
): FileChangeStats | null {
|
||||
const msgIndex = conversation.messages.indexOf(userMessage);
|
||||
if (msgIndex === -1) return null;
|
||||
|
||||
let addedLines = 0;
|
||||
let removedLines = 0;
|
||||
const files = new Set<string>();
|
||||
let hasEdits = false;
|
||||
|
||||
// Look ahead until the next user message (single turn)
|
||||
for (let i = msgIndex + 1; i < conversation.messages.length; i++) {
|
||||
const msg = conversation.messages[i];
|
||||
if (msg.type === 'user') break; // Stop at next user message
|
||||
|
||||
if (msg.type === 'gemini' && msg.toolCalls) {
|
||||
for (const toolCall of msg.toolCalls) {
|
||||
const fileDiff = getFileDiffFromResultDisplay(toolCall.resultDisplay);
|
||||
if (fileDiff) {
|
||||
hasEdits = true;
|
||||
const stats = fileDiff.diffStat;
|
||||
const calculations = computeAddedAndRemovedLines(stats);
|
||||
addedLines += calculations.addedLines;
|
||||
removedLines += calculations.removedLines;
|
||||
|
||||
files.add(fileDiff.fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEdits) return null;
|
||||
|
||||
return {
|
||||
addedLines,
|
||||
removedLines,
|
||||
fileCount: files.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the cumulative file change statistics from a specific message
|
||||
* to the end of the conversation.
|
||||
*
|
||||
* @param conversation The full conversation record.
|
||||
* @param userMessage The message to start calculating impact from (exclusive).
|
||||
* @returns Aggregate statistics about lines added/removed and files touched, or null if no edits occurred.
|
||||
*/
|
||||
export function calculateRewindImpact(
|
||||
conversation: ConversationRecord,
|
||||
userMessage: MessageRecord,
|
||||
): FileChangeStats | null {
|
||||
const msgIndex = conversation.messages.indexOf(userMessage);
|
||||
if (msgIndex === -1) return null;
|
||||
|
||||
let addedLines = 0;
|
||||
let removedLines = 0;
|
||||
const files = new Set<string>();
|
||||
const details: FileChangeDetail[] = [];
|
||||
let hasEdits = false;
|
||||
|
||||
// Look ahead to the end of conversation (cumulative)
|
||||
for (let i = msgIndex + 1; i < conversation.messages.length; i++) {
|
||||
const msg = conversation.messages[i];
|
||||
// Do NOT break on user message - we want total impact
|
||||
|
||||
if (msg.type === 'gemini' && msg.toolCalls) {
|
||||
for (const toolCall of msg.toolCalls) {
|
||||
const fileDiff = getFileDiffFromResultDisplay(toolCall.resultDisplay);
|
||||
if (fileDiff) {
|
||||
hasEdits = true;
|
||||
const stats = fileDiff.diffStat;
|
||||
const calculations = computeAddedAndRemovedLines(stats);
|
||||
addedLines += calculations.addedLines;
|
||||
removedLines += calculations.removedLines;
|
||||
files.add(fileDiff.fileName);
|
||||
details.push({
|
||||
fileName: fileDiff.fileName,
|
||||
diff: fileDiff.fileDiff,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEdits) return null;
|
||||
|
||||
return {
|
||||
addedLines,
|
||||
removedLines,
|
||||
fileCount: files.size,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts file changes made by the model from the end of the conversation
|
||||
* back to a specific target message.
|
||||
*
|
||||
* It iterates backwards through the conversation history and attempts to undo
|
||||
* any file modifications. It handles cases where the user might have subsequently
|
||||
* modified the file by attempting a smart patch (using the `diff` library).
|
||||
*
|
||||
* @param conversation The full conversation record.
|
||||
* @param targetMessageId The ID of the message to revert back to. Changes *after* this message will be undone.
|
||||
*/
|
||||
export async function revertFileChanges(
|
||||
conversation: ConversationRecord,
|
||||
targetMessageId: string,
|
||||
): Promise<void> {
|
||||
const messageIndex = conversation.messages.findIndex(
|
||||
(m) => m.id === targetMessageId,
|
||||
);
|
||||
|
||||
if (messageIndex === -1) {
|
||||
debugLogger.error('Requested message to rewind to was not found ');
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate backwards from the end to the message being rewound (exclusive of the messageId itself)
|
||||
for (let i = conversation.messages.length - 1; i > messageIndex; i--) {
|
||||
const msg = conversation.messages[i];
|
||||
if (msg.type === 'gemini' && msg.toolCalls) {
|
||||
for (let j = msg.toolCalls.length - 1; j >= 0; j--) {
|
||||
const toolCall = msg.toolCalls[j];
|
||||
const fileDiff = getFileDiffFromResultDisplay(toolCall.resultDisplay);
|
||||
if (fileDiff) {
|
||||
const { filePath, fileName, newContent, originalContent, isNewFile } =
|
||||
fileDiff;
|
||||
try {
|
||||
let currentContent: string | null = null;
|
||||
try {
|
||||
currentContent = await fs.readFile(filePath, 'utf8');
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
if ('code' in error && error.code === 'ENOENT') {
|
||||
// File does not exist, which is fine in some revert scenarios.
|
||||
debugLogger.debug(
|
||||
`File ${fileName} not found during revert, proceeding as it may be a new file deletion.`,
|
||||
);
|
||||
} else {
|
||||
// Other read errors are unexpected.
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error reading ${fileName} during revert: ${error.message}`,
|
||||
e,
|
||||
);
|
||||
// Continue to next tool call
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 1. Exact Match: Safe to revert directly
|
||||
if (currentContent === newContent) {
|
||||
if (!isNewFile) {
|
||||
await fs.writeFile(filePath, originalContent ?? '');
|
||||
} else {
|
||||
// Original content was null (new file), so we delete the file
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
}
|
||||
// 2. Mismatch: Attempt Smart Revert (Patch)
|
||||
else if (currentContent !== null) {
|
||||
const originalText = originalContent ?? '';
|
||||
|
||||
// Create a patch that transforms Agent -> Original
|
||||
const undoPatch = Diff.createPatch(
|
||||
fileName,
|
||||
newContent,
|
||||
originalText,
|
||||
);
|
||||
|
||||
// Apply that patch to the Current content
|
||||
const patchedContent = Diff.applyPatch(currentContent, undoPatch);
|
||||
|
||||
if (typeof patchedContent === 'string') {
|
||||
if (patchedContent === '' && isNewFile) {
|
||||
// If the result is empty and the file didn't exist originally, delete it
|
||||
await fs.unlink(filePath);
|
||||
} else {
|
||||
await fs.writeFile(filePath, patchedContent);
|
||||
}
|
||||
} else {
|
||||
// Patch failed
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Smart revert for ${fileName} failed. The file may have been modified in a way that conflicts with the undo operation.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// File was deleted by the user, but we expected content.
|
||||
// This can happen if a file created by the agent is deleted before rewind.
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Cannot revert changes for ${fileName} because it was not found on disk. This is expected if a file created by the agent was deleted before rewind`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`An unexpected error occurred while reverting ${fileName}.`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export * from './utils/quotaErrorDetection.js';
|
||||
export * from './utils/userAccountManager.js';
|
||||
export * from './utils/googleQuotaErrors.js';
|
||||
export * from './utils/fileUtils.js';
|
||||
export * from './utils/fileDiffUtils.js';
|
||||
export * from './utils/retry.js';
|
||||
export * from './utils/shell-utils.js';
|
||||
export { PolicyDecision, ApprovalMode } from './policy/types.js';
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
toSystemInstruction,
|
||||
} from './semantic.js';
|
||||
import { sanitizeHookName } from './sanitize.js';
|
||||
import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
|
||||
|
||||
export interface BaseTelemetryEvent {
|
||||
'event.name': string;
|
||||
@@ -290,13 +291,17 @@ export class ToolCallEvent implements BaseTelemetryEvent {
|
||||
this.tool_type = 'native';
|
||||
}
|
||||
|
||||
const fileDiff = getFileDiffFromResultDisplay(
|
||||
call.response.resultDisplay,
|
||||
);
|
||||
|
||||
if (
|
||||
call.status === 'success' &&
|
||||
typeof call.response.resultDisplay === 'object' &&
|
||||
call.response.resultDisplay !== null &&
|
||||
'diffStat' in call.response.resultDisplay
|
||||
fileDiff
|
||||
) {
|
||||
const diffStat = call.response.resultDisplay.diffStat;
|
||||
const diffStat = fileDiff.diffStat;
|
||||
if (diffStat) {
|
||||
this.metadata = {
|
||||
model_added_lines: diffStat.model_added_lines,
|
||||
|
||||
104
packages/core/src/utils/fileDiffUtils.test.ts
Normal file
104
packages/core/src/utils/fileDiffUtils.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getFileDiffFromResultDisplay,
|
||||
computeAddedAndRemovedLines,
|
||||
} from './fileDiffUtils.js';
|
||||
import type { FileDiff, ToolResultDisplay } from '../tools/tools.js';
|
||||
|
||||
describe('fileDiffUtils', () => {
|
||||
describe('getFileDiffFromResultDisplay', () => {
|
||||
it('returns undefined if resultDisplay is undefined', () => {
|
||||
expect(getFileDiffFromResultDisplay(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if resultDisplay is not an object', () => {
|
||||
expect(
|
||||
getFileDiffFromResultDisplay('string' as ToolResultDisplay),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if resultDisplay missing diffStat', () => {
|
||||
const resultDisplay = {
|
||||
fileName: 'file.txt',
|
||||
};
|
||||
expect(
|
||||
getFileDiffFromResultDisplay(resultDisplay as ToolResultDisplay),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the FileDiff object if structure is valid', () => {
|
||||
const validDiffStat = {
|
||||
model_added_lines: 1,
|
||||
model_removed_lines: 2,
|
||||
user_added_lines: 3,
|
||||
user_removed_lines: 4,
|
||||
model_added_chars: 10,
|
||||
model_removed_chars: 20,
|
||||
user_added_chars: 30,
|
||||
user_removed_chars: 40,
|
||||
};
|
||||
const resultDisplay = {
|
||||
fileName: 'file.txt',
|
||||
diffStat: validDiffStat,
|
||||
};
|
||||
|
||||
const result = getFileDiffFromResultDisplay(
|
||||
resultDisplay as ToolResultDisplay,
|
||||
);
|
||||
expect(result).toBe(resultDisplay);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAddedAndRemovedLines', () => {
|
||||
it('returns 0 added and 0 removed if stats is undefined', () => {
|
||||
expect(computeAddedAndRemovedLines(undefined)).toEqual({
|
||||
addedLines: 0,
|
||||
removedLines: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('correctly sums added and removed lines from stats', () => {
|
||||
const stats: FileDiff['diffStat'] = {
|
||||
model_added_lines: 10,
|
||||
model_removed_lines: 5,
|
||||
user_added_lines: 2,
|
||||
user_removed_lines: 1,
|
||||
model_added_chars: 100,
|
||||
model_removed_chars: 50,
|
||||
user_added_chars: 20,
|
||||
user_removed_chars: 10,
|
||||
};
|
||||
|
||||
const result = computeAddedAndRemovedLines(stats);
|
||||
expect(result).toEqual({
|
||||
addedLines: 12, // 10 + 2
|
||||
removedLines: 6, // 5 + 1
|
||||
});
|
||||
});
|
||||
|
||||
it('handles zero values correctly', () => {
|
||||
const stats: FileDiff['diffStat'] = {
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
user_added_lines: 0,
|
||||
user_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
};
|
||||
|
||||
const result = computeAddedAndRemovedLines(stats);
|
||||
expect(result).toEqual({
|
||||
addedLines: 0,
|
||||
removedLines: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/core/src/utils/fileDiffUtils.ts
Normal file
50
packages/core/src/utils/fileDiffUtils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FileDiff } from '../tools/tools.js';
|
||||
import type { ToolCallRecord } from '../services/chatRecordingService.js';
|
||||
|
||||
/**
|
||||
* Safely extracts the FileDiff object from a tool call's resultDisplay.
|
||||
* This helper performs runtime checks to ensure the object conforms to the FileDiff structure.
|
||||
* @param resultDisplay The resultDisplay property of a ToolCallRecord.
|
||||
* @returns The FileDiff object if found and valid, otherwise undefined.
|
||||
*/
|
||||
export function getFileDiffFromResultDisplay(
|
||||
resultDisplay: ToolCallRecord['resultDisplay'],
|
||||
): FileDiff | undefined {
|
||||
if (
|
||||
resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'diffStat' in resultDisplay &&
|
||||
typeof resultDisplay.diffStat === 'object' &&
|
||||
resultDisplay.diffStat !== null
|
||||
) {
|
||||
const diffStat = resultDisplay.diffStat as FileDiff['diffStat'];
|
||||
if (diffStat) {
|
||||
return resultDisplay;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function computeAddedAndRemovedLines(
|
||||
stats: FileDiff['diffStat'] | undefined,
|
||||
): {
|
||||
addedLines: number;
|
||||
removedLines: number;
|
||||
} {
|
||||
if (!stats) {
|
||||
return {
|
||||
addedLines: 0,
|
||||
removedLines: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
addedLines: stats.model_added_lines + stats.user_added_lines,
|
||||
removedLines: stats.model_removed_lines + stats.user_removed_lines,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user