Skip to content

Commit 9f55a70

Browse files
Merge pull request #535 from corbitsdev/cl-6900-edit_file-filler-arg-rejections-cause-identical-retry-loops
Treat edit_file filler args as absent for mode selection
2 parents 2255072 + c285dbe commit 9f55a70

2 files changed

Lines changed: 101 additions & 19 deletions

File tree

src/plugins/edit-file-line-range.test.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,65 @@ describe("parseEditFileMode", () => {
5252
expect(mode.message).toContain("only one edit mode is allowed");
5353
expect(mode.message).toContain("Omit old_string");
5454
expect(mode.message).toContain("omit start_line/end_line");
55+
expect(mode.message).toContain("old_string (len 1)");
56+
expect(mode.message).toContain("start_line=1");
57+
expect(mode.message).toContain("end_line=1");
58+
}
59+
});
60+
61+
test("treats filler start_line/end_line of 0 as absent and picks substring mode", () => {
62+
const mode = parseEditFileMode({
63+
path: "a.ts",
64+
old_string: "x",
65+
new_string: "y",
66+
start_line: 0,
67+
end_line: 0,
68+
});
69+
expect(mode.kind).toBe("substring");
70+
});
71+
72+
test("treats null line fields as absent and picks substring mode", () => {
73+
const mode = parseEditFileMode({
74+
path: "a.ts",
75+
old_string: "x",
76+
new_string: "y",
77+
start_line: null,
78+
end_line: null,
79+
});
80+
expect(mode.kind).toBe("substring");
81+
});
82+
83+
test("treats filler empty old_string as absent and picks line-range mode", () => {
84+
const mode = parseEditFileMode({
85+
path: "a.ts",
86+
old_string: "",
87+
start_line: 2,
88+
end_line: 3,
89+
new_string: "z",
90+
});
91+
expect(mode.kind).toBe("line_range");
92+
});
93+
94+
test("empty old_string alone gets a helpful error naming what was received", () => {
95+
const mode = parseEditFileMode({
96+
path: "a.ts",
97+
old_string: "",
98+
new_string: "y",
99+
});
100+
expect(mode.kind).toBe("invalid");
101+
if (mode.kind === "invalid") {
102+
expect(mode.message).toContain("old_string is empty");
103+
expect(mode.message).toContain("old_string (len 0)");
104+
}
105+
});
106+
107+
test("missing both modes reports what was received", () => {
108+
const mode = parseEditFileMode({ path: "a.ts", new_string: "y" });
109+
expect(mode.kind).toBe("invalid");
110+
if (mode.kind === "invalid") {
111+
expect(mode.message).toContain("requires old_string");
112+
expect(mode.message).toContain("no old_string");
113+
expect(mode.message).toContain("no start_line");
55114
}
56115
});
57116

@@ -166,11 +225,9 @@ describe("editFileLineRangePlugin", () => {
166225
});
167226

168227
function handler(next: (call: ToolCall, signal: AbortSignal) => Promise<ToolResult>) {
169-
const mws = [
170-
pathEscapePlugin(cwd).middleware!,
171-
editFileLineRangePlugin().middleware!,
172-
verifyPlugin().middleware!,
173-
];
228+
const mws = [pathEscapePlugin(cwd), editFileLineRangePlugin(), verifyPlugin()].flatMap((p) =>
229+
p.middleware ? [p.middleware] : [],
230+
);
174231
return composeMiddleware(mws, next);
175232
}
176233

src/plugins/edit-file-line-range.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,42 @@ function optionalInt(value: unknown): number | undefined {
2727
: undefined;
2828
}
2929

30+
// Models pad the unused mode's fields with fillers (old_string: "", start_line: 0).
31+
// For mode selection a filler counts as absent: "" is never a valid old_string and
32+
// 0/null/non-integers are never valid 1-based lines. Treating them as present made
33+
// filler-padded calls look like "both edit modes" and rejected them (CL-6900).
3034
function hasOldStringArg(args: Record<string, unknown>): boolean {
31-
return typeof args.old_string === "string";
35+
return typeof args.old_string === "string" && args.old_string.length > 0;
36+
}
37+
38+
function validLineNumber(value: unknown): number | undefined {
39+
const n = optionalInt(value);
40+
return n !== undefined && n >= 1 ? n : undefined;
3241
}
3342

3443
function hasLineRangeArgs(args: Record<string, unknown>): boolean {
35-
return optionalInt(args.start_line) !== undefined || optionalInt(args.end_line) !== undefined;
44+
return (
45+
validLineNumber(args.start_line) !== undefined || validLineNumber(args.end_line) !== undefined
46+
);
47+
}
48+
49+
function describeReceived(args: Record<string, unknown>): string {
50+
const old = args.old_string;
51+
return [
52+
typeof old === "string" ? `old_string (len ${old.length})` : "no old_string",
53+
args.start_line === undefined
54+
? "no start_line"
55+
: `start_line=${JSON.stringify(args.start_line)}`,
56+
args.end_line === undefined ? "no end_line" : `end_line=${JSON.stringify(args.end_line)}`,
57+
].join(", ");
3658
}
3759

38-
const MIXED_MODE_MESSAGE =
39-
"edit_file: received both old_string and start_line/end_line; only one edit mode is allowed. " +
40-
"Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode.";
60+
function mixedModeMessage(args: Record<string, unknown>): string {
61+
return (
62+
`edit_file: received ${describeReceived(args)}; only one edit mode is allowed. ` +
63+
"Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode."
64+
);
65+
}
4166

4267
export function parseLineRangeFields(
4368
path: string,
@@ -88,30 +113,30 @@ export function parseEditFileMode(args: Record<string, unknown>): EditFileModePa
88113
// slice, producing a confusing "old_string does not match" error even when the caller
89114
// meant plain substring mode (CL-4399). One explicit error beats a wrong guess.
90115
if (substring && lineRange) {
91-
return { kind: "invalid", message: MIXED_MODE_MESSAGE };
116+
return { kind: "invalid", message: mixedModeMessage(args) };
92117
}
93118

94119
if (lineRange) {
95120
return parseLineRangeFields(path, new_string, args);
96121
}
97122

98123
if (!substring) {
124+
const emptyOldString = typeof args.old_string === "string";
99125
return {
100126
kind: "invalid",
101-
message:
102-
"edit_file requires old_string (substring mode) or start_line and end_line (line-range mode)",
127+
message: emptyOldString
128+
? "edit_file: old_string is empty; provide the exact text to replace (substring mode), " +
129+
"or omit it and send start_line/end_line >= 1 (line-range mode). " +
130+
`Received ${describeReceived(args)}.`
131+
: "edit_file requires old_string (substring mode) or start_line and end_line (line-range mode); " +
132+
`received ${describeReceived(args)}`,
103133
};
104134
}
105135

106-
const old_string = String(args.old_string);
107-
if (old_string.length === 0) {
108-
return { kind: "invalid", message: "old_string must not be empty" };
109-
}
110-
111136
return {
112137
kind: "substring",
113138
path,
114-
old_string,
139+
old_string: String(args.old_string),
115140
new_string,
116141
replace_all: Boolean(args.replace_all),
117142
};

0 commit comments

Comments
 (0)