Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions packages/runtime/src/tools/built-in/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()) {
Expand Down
59 changes: 59 additions & 0 deletions packages/runtime/tests/tools/built-in/apply-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down