@kenkaiiii/ggcoder 4.3.176 → 4.3.178
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/system-prompt.d.ts.map +1 -1
- package/dist/system-prompt.js +3 -1
- package/dist/system-prompt.js.map +1 -1
- package/dist/tools/edit-diff.d.ts +50 -0
- package/dist/tools/edit-diff.d.ts.map +1 -1
- package/dist/tools/edit-diff.js +170 -0
- package/dist/tools/edit-diff.js.map +1 -1
- package/dist/tools/edit-diff.test.js +124 -1
- package/dist/tools/edit-diff.test.js.map +1 -1
- package/dist/tools/edit.d.ts +1 -0
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +154 -52
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/edit.test.js +252 -3
- package/dist/tools/edit.test.js.map +1 -1
- package/dist/tools/prompt-hints.js +2 -2
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +10 -0
- package/dist/ui/App.js.map +1 -1
- package/package.json +4 -4
package/dist/tools/edit.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { resolvePath, rejectSymlink } from "./path-utils.js";
|
|
4
|
-
import { fuzzyFindText, countOccurrences, generateDiff, findClosestSnippet, findOccurrenceLines, } from "./edit-diff.js";
|
|
4
|
+
import { fuzzyFindText, countOccurrences, generateDiff, findClosestSnippet, findOccurrenceLines, stripLeadingBlankLine, applyDotdotdots, applyMissingLeadingWhitespace, } from "./edit-diff.js";
|
|
5
5
|
import { localOperations } from "./operations.js";
|
|
6
6
|
import { assertFresh, recordWrite } from "./read-tracker.js";
|
|
7
7
|
const EditItem = z.object({
|
|
@@ -31,20 +31,44 @@ const EditParams = z.object({
|
|
|
31
31
|
file_path: z.string().describe("The file path to edit"),
|
|
32
32
|
edits: z
|
|
33
33
|
.preprocess(coerceStringifiedEdits, z.array(EditItem).min(1))
|
|
34
|
-
.describe("One or more edits applied in order. Each edit operates on the result of the previous one.
|
|
35
|
-
|
|
34
|
+
.describe("One or more edits applied in order. Each edit operates on the result of the previous one."),
|
|
35
|
+
atomic: z
|
|
36
|
+
.boolean()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("If true, fail the whole batch when any edit fails — no changes written. " +
|
|
39
|
+
"Default false: partial-apply, keep every successful edit and report failures " +
|
|
40
|
+
"for retry. Use atomic only when later edits depend on earlier ones in a way " +
|
|
41
|
+
"where a half-applied state would be worse than nothing."),
|
|
36
42
|
});
|
|
43
|
+
function tryMatch(working, old, next, replaceAll) {
|
|
44
|
+
if (old.length === 0)
|
|
45
|
+
return { ok: false, reason: "not_found" };
|
|
46
|
+
if (replaceAll && working.includes(old)) {
|
|
47
|
+
return { ok: true, newWorking: working.split(old).join(next) };
|
|
48
|
+
}
|
|
49
|
+
const occurrences = countOccurrences(working, old);
|
|
50
|
+
if (occurrences === 0)
|
|
51
|
+
return { ok: false, reason: "not_found" };
|
|
52
|
+
if (occurrences > 1)
|
|
53
|
+
return { ok: false, reason: "ambiguous", occurrences };
|
|
54
|
+
const match = fuzzyFindText(working, old);
|
|
55
|
+
if (!match.found)
|
|
56
|
+
return { ok: false, reason: "not_found" };
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
newWorking: working.slice(0, match.index) + next + working.slice(match.index + match.matchLength),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
37
62
|
export function createEditTool(cwd, readFiles, ops = localOperations, planModeRef) {
|
|
38
63
|
return {
|
|
39
64
|
name: "edit",
|
|
40
|
-
description: "Replace
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"swap every occurrence (useful for renames). Returns a unified diff of the combined change.",
|
|
65
|
+
description: "Replace text in a file via { old_text, new_text } edits applied sequentially. Read the file first. " +
|
|
66
|
+
"Each old_text must uniquely match exactly one location — set replace_all: true to swap every occurrence (renames). " +
|
|
67
|
+
"For long blocks, a line containing only `...` in BOTH old_text and new_text elides a middle preserved verbatim. " +
|
|
68
|
+
"Partial-apply by default: failed edits are listed for retry, successful ones are still written — " +
|
|
69
|
+
"re-issue ONLY the listed failures, not the whole batch. Returns a unified diff.",
|
|
46
70
|
parameters: EditParams,
|
|
47
|
-
async execute({ file_path, edits }) {
|
|
71
|
+
async execute({ file_path, edits, atomic = false }) {
|
|
48
72
|
if (planModeRef?.current) {
|
|
49
73
|
return "Error: edit is restricted in plan mode. Use read-only tools to explore the codebase, then write your plan to .gg/plans/.";
|
|
50
74
|
}
|
|
@@ -56,70 +80,148 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
|
|
|
56
80
|
const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
|
|
57
81
|
let working = originalNormalized;
|
|
58
82
|
const fileName = path.basename(resolved);
|
|
59
|
-
const
|
|
83
|
+
const outcomes = new Array(edits.length);
|
|
60
84
|
for (let i = 0; i < edits.length; i++) {
|
|
61
85
|
const { old_text, new_text, replace_all } = edits[i];
|
|
62
86
|
const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
|
|
63
87
|
const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
|
|
64
|
-
const
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (replace_all && normalizedOld.length > 0 && working.includes(normalizedOld)) {
|
|
70
|
-
working = working.split(normalizedOld).join(normalizedNew);
|
|
88
|
+
const replaceAll = replace_all ?? false;
|
|
89
|
+
// Reject no-op edits before they consume a write or silently "succeed"
|
|
90
|
+
// — usually the model paraphrased the new_text to be identical to old.
|
|
91
|
+
if (normalizedOld === normalizedNew) {
|
|
92
|
+
outcomes[i] = { ok: false, failure: { reason: "noop" } };
|
|
71
93
|
continue;
|
|
72
94
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
// Aider's full fallback ladder, run only when the primary match
|
|
96
|
+
// returns "not_found". Ambiguous matches deliberately don't fall
|
|
97
|
+
// through — the model needs to add context, not paraphrase further.
|
|
98
|
+
// Order mirrors aider/coders/editblock_coder.py:
|
|
99
|
+
// 1. exact + smart-quote/dash fuzzy (in tryMatch)
|
|
100
|
+
// 2. indent-flex (model omitted/shortened leading whitespace)
|
|
101
|
+
// 3. drop leading blank line, retry 1+2
|
|
102
|
+
// 4. dotdotdots (`...` elision with preserved middle)
|
|
103
|
+
let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
|
|
104
|
+
const tryFallbacks = (oldText) => {
|
|
105
|
+
const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
|
|
106
|
+
if (flexed !== null)
|
|
107
|
+
return flexed;
|
|
108
|
+
// Re-run primary matcher on the stripped variant as a cheap retry.
|
|
109
|
+
const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
|
|
110
|
+
if (exact.ok)
|
|
111
|
+
return exact.newWorking;
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
if (!result.ok && result.reason === "not_found") {
|
|
115
|
+
const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
|
|
116
|
+
if (indentFlexed !== null) {
|
|
117
|
+
result = { ok: true, newWorking: indentFlexed };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!result.ok && result.reason === "not_found") {
|
|
121
|
+
const stripped = stripLeadingBlankLine(normalizedOld);
|
|
122
|
+
if (stripped !== null) {
|
|
123
|
+
const candidate = tryFallbacks(stripped);
|
|
124
|
+
if (candidate !== null)
|
|
125
|
+
result = { ok: true, newWorking: candidate };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!result.ok && result.reason === "not_found") {
|
|
129
|
+
const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
|
|
130
|
+
if (elided !== null)
|
|
131
|
+
result = { ok: true, newWorking: elided };
|
|
132
|
+
}
|
|
133
|
+
if (result.ok) {
|
|
134
|
+
working = result.newWorking;
|
|
135
|
+
outcomes[i] = { ok: true };
|
|
80
136
|
continue;
|
|
81
137
|
}
|
|
82
|
-
if (
|
|
138
|
+
if (result.reason === "not_found") {
|
|
139
|
+
// Capture the closest-match snippet eagerly against the current
|
|
140
|
+
// working buffer; we'll decide whether to render it post-loop based
|
|
141
|
+
// on whether other edits in this batch succeeded.
|
|
142
|
+
outcomes[i] = {
|
|
143
|
+
ok: false,
|
|
144
|
+
failure: {
|
|
145
|
+
reason: "not_found",
|
|
146
|
+
closestSnippet: findClosestSnippet(working, normalizedOld),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const occurrences = result.occurrences ?? 0;
|
|
83
152
|
const matches = findOccurrenceLines(working, normalizedOld);
|
|
84
153
|
const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
|
|
85
154
|
const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
|
|
86
|
-
|
|
155
|
+
outcomes[i] = {
|
|
156
|
+
ok: false,
|
|
157
|
+
failure: { reason: "ambiguous", occurrences, matchLines, more },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const failures = outcomes
|
|
162
|
+
.map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
|
|
163
|
+
.filter((x) => x !== null);
|
|
164
|
+
const successCount = outcomes.length - failures.length;
|
|
165
|
+
// Closest-match snippets only get suppressed when successes will ACTUALLY
|
|
166
|
+
// be persisted (partial-apply with at least one win). In atomic mode we
|
|
167
|
+
// throw before writing, so the model retries against an unchanged file
|
|
168
|
+
// and the snippet is its only guidance — keep it.
|
|
169
|
+
const willPersistSuccesses = successCount > 0 && !atomic;
|
|
170
|
+
const formatFailureMessage = (f) => {
|
|
171
|
+
if (f.reason === "noop") {
|
|
172
|
+
return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
|
|
173
|
+
}
|
|
174
|
+
if (f.reason === "ambiguous") {
|
|
175
|
+
return (`old_text found ${f.occurrences} times in ${fileName}. ` +
|
|
87
176
|
"Include more surrounding context to make the match unique, " +
|
|
88
177
|
"or set replace_all: true to swap every occurrence.\n" +
|
|
89
178
|
"Matches at:\n" +
|
|
90
|
-
matchLines +
|
|
91
|
-
more);
|
|
92
|
-
continue;
|
|
179
|
+
f.matchLines +
|
|
180
|
+
f.more);
|
|
93
181
|
}
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
182
|
+
const base = `old_text not found in ${fileName}. ` +
|
|
183
|
+
"Text must match verbatim — do not paraphrase. Re-read the file if unsure.";
|
|
184
|
+
if (willPersistSuccesses || !f.closestSnippet)
|
|
185
|
+
return base;
|
|
186
|
+
return `${base}\nClosest match in file:\n${f.closestSnippet}`;
|
|
187
|
+
};
|
|
188
|
+
const formatFailures = () => {
|
|
189
|
+
if (failures.length === 1 && edits.length === 1) {
|
|
190
|
+
return formatFailureMessage(failures[0].failure);
|
|
98
191
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
192
|
+
return failures
|
|
193
|
+
.map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
|
|
194
|
+
.join("\n\n");
|
|
195
|
+
};
|
|
196
|
+
// Atomic-mode failure, OR partial-mode failure where literally nothing
|
|
197
|
+
// succeeded. Either way nothing should be written and we throw to make
|
|
198
|
+
// the model retry the whole batch.
|
|
199
|
+
if (failures.length > 0 && (atomic || successCount === 0)) {
|
|
200
|
+
const header = atomic && failures.length > 0
|
|
201
|
+
? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
|
|
202
|
+
: edits.length > 1
|
|
203
|
+
? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
|
|
204
|
+
: "";
|
|
205
|
+
throw new Error(header + formatFailures());
|
|
110
206
|
}
|
|
111
207
|
const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
|
|
112
208
|
await ops.writeFile(resolved, finalContent);
|
|
113
209
|
await recordWrite(readFiles, resolved, finalContent, ops);
|
|
114
210
|
const relPath = path.relative(cwd, resolved);
|
|
115
211
|
const diff = generateDiff(originalNormalized, working, relPath);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
content: summary,
|
|
121
|
-
|
|
122
|
-
|
|
212
|
+
if (failures.length === 0) {
|
|
213
|
+
const summary = edits.length > 1
|
|
214
|
+
? `Successfully applied ${edits.length} edits to ${relPath}.`
|
|
215
|
+
: `Successfully replaced text in ${relPath}.`;
|
|
216
|
+
return { content: summary, details: { diff } };
|
|
217
|
+
}
|
|
218
|
+
// Partial success — the loud header is deliberate: the model has to know
|
|
219
|
+
// that work was saved AND that only the listed edits need to be retried.
|
|
220
|
+
const noun = failures.length === 1 ? "edit" : "edits";
|
|
221
|
+
const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
|
|
222
|
+
`${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
|
|
223
|
+
formatFailures();
|
|
224
|
+
return { content, details: { diff } };
|
|
123
225
|
},
|
|
124
226
|
};
|
|
125
227
|
}
|
package/dist/tools/edit.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"edit.js","sourceRoot":"","sources":["../../src/tools/edit.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EACL,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,mBAAmB,
|
|
1
|
+
{"version":3,"file":"edit.js","sourceRoot":"","sources":["../../src/tools/edit.ts"],"names":[],"mappings":"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"}
|
package/dist/tools/edit.test.js
CHANGED
|
@@ -67,7 +67,7 @@ describe("createEditTool", () => {
|
|
|
67
67
|
const written = await fs.readFile(filePath, "utf-8");
|
|
68
68
|
expect(written).toBe("baz\n");
|
|
69
69
|
});
|
|
70
|
-
it("reports edit index on failure within a multi-edit batch", async () => {
|
|
70
|
+
it("reports edit index on failure within a multi-edit batch (atomic mode)", async () => {
|
|
71
71
|
const filePath = path.join(tmpDir, "batch.txt");
|
|
72
72
|
await fs.writeFile(filePath, "one two three\n");
|
|
73
73
|
const tool = createEditTool(tmpDir);
|
|
@@ -77,6 +77,7 @@ describe("createEditTool", () => {
|
|
|
77
77
|
{ old_text: "one", new_text: "1" },
|
|
78
78
|
{ old_text: "missing", new_text: "x" },
|
|
79
79
|
],
|
|
80
|
+
atomic: true,
|
|
80
81
|
}, { signal: new AbortController().signal, toolCallId: "test-batch" })).rejects.toThrow(/edit 2\/2/);
|
|
81
82
|
// Nothing should have been written — atomic
|
|
82
83
|
const written = await fs.readFile(filePath, "utf-8");
|
|
@@ -151,7 +152,7 @@ describe("createEditTool", () => {
|
|
|
151
152
|
],
|
|
152
153
|
}, { signal: new AbortController().signal, toolCallId: "test-snippet" })).rejects.toThrow(/Closest match in file:[\s\S]*useState\(0\)/);
|
|
153
154
|
});
|
|
154
|
-
it("aggregates multiple edit failures into one error", async () => {
|
|
155
|
+
it("aggregates multiple edit failures into one error (atomic mode)", async () => {
|
|
155
156
|
const filePath = path.join(tmpDir, "agg.txt");
|
|
156
157
|
await fs.writeFile(filePath, "alpha\nbeta\ngamma\n");
|
|
157
158
|
const tool = createEditTool(tmpDir);
|
|
@@ -162,11 +163,259 @@ describe("createEditTool", () => {
|
|
|
162
163
|
{ old_text: "MISSING", new_text: "X" },
|
|
163
164
|
{ old_text: "ALSO_MISSING", new_text: "Y" },
|
|
164
165
|
],
|
|
165
|
-
|
|
166
|
+
atomic: true,
|
|
167
|
+
}, { signal: new AbortController().signal, toolCallId: "test-agg" })).rejects.toThrow(/2 of 3 edits failed[\s\S]*edit 2\/3[\s\S]*edit 3\/3/);
|
|
166
168
|
// Atomic — nothing written.
|
|
167
169
|
const written = await fs.readFile(filePath, "utf-8");
|
|
168
170
|
expect(written).toBe("alpha\nbeta\ngamma\n");
|
|
169
171
|
});
|
|
172
|
+
it("partial-apply (default): keeps successful edits and reports failures for retry", async () => {
|
|
173
|
+
const filePath = path.join(tmpDir, "partial.txt");
|
|
174
|
+
await fs.writeFile(filePath, "alpha\nbeta\ngamma\n");
|
|
175
|
+
const tool = createEditTool(tmpDir);
|
|
176
|
+
const result = await tool.execute({
|
|
177
|
+
file_path: "partial.txt",
|
|
178
|
+
edits: [
|
|
179
|
+
{ old_text: "alpha", new_text: "ALPHA" },
|
|
180
|
+
{ old_text: "MISSING", new_text: "X" },
|
|
181
|
+
{ old_text: "gamma", new_text: "GAMMA" },
|
|
182
|
+
],
|
|
183
|
+
}, { signal: new AbortController().signal, toolCallId: "test-partial" });
|
|
184
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
185
|
+
expect(summary).toMatch(/Applied 2 of 3 edits/);
|
|
186
|
+
expect(summary).toMatch(/re-issue ONLY these/);
|
|
187
|
+
expect(summary).toMatch(/edit 2\/3/);
|
|
188
|
+
expect(summary).not.toMatch(/edit 1\/3/);
|
|
189
|
+
expect(summary).not.toMatch(/edit 3\/3/);
|
|
190
|
+
// Successful edits landed; failed one didn't.
|
|
191
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
192
|
+
expect(written).toBe("ALPHA\nbeta\nGAMMA\n");
|
|
193
|
+
});
|
|
194
|
+
it("partial-apply on a 19-edit batch with 2 failures lands the other 17", async () => {
|
|
195
|
+
const filePath = path.join(tmpDir, "big.css");
|
|
196
|
+
const lines = Array.from({ length: 19 }, (_, i) => `.cls${i} { color: red; }`);
|
|
197
|
+
await fs.writeFile(filePath, lines.join("\n") + "\n");
|
|
198
|
+
const edits = lines.map((line, i) => ({
|
|
199
|
+
old_text: line,
|
|
200
|
+
// Two of them deliberately drift so the batch sees real failures.
|
|
201
|
+
new_text: i === 7 || i === 13
|
|
202
|
+
? line.replace("color: red", "color: blue")
|
|
203
|
+
: line.replace("red", "green"),
|
|
204
|
+
}));
|
|
205
|
+
// Corrupt edits 8 (index 7) and 14 (index 13) by paraphrasing old_text.
|
|
206
|
+
edits[7] = { old_text: ".cls7 { colour: red; }", new_text: ".cls7 { color: blue; }" };
|
|
207
|
+
edits[13] = { old_text: ".cls13 { colur: red; }", new_text: ".cls13 { color: blue; }" };
|
|
208
|
+
const tool = createEditTool(tmpDir);
|
|
209
|
+
const result = await tool.execute({ file_path: "big.css", edits }, { signal: new AbortController().signal, toolCallId: "test-19" });
|
|
210
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
211
|
+
expect(summary).toMatch(/Applied 17 of 19 edits/);
|
|
212
|
+
expect(summary).toMatch(/edit 8\/19/);
|
|
213
|
+
expect(summary).toMatch(/edit 14\/19/);
|
|
214
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
215
|
+
// 17 lines should have green, the two failed ones still red.
|
|
216
|
+
expect((written.match(/color: green/g) ?? []).length).toBe(17);
|
|
217
|
+
expect(written).toContain(".cls7 { color: red; }");
|
|
218
|
+
expect(written).toContain(".cls13 { color: red; }");
|
|
219
|
+
});
|
|
220
|
+
it("throws when every edit fails even in partial-apply mode", async () => {
|
|
221
|
+
const filePath = path.join(tmpDir, "all-fail.txt");
|
|
222
|
+
await fs.writeFile(filePath, "untouched\n");
|
|
223
|
+
const tool = createEditTool(tmpDir);
|
|
224
|
+
await expect(tool.execute({
|
|
225
|
+
file_path: "all-fail.txt",
|
|
226
|
+
edits: [
|
|
227
|
+
{ old_text: "MISSING1", new_text: "X" },
|
|
228
|
+
{ old_text: "MISSING2", new_text: "Y" },
|
|
229
|
+
],
|
|
230
|
+
}, { signal: new AbortController().signal, toolCallId: "test-all-fail" })).rejects.toThrow(/2 of 2 edits failed/);
|
|
231
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
232
|
+
expect(written).toBe("untouched\n");
|
|
233
|
+
});
|
|
234
|
+
it("suppresses Closest-match snippet in partial-apply when other edits succeeded", async () => {
|
|
235
|
+
// Mirrors the StartingYourAgency.tsx scenario: token-heavy lines where
|
|
236
|
+
// findClosestSnippet returns noisy top-of-file regions. When 17 of 19
|
|
237
|
+
// edits succeed, those diffs already give the model context — the
|
|
238
|
+
// snippet would just be noise.
|
|
239
|
+
const filePath = path.join(tmpDir, "noisy.tsx");
|
|
240
|
+
const lines = Array.from({ length: 6 }, (_, i) => ` <div className="card-${i}">Section ${i}</div>`);
|
|
241
|
+
await fs.writeFile(filePath, lines.join("\n") + "\n");
|
|
242
|
+
const edits = lines.map((line) => ({
|
|
243
|
+
old_text: line,
|
|
244
|
+
new_text: line.replace("card-", "glass-card-"),
|
|
245
|
+
}));
|
|
246
|
+
// Drift edit 4 — paraphrase the case so it doesn't match.
|
|
247
|
+
edits[3] = {
|
|
248
|
+
old_text: ` <div className="Card-3">Section 3</div>`,
|
|
249
|
+
new_text: ` <div className="glass-card-3">Section 3</div>`,
|
|
250
|
+
};
|
|
251
|
+
const tool = createEditTool(tmpDir);
|
|
252
|
+
const result = await tool.execute({ file_path: "noisy.tsx", edits }, { signal: new AbortController().signal, toolCallId: "test-suppress" });
|
|
253
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
254
|
+
expect(summary).toMatch(/Applied 5 of 6/);
|
|
255
|
+
expect(summary).toMatch(/edit 4\/6/);
|
|
256
|
+
// The snippet would be ~3-7 lines starting with "Closest match in file:".
|
|
257
|
+
// In partial-apply with successes, we suppress it.
|
|
258
|
+
expect(summary).not.toMatch(/Closest match in file:/);
|
|
259
|
+
});
|
|
260
|
+
it("keeps Closest-match snippet when no other edit succeeded", async () => {
|
|
261
|
+
// Single-edit call — no surrounding context for the model, so the snippet
|
|
262
|
+
// is genuinely useful and must remain. Use overlapping tokens so the
|
|
263
|
+
// closest-snippet heuristic actually fires.
|
|
264
|
+
const filePath = path.join(tmpDir, "lonely.tsx");
|
|
265
|
+
await fs.writeFile(filePath, "function Counter() {\n const [count, setCount] = useState(0);\n return count;\n}\n");
|
|
266
|
+
const tool = createEditTool(tmpDir);
|
|
267
|
+
await expect(tool.execute({
|
|
268
|
+
file_path: "lonely.tsx",
|
|
269
|
+
edits: [
|
|
270
|
+
{
|
|
271
|
+
old_text: "const [count, setCount] = useState(1);",
|
|
272
|
+
new_text: "const [count, setCount] = useState(2);",
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
}, { signal: new AbortController().signal, toolCallId: "test-keep" })).rejects.toThrow(/Closest match in file:[\s\S]*useState\(0\)/);
|
|
276
|
+
});
|
|
277
|
+
it("atomic mode keeps Closest-match snippet (model retries against unchanged file)", async () => {
|
|
278
|
+
const filePath = path.join(tmpDir, "atomic-snippet.tsx");
|
|
279
|
+
const lines = Array.from({ length: 4 }, (_, i) => ` <div className="card-${i}">Section ${i}</div>`);
|
|
280
|
+
await fs.writeFile(filePath, lines.join("\n") + "\n");
|
|
281
|
+
const edits = lines.map((line) => ({
|
|
282
|
+
old_text: line,
|
|
283
|
+
new_text: line.replace("card-", "glass-card-"),
|
|
284
|
+
}));
|
|
285
|
+
edits[2] = {
|
|
286
|
+
old_text: ` <div className="Card-2">Section 2</div>`,
|
|
287
|
+
new_text: ` <div className="glass-card-2">Section 2</div>`,
|
|
288
|
+
};
|
|
289
|
+
const tool = createEditTool(tmpDir);
|
|
290
|
+
await expect(tool.execute({ file_path: "atomic-snippet.tsx", edits, atomic: true }, { signal: new AbortController().signal, toolCallId: "test-atomic-snippet" })).rejects.toThrow(/Closest match in file:/);
|
|
291
|
+
});
|
|
292
|
+
it("indent-flex: model omits indentation entirely; file has 4-space prefix — applies it", async () => {
|
|
293
|
+
const filePath = path.join(tmpDir, "indent.ts");
|
|
294
|
+
await fs.writeFile(filePath, " const x = 1;\n const y = 2;\n const z = 3;\n");
|
|
295
|
+
const tool = createEditTool(tmpDir);
|
|
296
|
+
const result = await tool.execute({
|
|
297
|
+
file_path: "indent.ts",
|
|
298
|
+
edits: [
|
|
299
|
+
{
|
|
300
|
+
old_text: "const x = 1;\nconst y = 2;\nconst z = 3;",
|
|
301
|
+
new_text: "const x = 10;\nconst y = 20;\nconst z = 30;",
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
}, { signal: new AbortController().signal, toolCallId: "test-indent-flex" });
|
|
305
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
306
|
+
expect(summary).toMatch(/Successfully/);
|
|
307
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
308
|
+
expect(written).toBe(" const x = 10;\n const y = 20;\n const z = 30;\n");
|
|
309
|
+
});
|
|
310
|
+
it("indent-flex: model used 2-space but file uses 4 — outdents both, re-indents new", async () => {
|
|
311
|
+
const filePath = path.join(tmpDir, "mixed-indent.ts");
|
|
312
|
+
await fs.writeFile(filePath, " if (x) {\n return y;\n }\n");
|
|
313
|
+
const tool = createEditTool(tmpDir);
|
|
314
|
+
const result = await tool.execute({
|
|
315
|
+
file_path: "mixed-indent.ts",
|
|
316
|
+
edits: [
|
|
317
|
+
{
|
|
318
|
+
old_text: " if (x) {\n return y;\n }",
|
|
319
|
+
new_text: " if (x) {\n return z;\n }",
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
}, { signal: new AbortController().signal, toolCallId: "test-mixed-indent" });
|
|
323
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
324
|
+
expect(summary).toMatch(/Successfully/);
|
|
325
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
326
|
+
expect(written).toBe(" if (x) {\n return z;\n }\n");
|
|
327
|
+
});
|
|
328
|
+
it("dotdotdots: model elides middle with `...`, edit lands and middle preserved", async () => {
|
|
329
|
+
const filePath = path.join(tmpDir, "elide.ts");
|
|
330
|
+
await fs.writeFile(filePath, [
|
|
331
|
+
"function pomodoro() {",
|
|
332
|
+
" const timer = startTimer();",
|
|
333
|
+
" trackPomodoro(timer);",
|
|
334
|
+
" scheduleBreak();",
|
|
335
|
+
" return timer;",
|
|
336
|
+
"}",
|
|
337
|
+
].join("\n") + "\n");
|
|
338
|
+
const tool = createEditTool(tmpDir);
|
|
339
|
+
const result = await tool.execute({
|
|
340
|
+
file_path: "elide.ts",
|
|
341
|
+
edits: [
|
|
342
|
+
{
|
|
343
|
+
old_text: "function pomodoro() {\n ...\n return timer;\n}",
|
|
344
|
+
new_text: "function pomodoro(): Timer {\n ...\n return timer;\n}",
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
}, { signal: new AbortController().signal, toolCallId: "test-elide" });
|
|
348
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
349
|
+
expect(summary).toMatch(/Successfully/);
|
|
350
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
351
|
+
expect(written).toContain("function pomodoro(): Timer {");
|
|
352
|
+
// Middle preserved verbatim.
|
|
353
|
+
expect(written).toContain("trackPomodoro(timer);");
|
|
354
|
+
expect(written).toContain("scheduleBreak();");
|
|
355
|
+
});
|
|
356
|
+
it("dotdotdots: failed elision falls through to standard not_found error", async () => {
|
|
357
|
+
const filePath = path.join(tmpDir, "elide-fail.ts");
|
|
358
|
+
await fs.writeFile(filePath, "function actuallyExists() { return 1; }\n");
|
|
359
|
+
const tool = createEditTool(tmpDir);
|
|
360
|
+
await expect(tool.execute({
|
|
361
|
+
file_path: "elide-fail.ts",
|
|
362
|
+
edits: [
|
|
363
|
+
{
|
|
364
|
+
// Bookends don't exist in the file.
|
|
365
|
+
old_text: "function nonexistent() {\n ...\n return x;\n}",
|
|
366
|
+
new_text: "function nonexistent() {\n ...\n return y;\n}",
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
}, { signal: new AbortController().signal, toolCallId: "test-elide-fail" })).rejects.toThrow(/old_text not found/);
|
|
370
|
+
});
|
|
371
|
+
it("rejects no-op edits where old_text equals new_text", async () => {
|
|
372
|
+
const filePath = path.join(tmpDir, "noop.txt");
|
|
373
|
+
await fs.writeFile(filePath, "hello world\n");
|
|
374
|
+
const tool = createEditTool(tmpDir);
|
|
375
|
+
await expect(tool.execute({
|
|
376
|
+
file_path: "noop.txt",
|
|
377
|
+
edits: [{ old_text: "hello", new_text: "hello" }],
|
|
378
|
+
}, { signal: new AbortController().signal, toolCallId: "test-noop" })).rejects.toThrow(/identical[\s\S]*no-op/);
|
|
379
|
+
// File untouched — confirms we didn't write a no-op.
|
|
380
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
381
|
+
expect(written).toBe("hello world\n");
|
|
382
|
+
});
|
|
383
|
+
it("no-op edit in a partial-apply batch still lets the other edits land", async () => {
|
|
384
|
+
const filePath = path.join(tmpDir, "noop-batch.txt");
|
|
385
|
+
await fs.writeFile(filePath, "alpha\nbeta\n");
|
|
386
|
+
const tool = createEditTool(tmpDir);
|
|
387
|
+
const result = await tool.execute({
|
|
388
|
+
file_path: "noop-batch.txt",
|
|
389
|
+
edits: [
|
|
390
|
+
{ old_text: "alpha", new_text: "ALPHA" },
|
|
391
|
+
{ old_text: "beta", new_text: "beta" }, // no-op
|
|
392
|
+
],
|
|
393
|
+
}, { signal: new AbortController().signal, toolCallId: "test-noop-batch" });
|
|
394
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
395
|
+
expect(summary).toMatch(/Applied 1 of 2/);
|
|
396
|
+
expect(summary).toMatch(/identical[\s\S]*no-op/);
|
|
397
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
398
|
+
expect(written).toBe("ALPHA\nbeta\n");
|
|
399
|
+
});
|
|
400
|
+
it("strips a spurious leading blank line in old_text and still matches", async () => {
|
|
401
|
+
const filePath = path.join(tmpDir, "blank.ts");
|
|
402
|
+
await fs.writeFile(filePath, "function foo() {\n return 42;\n}\n");
|
|
403
|
+
const tool = createEditTool(tmpDir);
|
|
404
|
+
const result = await tool.execute({
|
|
405
|
+
file_path: "blank.ts",
|
|
406
|
+
edits: [
|
|
407
|
+
{
|
|
408
|
+
// Note the leading blank line — the model often pastes one in.
|
|
409
|
+
old_text: "\n return 42;",
|
|
410
|
+
new_text: "\n return 100;",
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
}, { signal: new AbortController().signal, toolCallId: "test-blank" });
|
|
414
|
+
const summary = typeof result === "string" ? result : result.content;
|
|
415
|
+
expect(summary).toMatch(/Successfully/);
|
|
416
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
417
|
+
expect(written).toBe("function foo() {\n return 100;\n}\n");
|
|
418
|
+
});
|
|
170
419
|
it("throws when old_text is not found", async () => {
|
|
171
420
|
const filePath = path.join(tmpDir, "missing.txt");
|
|
172
421
|
await fs.writeFile(filePath, "some content here\n");
|