diff --git a/packages/agent-tools/src/shared/replace-all-edit.test.ts b/packages/agent-tools/src/shared/replace-all-edit.test.ts new file mode 100644 index 000000000..1f466b899 --- /dev/null +++ b/packages/agent-tools/src/shared/replace-all-edit.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalEditTool } from '../desktop/local-pi-tools.js'; +import { buildReplaceAllEdit } from './replace-all-edit.js'; + +const replacements = ['$$', '$&', '$`', "$'"]; + +describe('literal edit replacement text', () => { + let directory: string; + let file: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'literal-edit-')); + file = join(directory, 'example.txt'); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it.each(replacements)( + 'builds a literal full-file replacement for %s', + async (replacement) => { + const original = 'before TOKEN after\nTOKEN\n'; + await writeFile(file, original); + + const result = await buildReplaceAllEdit(file, 'TOKEN', replacement); + + expect(result).toEqual({ + path: file, + edits: [ + { + oldText: original, + newText: `before ${replacement} after\n${replacement}\n`, + }, + ], + replacedCount: 2, + }); + expect(await readFile(file, 'utf8')).toBe(original); + }, + ); + + describe.each([true, false])('LocalEditTool with replace_all=%s', (replaceAll) => { + it.each(replacements)('writes %s literally to disk', async (replacement) => { + await writeFile( + file, + replaceAll ? 'before TOKEN after\nTOKEN\n' : 'before TOKEN after\n', + ); + + const result = await new LocalEditTool(directory).execute( + { sessionId: 'literal-edit-session', turnId: 'literal-edit-turn' }, + { + file_path: 'example.txt', + old_string: 'TOKEN', + new_string: replacement, + replace_all: replaceAll, + }, + ); + + expect(await readFile(file, 'utf8')).toBe( + replaceAll + ? `before ${replacement} after\n${replacement}\n` + : `before ${replacement} after\n`, + ); + expect(result.text).toContain('Successfully replaced'); + }); + }); +}); diff --git a/packages/agent-tools/src/shared/replace-all-edit.ts b/packages/agent-tools/src/shared/replace-all-edit.ts index aa9b9ecc8..ba786e950 100644 --- a/packages/agent-tools/src/shared/replace-all-edit.ts +++ b/packages/agent-tools/src/shared/replace-all-edit.ts @@ -93,7 +93,8 @@ export async function buildReplaceAllEdit( */ function rawStringReplaceAll(haystack: string, needle: string, replacement: string): string { if (typeof haystack.replaceAll === 'function') { - return haystack.replaceAll(needle, replacement); + // A callback keeps replacement text literal instead of expanding $ sequences. + return haystack.replaceAll(needle, () => replacement); } return haystack.split(needle).join(replacement); } diff --git a/release/public-source.json b/release/public-source.json index b9c1ff4f2..c2e200c6b 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -381,6 +381,7 @@ "packages/agent-tools/src/shared/read-postprocess.ts", "packages/agent-tools/src/shared/read-result.ts", "packages/agent-tools/src/shared/read-video.ts", + "packages/agent-tools/src/shared/replace-all-edit.test.ts", "packages/agent-tools/src/shared/replace-all-edit.ts", "packages/agent-tools/src/shared/rg-scan-policy.ts", "packages/agent-tools/src/shared/rg-sensitive.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 3568d3578..d8f10b1cf 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -2,6 +2,7 @@ "comment": "Vitest suites for the standalone distribution, grouped by the gate that runs them. `vitest.oss.config.mjs` includes every group; `scripts/run-vitest-suite.mjs ` runs one. Add a test file here rather than in package.json or the Vitest config.", "suites": { "capability": [ + "packages/agent-tools/src/shared/replace-all-edit.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/tui/test/unit/auth-application.test.ts", "packages/tui/test/unit/business-telemetry-privacy.test.ts",