Skip to content

Commit a21b7ce

Browse files
fix: preserve files during fuzzy edits (#51)
1 parent 5c7bb12 commit a21b7ce

6 files changed

Lines changed: 418 additions & 36 deletions

File tree

.changeset/quiet-files-stay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/computer": patch
3+
---
4+
5+
Preserve untargeted file content when the `edit` tool falls back to fuzzy matching.

docs/09_tool_interface.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@ The schema is:
219219
}
220220
```
221221

222-
Every `oldText` must identify one unique, non-overlapping range in the original content. The tool applies the batch atomically, preserves the byte order mark, line ending style, and file mode, and returns a unified patch plus `firstChangedLine`.
222+
Every `oldText` must identify one unique, non-overlapping range in the original content. Exact matching is tried first. If that misses, the tool can locate the range after NFKC normalization, trailing-whitespace trimming, and common quote, dash, and space folding. Fuzzy normalization is lookup-only: the replacement is spliced into the original text, so content outside the matched range stays unchanged and the returned diff describes the bytes written. A fuzzy match whose normalized boundary cannot map unambiguously to the source is rejected; copy a larger exact range in that case.
223+
224+
The tool applies the batch atomically, preserves the byte order mark, line ending style, and file mode, and returns a unified patch plus `firstChangedLine`.
223225

224226
`edit`, `write`, and `delete` share locks through the store's stable `lockIdentity`. Every `WorkspaceFileStore` over the same `workspace.fs` uses the same identity, including adapters created by separate `createAITools()` calls. A write cannot land between edit's read and write phases, while unrelated workspaces and paths remain independent. Recursive deletion also locks the whole subtree, so mutations to ancestors or descendants cannot interleave with it.
225227

packages/computer/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,11 @@ next call to avoid transferring the same bytes again. Eligible image and
295295
PDF bytes are captured once during the bounded tool execution and returned
296296
as AI SDK `file` model output without re-reading the file. SVG source remains
297297
text. `ls`, `find`, and `grep` pass pagination through to the storage layer
298-
and return `nextOffset` when more results exist. File mutations share
299-
locks across tool sets for the same workspace, and recursive deletion
300-
excludes mutations throughout its subtree. See
298+
and return `nextOffset` when more results exist. `edit` falls back to
299+
Unicode- and whitespace-tolerant matching while splicing replacements into
300+
the original text, so untargeted content stays byte-for-byte unchanged. File
301+
mutations share locks across tool sets for the same workspace, and recursive
302+
deletion excludes mutations throughout its subtree. See
301303
[`docs/09_tool_interface.md`](../../docs/09_tool_interface.md).
302304

303305
## Git

packages/computer/src/tools/ai.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,27 @@ describe("createAITools filesystem tools", () => {
386386
);
387387
});
388388

389+
it("preserves untargeted bytes when an edit uses fuzzy matching", async () => {
390+
const workspace = makeWorkspace();
391+
const tools = createAITools({ workspace });
392+
const original = "The spec says “must” — not optional. \nlet target = 1;\n";
393+
await workspace.fs.mkdir("/workspace", { recursive: true });
394+
await workspace.fs.writeFile("/workspace/notes.md", original);
395+
396+
const result = await executeTool(tools.edit, {
397+
path: "/workspace/notes.md",
398+
edits: [{ oldText: "let target = 1; ", newText: "let target = 2;" }],
399+
});
400+
401+
await expect(workspace.fs.readFile("/workspace/notes.md", "utf8")).resolves.toBe(
402+
original.replace("let target = 1;", "let target = 2;"),
403+
);
404+
expect(result).toMatchObject({
405+
diff: expect.not.stringContaining('The spec says "must" - not optional.'),
406+
patch: expect.stringContaining("+let target = 2;"),
407+
});
408+
});
409+
389410
it("paginates ls results and reports a continuation offset", async () => {
390411
const workspace = makeWorkspace();
391412
await workspace.fs.mkdir("/workspace", { recursive: true });
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, expect, it } from "vitest";
2+
import { applyEditsToNormalizedContent, fuzzyFindText } from "./edit-diff.js";
3+
4+
const path = "/workspace/notes.md";
5+
6+
describe("fuzzy edit source mapping", () => {
7+
it("preserves every byte outside the fuzzy-matched span", () => {
8+
const original =
9+
"The spec says “must” — not optional. \n" +
10+
"ligature: file; fraction: ½; space: a b\n" +
11+
"let target = 1;\n";
12+
13+
expect(
14+
applyEditsToNormalizedContent(
15+
original,
16+
[{ oldText: "let target = 1; ", newText: "let target = 2;" }],
17+
path,
18+
),
19+
).toEqual({
20+
baseContent: original,
21+
newContent: original.replace("let target = 1;", "let target = 2;"),
22+
});
23+
});
24+
25+
it("mixes exact and fuzzy edits in original-content coordinates", () => {
26+
const original = "keep “this” — unchanged \nconst exact = 1;\nconst fuzzy = 2;\n";
27+
28+
expect(
29+
applyEditsToNormalizedContent(
30+
original,
31+
[
32+
{ oldText: "const exact = 1;", newText: "const exact = 3;" },
33+
{ oldText: "const fuzzy = 2;", newText: "const fuzzy = 4;" },
34+
],
35+
path,
36+
).newContent,
37+
).toBe("keep “this” — unchanged \nconst exact = 3;\nconst fuzzy = 4;\n");
38+
});
39+
40+
it("maps a complete NFKC expansion back to its source character", () => {
41+
expect(
42+
applyEditsToNormalizedContent(
43+
"const value = fi;\n",
44+
[{ oldText: "fi", newText: "pair" }],
45+
path,
46+
).newContent,
47+
).toBe("const value = pair;\n");
48+
});
49+
50+
it("maps a complete combining sequence and preserves supplementary characters", () => {
51+
const original = "const café = 1; // 🙂\n";
52+
53+
expect(
54+
applyEditsToNormalizedContent(
55+
original,
56+
[{ oldText: "const café = 1;", newText: "const cafe = 2;" }],
57+
path,
58+
).newContent,
59+
).toBe("const cafe = 2; // 🙂\n");
60+
});
61+
62+
it("rejects a match that ends inside an NFKC expansion", () => {
63+
expect(() =>
64+
applyEditsToNormalizedContent("const value = fi;\n", [{ oldText: "f", newText: "x" }], path),
65+
).toThrow(/ambiguous Unicode-normalization or trimmed-whitespace boundary/);
66+
});
67+
68+
it("rejects a fuzzy edit when normalization makes multiple matches ambiguous", () => {
69+
expect(() =>
70+
applyEditsToNormalizedContent(
71+
"const value = 1;\nconst value = 1;\n",
72+
[{ oldText: "const value = 1; ", newText: "updated" }],
73+
path,
74+
),
75+
).toThrow(/Found 2 occurrences/);
76+
});
77+
78+
it("uses a later safe match when an earlier normalized occurrence has an unsafe boundary", () => {
79+
expect(
80+
applyEditsToNormalizedContent("fi\nf\n", [{ oldText: "f", newText: "x" }], path).newContent,
81+
).toBe("fi\nx\n");
82+
});
83+
84+
it("rejects a match that starts inside an NFKC expansion", () => {
85+
expect(() =>
86+
applyEditsToNormalizedContent("const value = fi;\n", [{ oldText: "i", newText: "x" }], path),
87+
).toThrow(/ambiguous Unicode-normalization or trimmed-whitespace boundary/);
88+
});
89+
90+
it("preserves trailing whitespace adjacent to a fuzzy span", () => {
91+
expect(
92+
applyEditsToNormalizedContent(
93+
"const value = 1; ",
94+
[{ oldText: "const value = 1;", newText: "const value = 2;" }],
95+
path,
96+
).newContent,
97+
).toBe("const value = 2; ");
98+
});
99+
100+
it("preserves trailing whitespace before a fuzzy span that starts with a newline", () => {
101+
expect(
102+
applyEditsToNormalizedContent(
103+
"header \nfoo “x”;\n",
104+
[{ oldText: '\nfoo "x";', newText: '\nfoo "y";' }],
105+
path,
106+
).newContent,
107+
).toBe('header \nfoo "y";\n');
108+
});
109+
110+
it("does not treat an exact match as a fuzzy duplicate", () => {
111+
expect(
112+
applyEditsToNormalizedContent(
113+
"foo(); \nfoo();\n",
114+
[{ oldText: "foo();\n", newText: "bar();\n" }],
115+
path,
116+
).newContent,
117+
).toBe("foo(); \nbar();\n");
118+
});
119+
120+
it("preserves trailing whitespace after a multiline fuzzy span", () => {
121+
expect(
122+
applyEditsToNormalizedContent(
123+
"first line\nsecond line \nafter \n",
124+
[{ oldText: "first line\nsecond line", newText: "combined" }],
125+
path,
126+
).newContent,
127+
).toBe("combined \nafter \n");
128+
});
129+
130+
it("replaces a fuzzy match that ends at EOF after an earlier trimmed line", () => {
131+
expect(
132+
applyEditsToNormalizedContent(
133+
"keep trailing spaces \ntarget value",
134+
[{ oldText: "target value", newText: "updated" }],
135+
path,
136+
).newContent,
137+
).toBe("keep trailing spaces \nupdated");
138+
});
139+
140+
it("rejects a fuzzy edit when grapheme-local NFKC differs from whole-string NFKC", () => {
141+
expect(() =>
142+
applyEditsToNormalizedContent(
143+
"ㄱᅡ value\n",
144+
[{ oldText: "가 value", newText: "updated" }],
145+
path,
146+
),
147+
).toThrow(/ambiguous Unicode-normalization or trimmed-whitespace boundary/);
148+
});
149+
150+
it("replaces an entire grapheme even when whole-string NFKC composes it", () => {
151+
expect(
152+
applyEditsToNormalizedContent("Ångstrom\n", [{ oldText: "Å", newText: "A" }], path)
153+
.newContent,
154+
).toBe("Angstrom\n");
155+
});
156+
157+
it("maps fuzzy matches after supplementary characters", () => {
158+
const content = "🙂 before; const value = 1; after\n";
159+
const match = fuzzyFindText(content, "const value = 1;");
160+
161+
expect(match).toMatchObject({
162+
found: true,
163+
index: content.indexOf("const value"),
164+
matchLength: "const value = 1;".length,
165+
usedFuzzyMatch: true,
166+
});
167+
});
168+
});

0 commit comments

Comments
 (0)