fix: use old_text/new_text in edit_files tool schema (#25658)

Models frequently confuse the search and replace fields in the
edit_files tool (CODAGT-312). Rename the model-facing JSON fields
to old_text/new_text so the intent is unambiguous.

Backend: custom UnmarshalJSON on editFileEdit falls back to
deprecated search/replace when old_text/new_text are empty. The
workspace agent API is unchanged; toSDKFiles maps old_text/new_text
back to search/replace for agent/agentfiles.

Frontend: normalizeEdit in parseEditFilesArgs accepts both
old_text/new_text and search/replace, normalizing to the internal
{ search, replace } representation so streaming diff rendering
works with either field naming convention.
This commit is contained in:
Mathias Fredriksson
2026-05-26 11:11:47 +03:00
committed by GitHub
parent c801dcbbc8
commit 32ed9f1f39
4 changed files with 297 additions and 19 deletions
@@ -763,6 +763,57 @@ describe("parseEditFilesArgs", () => {
expect(parsed[0].edits[0].replace).toBe("");
});
it("accepts old_text/new_text field names", () => {
const args = {
files: [
{
path: "a.ts",
edits: [{ old_text: "before", new_text: "after" }],
},
],
};
const result = parseEditFilesArgs(args);
expect(result).toHaveLength(1);
expect(result[0].edits).toHaveLength(1);
expect(result[0].edits[0]).toEqual({ search: "before", replace: "after" });
});
it("prefers old_text/new_text over search/replace when both present", () => {
const args = {
files: [
{
path: "a.ts",
edits: [
{
old_text: "from-old-text",
new_text: "from-new-text",
search: "from-search",
replace: "from-replace",
},
],
},
],
};
const result = parseEditFilesArgs(args);
expect(result[0].edits[0]).toEqual({
search: "from-old-text",
replace: "from-new-text",
});
});
it("preserves deletion via old_text/new_text (empty new_text)", () => {
const args = {
files: [
{
path: "a.ts",
edits: [{ old_text: "remove me", new_text: "" }],
},
],
};
const result = parseEditFilesArgs(args);
expect(result[0].edits[0]).toEqual({ search: "remove me", replace: "" });
});
// During streaming the model may emit a file entry before any
// edit is complete. Every edit has a missing replace, so all are
// filtered out. The file entry survives with an empty edits
@@ -12,12 +12,29 @@ export interface EditFilesFileEntry {
edits: Array<{ search: string; replace: string }>;
}
const searchReplaceSchema = Yup.object({
search: Yup.string().required(),
replace: Yup.string().defined(),
}).required();
type SearchReplace = Yup.InferType<typeof searchReplaceSchema>;
// Validates that the edit has at least the shape of an object with
// string-typed text fields. Accepts both current field names
// (old_text/new_text) and deprecated names (search/replace).
const normalizeEdit = (
e: unknown,
): { search: string; replace: string } | null => {
if (typeof e !== "object" || e === null) return null;
const raw = e as Record<string, unknown>;
const search =
typeof raw.old_text === "string"
? raw.old_text
: typeof raw.search === "string"
? raw.search
: null;
const replace =
typeof raw.new_text === "string"
? raw.new_text
: typeof raw.replace === "string"
? raw.replace
: null;
if (!search || replace === null) return null;
return { search, replace };
};
const fileEntrySchema = Yup.object({
path: Yup.string().required(),
@@ -688,15 +705,15 @@ export const parseEditFilesArgs = (args: unknown): EditFilesFileEntry[] => {
.filter((f): f is FileEntry => isValid(fileEntrySchema, f))
.map((f) => ({
path: f.path,
edits: f.edits.filter((e): e is SearchReplace =>
isValid(searchReplaceSchema, e),
),
edits: f.edits
.map(normalizeEdit)
.filter((e): e is { search: string; replace: string } => e !== null),
}));
};
/**
* Builds a synthetic unified diff from search/replace edit pairs
* for a single file. Each edit becomes a separate
* Builds a synthetic unified diff from edit pairs (normalized to
* search/replace) for a single file. Each edit becomes a separate
* `Diff.createPatch` call; the patches are concatenated and
* parsed into a single FileDiffMetadata.
*/