Fix dollar sign replacement bug in file editing (#7703)

This commit is contained in:
fuyou
2025-09-05 10:12:57 +08:00
committed by GitHub
parent aea6230bcc
commit 0bc0d23cb3
6 changed files with 68 additions and 23 deletions

View File

@@ -61,4 +61,34 @@ describe('replace', () => {
console.log('File replaced successfully. New content:', newFileContent);
}
});
it('should handle $ literally when replacing text ending with $', async () => {
const rig = new TestRig();
await rig.setup(
'should handle $ literally when replacing text ending with $',
);
const fileName = 'regex.yml';
const originalContent = "| select('match', '^[sv]d[a-z]$')\n";
const expectedContent = "| select('match', '^[sv]d[a-z]$') # updated\n";
rig.createFile(fileName, originalContent);
const prompt =
"Open regex.yml and append ' # updated' after the line containing ^[sv]d[a-z]$ without breaking the $ character.";
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('replace');
if (!foundToolCall) {
printDebugInfo(rig, result);
}
expect(foundToolCall, 'Expected to find a replace tool call').toBeTruthy();
validateModelOutput(result, ['regex.yml'], 'Replace $ literal test');
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe(expectedContent);
});
});

View File

@@ -509,7 +509,8 @@ export class Task {
if (oldString === '' && !isNewFile) {
return currentContent;
}
return currentContent.replaceAll(oldString, newString);
// Use split/join to ensure replacement
return currentContent.split(oldString).join(newString);
}
async scheduleToolCalls(

View File

@@ -198,6 +198,22 @@ describe('EditTool', () => {
'hello world',
);
});
it('should treat $ literally and not as replacement pattern', () => {
const current = "price is $100 and pattern end is ' '";
const oldStr = 'price is $100';
const newStr = 'price is $200';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("price is $200 and pattern end is ' '");
});
it("should treat $' literally and not as a replacement pattern", () => {
const current = 'foo';
const oldStr = 'foo';
const newStr = "bar$'baz";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("bar$'baz");
});
});
describe('validateToolParams', () => {

View File

@@ -51,7 +51,8 @@ export function applyReplacement(
if (oldString === '' && !isNewFile) {
return currentContent;
}
return currentContent.replaceAll(oldString, newString);
// Use split/join to ensure replacement
return currentContent.split(oldString).join(newString);
}
/**

View File

@@ -46,11 +46,11 @@ import {
type Mock,
} from 'vitest';
import {
applyReplacement,
SmartEditTool,
type EditToolParams,
calculateReplacement,
} from './smart-edit.js';
import { applyReplacement } from './edit.js';
import { type FileDiff, ToolConfirmationOutcome } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import path from 'node:path';
@@ -169,6 +169,22 @@ describe('SmartEditTool', () => {
'hello new world new',
);
});
it('should treat $ literally and not as replacement pattern', () => {
const current = 'regex end is $ and more';
const oldStr = 'regex end is $';
const newStr = 'regex end is $ and correct';
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe('regex end is $ and correct and more');
});
it("should treat $' literally and not as a replacement pattern", () => {
const current = 'foo';
const oldStr = 'foo';
const newStr = "bar$'baz";
const result = applyReplacement(current, oldStr, newStr, false);
expect(result).toBe("bar$'baz");
});
});
describe('calculateReplacement', () => {

View File

@@ -30,26 +30,7 @@ import {
} from './modifiable-tool.js';
import { IdeClient, IDEConnectionStatus } from '../ide/ide-client.js';
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
export function applyReplacement(
currentContent: string | null,
oldString: string,
newString: string,
isNewFile: boolean,
): string {
if (isNewFile) {
return newString;
}
if (currentContent === null) {
// Should not happen if not a new file, but defensively return empty or newString if oldString is also empty
return oldString === '' ? newString : '';
}
// If oldString is empty and it's not a new file, do not modify the content.
if (oldString === '' && !isNewFile) {
return currentContent;
}
return currentContent.replaceAll(oldString, newString);
}
import { applyReplacement } from './edit.js';
interface ReplacementContext {
params: EditToolParams;