Add apply_patch for atomic multi-file edits

One envelope carrying Add, Update, Move, and Delete file markers, validated in full before anything is written: a failure on the fourth file leaves the first three untouched. Permission rules match every path a patch touches, and the write guard and secret checks cover it alongside the other write tools.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-03 16:41:44 +07:00
co-authored by Sisyphus
parent 87219350f9
commit 9e03512cb0
5 changed files with 586 additions and 12 deletions
+133 -1
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
applyPatchTool,
bashTool,
editFileTool,
globTool,
@@ -12,6 +13,7 @@ import {
listDirTool,
multiEditTool,
onBashOutput,
parsePatch,
readFileTool,
readManyFilesTool,
writeFileTool,
@@ -229,6 +231,134 @@ test('list_dir scopes to a subdirectory and refuses a file', async () => {
expect(run(listDirTool, { path: 'other.ts' })).rejects.toThrow(/Not a directory/);
});
const PATCH_ADD = `*** Add File: src/new.ts
+export const a = 1;
+export const b = 2;`;
test('parsePatch reads add, update, move, and delete markers', () => {
const ops = parsePatch(`${PATCH_ADD}
*** Update File: src/old.ts
*** Move to: src/renamed.ts
-const port = 8080;
+const port = 9090;
*** Delete File: src/gone.ts`);
expect(ops).toEqual([
{ kind: 'add', path: 'src/new.ts', content: 'export const a = 1;\nexport const b = 2;' },
{
kind: 'update',
path: 'src/old.ts',
moveTo: 'src/renamed.ts',
oldString: 'const port = 8080;',
newString: 'const port = 9090;',
},
{ kind: 'delete', path: 'src/gone.ts' },
]);
});
test('parsePatch rejects a malformed envelope rather than guessing', () => {
expect(() => parsePatch('')).toThrow(/empty/);
expect(() => parsePatch('just some text')).toThrow(/not a marker/);
expect(() => parsePatch('*** Update File: a.ts\n+only additions')).toThrow(/at least one - line/);
expect(() => parsePatch('*** Add File: a.ts\n-removing')).toThrow(/cannot remove lines/);
expect(() => parsePatch('*** Add File: a.ts\n*** Move to: b.ts\n+x')).toThrow(/only valid on an Update/);
expect(() => parsePatch('*** Update File: a.ts\nno prefix here')).toThrow(/neither \+ nor -/);
});
test('apply_patch adds, updates, moves, and deletes in one call', async () => {
await Bun.write(join(dir, 'old.ts'), 'const port = 8080;\n');
await Bun.write(join(dir, 'gone.ts'), 'obsolete\n');
await Bun.write(join(dir, 'moving.ts'), 'const name = "a";\n');
const out = await run(applyPatchTool, {
patch: `*** Add File: fresh.ts
+export const fresh = true;
*** Update File: old.ts
-const port = 8080;
+const port = 9090;
*** Update File: moving.ts
*** Move to: moved.ts
-const name = "a";
+const name = "b";
*** Delete File: gone.ts`,
});
expect(out).toContain('4 changes');
expect(await Bun.file(join(dir, 'fresh.ts')).text()).toBe('export const fresh = true;\n');
expect(await Bun.file(join(dir, 'old.ts')).text()).toBe('const port = 9090;\n');
expect(await Bun.file(join(dir, 'moved.ts')).text()).toBe('const name = "b";\n');
expect(await Bun.file(join(dir, 'moving.ts')).exists()).toBe(false);
expect(await Bun.file(join(dir, 'gone.ts')).exists()).toBe(false);
});
test('a patch that fails on its third file writes nothing at all', async () => {
await Bun.write(join(dir, 'one.ts'), 'const a = 1;\n');
await Bun.write(join(dir, 'two.ts'), 'const b = 2;\n');
expect(
run(applyPatchTool, {
patch: `*** Update File: one.ts
-const a = 1;
+const a = 10;
*** Update File: two.ts
-const b = 2;
+const b = 20;
*** Update File: three.ts
-const c = 3;
+const c = 30;`,
}),
).rejects.toThrow(/no such file/);
await Bun.sleep(20);
// Atomicity is the whole reason this tool exists rather than three edit_file
// calls, so the first two files must be untouched.
expect(await Bun.file(join(dir, 'one.ts')).text()).toBe('const a = 1;\n');
expect(await Bun.file(join(dir, 'two.ts')).text()).toBe('const b = 2;\n');
});
test('apply_patch refuses an ambiguous match without writing', async () => {
await Bun.write(join(dir, 'dup.ts'), 'x\nx\n');
expect(run(applyPatchTool, { patch: '*** Update File: dup.ts\n-x\n+y' })).rejects.toThrow(/appear 2 times/);
await Bun.sleep(20);
expect(await Bun.file(join(dir, 'dup.ts')).text()).toBe('x\nx\n');
});
test('apply_patch refuses to add over an existing file', async () => {
await Bun.write(join(dir, 'here.ts'), 'original\n');
expect(run(applyPatchTool, { patch: '*** Add File: here.ts\n+replacement' })).rejects.toThrow(/already exists/);
await Bun.sleep(20);
expect(await Bun.file(join(dir, 'here.ts')).text()).toBe('original\n');
});
test('apply_patch refuses to move onto an existing file', async () => {
await Bun.write(join(dir, 'from.ts'), 'const a = 1;\n');
await Bun.write(join(dir, 'to.ts'), 'occupied\n');
expect(
run(applyPatchTool, { patch: '*** Update File: from.ts\n*** Move to: to.ts\n-const a = 1;\n+const a = 2;' }),
).rejects.toThrow(/already exists/);
await Bun.sleep(20);
expect(await Bun.file(join(dir, 'from.ts')).text()).toBe('const a = 1;\n');
expect(await Bun.file(join(dir, 'to.ts')).text()).toBe('occupied\n');
});
test('apply_patch refuses the same file twice in one patch', async () => {
await Bun.write(join(dir, 'twice.ts'), 'a\nb\n');
expect(
run(applyPatchTool, { patch: '*** Update File: twice.ts\n-a\n+A\n*** Update File: twice.ts\n-b\n+B' }),
).rejects.toThrow(/appears twice/);
});
test('apply_patch refuses a path outside the workspace', async () => {
expect(run(applyPatchTool, { patch: '*** Add File: ../escape.ts\n+x' })).rejects.toThrow(/escapes workspace/);
});
test('an update that removes lines and adds none deletes them', async () => {
await Bun.write(join(dir, 'trim.ts'), 'keep\nremove me\nkeep too\n');
await run(applyPatchTool, { patch: '*** Update File: trim.ts\n-remove me\n' });
expect(await Bun.file(join(dir, 'trim.ts')).text()).toBe('keep\n\nkeep too\n');
});
test('glob skips gitignored paths and honours includeIgnored', async () => {
await Bun.write(join(dir, '.gitignore'), 'dist/\n');
await Bun.write(join(dir, 'dist/app.js'), 'x');
@@ -293,7 +423,9 @@ test('bash streams output to the listener before the command exits', async () =>
const script =
process.platform === 'win32'
? 'echo first && ping -n 2 127.0.0.1 > nul && echo second'
? // Unconditional sequencing: a blocked loopback makes `ping` exit 1, and `&&`
// would then skip `echo second` on machines where ICMP is filtered.
'echo first & ping -n 2 127.0.0.1 > nul & echo second'
: 'echo first; sleep 0.4; echo second';
await run(bashTool, { command: script });