diff --git a/packages/runtime/src/tools/built-in/apply-patch.ts b/packages/runtime/src/tools/built-in/apply-patch.ts index df22d5cd..ae3b64e2 100644 --- a/packages/runtime/src/tools/built-in/apply-patch.ts +++ b/packages/runtime/src/tools/built-in/apply-patch.ts @@ -181,7 +181,8 @@ function _parsePatch(patchText: string): FilePatch[] { isEndOfFile = true; break; } - const marker = line[0]; + // Like Codex, read a bare empty line as an empty context line. + const marker = line === "" ? " " : line[0]; const content = line.slice(1); if (marker === " ") { oldLines.push(content); @@ -243,19 +244,25 @@ function _applyHunks( replacements.push([lines.length, 0, hunk.newLines]); continue; } - const position = _findSequence( - lines, - hunk.oldLines, - lineIndex, - hunk.isEndOfFile - ); + let oldLines = hunk.oldLines; + let newLines = hunk.newLines; + let position = _findSequence(lines, oldLines, lineIndex, hunk.isEndOfFile); + if (position < 0 && oldLines.at(-1) === "") { + // A trailing empty context line stands for the final newline, which the + // split above does not keep as a line. Retry without it, as Codex does. + oldLines = oldLines.slice(0, -1); + if (newLines.at(-1) === "") { + newLines = newLines.slice(0, -1); + } + position = _findSequence(lines, oldLines, lineIndex, hunk.isEndOfFile); + } if (position < 0) { throw new Error( `Failed to find expected lines in ${filePath}:\n${hunk.oldLines.join("\n")}` ); } - replacements.push([position, hunk.oldLines.length, hunk.newLines]); - lineIndex = position + hunk.oldLines.length; + replacements.push([position, oldLines.length, newLines]); + lineIndex = position + oldLines.length; } for (const [position, oldLength, newLines] of replacements.reverse()) { diff --git a/packages/runtime/tests/tools/built-in/apply-patch.test.ts b/packages/runtime/tests/tools/built-in/apply-patch.test.ts index 42bbf641..1f1d26df 100644 --- a/packages/runtime/tests/tools/built-in/apply-patch.test.ts +++ b/packages/runtime/tests/tools/built-in/apply-patch.test.ts @@ -146,6 +146,65 @@ describe("apply_patch built-in", () => { ); }); + test("treats bare blank lines in update hunks as empty context", async () => { + const workspace = await createWorkspace(); + await fs.writeFile( + path.join(workspace, "main.py"), + "import os\n\ndef main():\n return 1\n" + ); + await fs.writeFile(path.join(workspace, "other.txt"), "beta\n"); + + // Models and editors often drop the leading space from blank context lines + // and separate file sections with an empty line. Codex accepts both. + await applyPatch( + [ + "*** Begin Patch", + "*** Update File: main.py", + "@@", + " import os", + "", + " def main():", + "- return 1", + "+ return 2", + "", + "*** Update File: other.txt", + "@@", + "-beta", + "+BETA", + "*** End Patch", + ].join("\n"), + workspace + ); + + expect(await fs.readFile(path.join(workspace, "main.py"), "utf8")).toBe( + "import os\n\ndef main():\n return 2\n" + ); + expect(await fs.readFile(path.join(workspace, "other.txt"), "utf8")).toBe( + "BETA\n" + ); + }); + + test("still rejects non-blank hunk lines without a diff marker", async () => { + const workspace = await createWorkspace(); + const filePath = path.join(workspace, "example.txt"); + await fs.writeFile(filePath, "one\ntwo\n"); + + expect( + applyPatch( + [ + "*** Begin Patch", + "*** Update File: example.txt", + "@@", + " one", + "two", + "*** End Patch", + ].join("\n"), + workspace + ) + ).rejects.toThrow("Invalid hunk line"); + expect(await fs.readFile(filePath, "utf8")).toBe("one\ntwo\n"); + }); + test("appends insertion-only hunks and normalizes a final newline", async () => { const workspace = await createWorkspace(); const filePath = path.join(workspace, "example.txt");