Skip to content

Commit 283f185

Browse files
Merge pull request #575 from corbitsdev/cl-6962-edit-and-write-tools-should-return-the-changed-region-so-no
Return the changed region from edit/write/delete/apply_patch
2 parents 22a10b3 + 5461e37 commit 283f185

8 files changed

Lines changed: 570 additions & 8 deletions

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ is the direct, explicit resume path.
9292
- Paths outside the workspace and writes under the session state root still ask; mutating MCP and unknown tools still prompt.
9393

9494
- **Path sandboxing** — Tool path arguments are resolved against the working directory; paths that escape it are blocked unless `--dangerously-skip-permissions` / `/yolo` is on (secret-guard and authz hard denies still apply).
95-
- **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed.
95+
- **Write verification** — After every write/edit the file is re-read and compared to confirm the change actually landed; the result returned to the model (and shown to the operator) includes a bounded diff of the changed region — `write_file`, `edit_file`, `delete_file`, and each op inside `apply_patch` — so a follow-up `read_file` is never needed just to confirm an edit landed. A whole-file rewrite's diff is truncated (and says so) rather than blowing the result size cap.
9696

9797
## Slash Commands (TUI)
9898

src/agent/apply-patch-diff.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createPosixTools } from "@intx/tools-posix";
6+
import { createToolRunner } from "@intx/agent";
7+
import type { AgentTool } from "@intx/agent";
8+
9+
import { createCodexToolProxies, type CodexRunTool } from "./codex-tool-proxies.js";
10+
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
11+
import { createPermissionGate } from "../permission/gate.js";
12+
13+
/**
14+
* apply_patch forwards each op through the same posixTools.run chain the rest
15+
* of the agent uses (see tools.ts), so verify-plugin's and delete-file-plugin's
16+
* diffs surface here too without any apply_patch-specific plumbing.
17+
*/
18+
async function invokeApplyPatch(tools: AgentTool[], input: string) {
19+
const runner = createToolRunner(tools);
20+
return runner.run(
21+
{ id: "call-1", name: "apply_patch", arguments: { input } },
22+
new AbortController().signal,
23+
);
24+
}
25+
26+
async function makeApplyPatch(cwd: string): Promise<AgentTool[]> {
27+
const gate = createPermissionGate({
28+
approvals: [],
29+
interactive: false,
30+
skipPermissions: true,
31+
auto: false,
32+
cwd,
33+
});
34+
const posixTools = createPosixTools({
35+
cwd,
36+
plugins: buildCorePosixToolPlugins({ cwd, permissionGate: gate }),
37+
});
38+
const runTool: CodexRunTool = async (name, args) => {
39+
// read_file's real tool output is cat -n formatted (line numbers), which
40+
// is not the raw content applyUpdateHunks needs — in production this
41+
// means Update File hunks generally fail to match context (filed as
42+
// CL-6966, Urgent; not this issue's bug to fix). This stub bypasses that
43+
// known defect by returning raw content, so the "Update File shows the
44+
// diff" test below is NOT proof that apply_patch Update works end to end
45+
// — it only proves the diff-surfacing added here is correct once the op
46+
// succeeds. Add File / Delete File below do not depend on read_file and
47+
// are real, unstubbed coverage.
48+
if (name === "read_file") {
49+
const path = String((args as { path?: unknown }).path ?? "");
50+
try {
51+
return { content: await readFile(join(cwd, path), "utf8") };
52+
} catch (err) {
53+
return { content: err instanceof Error ? err.message : String(err), isError: true };
54+
}
55+
}
56+
const result = await posixTools.run(
57+
{ id: "codex-proxy", name, arguments: args },
58+
new AbortController().signal,
59+
);
60+
return {
61+
content: typeof result.content === "string" ? result.content : JSON.stringify(result.content),
62+
...(result.isError === true ? { isError: true } : {}),
63+
};
64+
};
65+
return createCodexToolProxies({
66+
isCodex: true,
67+
runTool,
68+
runManageTasks: async () => ({ content: "ok" }),
69+
});
70+
}
71+
72+
describe("apply_patch surfaces the changed region", () => {
73+
test("Update File shows the diff", async () => {
74+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-diff-"));
75+
try {
76+
await writeFile(join(cwd, "a.txt"), "line1\nworld\nline3\n");
77+
const tools = await makeApplyPatch(cwd);
78+
const input = [
79+
"*** Begin Patch",
80+
"*** Update File: a.txt",
81+
"@@",
82+
" line1",
83+
"-world",
84+
"+universe",
85+
" line3",
86+
"*** End Patch",
87+
].join("\n");
88+
89+
const result = await invokeApplyPatch(tools, input);
90+
91+
expect(result.isError).not.toBe(true);
92+
expect(String(result.content)).toContain("-world");
93+
expect(String(result.content)).toContain("+universe");
94+
} finally {
95+
await rm(cwd, { recursive: true, force: true });
96+
}
97+
});
98+
99+
test("Delete File shows the removed content", async () => {
100+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-diff-"));
101+
try {
102+
await writeFile(join(cwd, "gone.txt"), "bye\n");
103+
const tools = await makeApplyPatch(cwd);
104+
const input = ["*** Begin Patch", "*** Delete File: gone.txt", "*** End Patch"].join("\n");
105+
106+
const result = await invokeApplyPatch(tools, input);
107+
108+
expect(result.isError).not.toBe(true);
109+
expect(String(result.content)).toContain("-bye");
110+
} finally {
111+
await rm(cwd, { recursive: true, force: true });
112+
}
113+
});
114+
115+
test("Add File shows the added content", async () => {
116+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-diff-"));
117+
try {
118+
const tools = await makeApplyPatch(cwd);
119+
const input = ["*** Begin Patch", "*** Add File: new.txt", "+hello", "*** End Patch"].join(
120+
"\n",
121+
);
122+
123+
const result = await invokeApplyPatch(tools, input);
124+
125+
expect(result.isError).not.toBe(true);
126+
expect(String(result.content)).toContain("+hello");
127+
} finally {
128+
await rm(cwd, { recursive: true, force: true });
129+
}
130+
});
131+
});

src/plugins/change-diff.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { formatChangeDiff, MAX_DIFF_CHARS } from "./change-diff.js";
3+
4+
describe("formatChangeDiff", () => {
5+
test("returns undefined when content is unchanged", () => {
6+
expect(formatChangeDiff("a.txt", "same\n", "same\n")).toBeUndefined();
7+
});
8+
9+
test("small edit produces a unified diff with context", () => {
10+
const before = "line1\nline2\nline3\nline4\nline5\n";
11+
const after = "line1\nline2\nCHANGED\nline4\nline5\n";
12+
13+
const diff = formatChangeDiff("a.txt", before, after);
14+
15+
expect(diff).toBeDefined();
16+
expect(diff).toContain("--- a.txt");
17+
expect(diff).toContain("+++ a.txt");
18+
expect(diff).toContain("-line3");
19+
expect(diff).toContain("+CHANGED");
20+
expect(diff).toContain(" line2");
21+
expect(diff).toContain(" line4");
22+
});
23+
24+
test("whole-file rewrite is bounded by the char cap and says so", () => {
25+
const before = "old content\n".repeat(2000);
26+
const after = "new content\n".repeat(2000);
27+
28+
const diff = formatChangeDiff("a.txt", before, after);
29+
30+
expect(diff).toBeDefined();
31+
// The cap must hold exactly — the truncation note is reserved WITHIN
32+
// maxChars, not appended after it.
33+
expect(diff!.length).toBeLessThanOrEqual(MAX_DIFF_CHARS);
34+
expect(diff).toContain("truncated");
35+
});
36+
37+
test("truncation note never pushes the result past the cap at a small boundary", () => {
38+
// A tiny maxChars stresses the fixed-point loop in truncate(): the note's
39+
// own length (which depends on the digit counts it reports) must still
40+
// fit within the cap it is describing.
41+
const before = "a\n".repeat(50);
42+
const after = "b\n".repeat(50);
43+
44+
for (const maxChars of [50, 80, 120, 200]) {
45+
const diff = formatChangeDiff("a.txt", before, after, maxChars);
46+
expect(diff).toBeDefined();
47+
expect(diff!.length).toBeLessThanOrEqual(maxChars);
48+
}
49+
});
50+
51+
test("very large files skip full LCS and report a bounded summary", () => {
52+
const before = Array.from({ length: 3000 }, (_, i) => `l${i}`).join("\n");
53+
const after = Array.from({ length: 3000 }, (_, i) => `m${i}`).join("\n");
54+
55+
const diff = formatChangeDiff("big.txt", before, after);
56+
57+
expect(diff).toBeDefined();
58+
expect(diff).toContain("large change");
59+
expect(diff).toContain("exceeds");
60+
expect(diff!.length).toBeLessThanOrEqual(MAX_DIFF_CHARS);
61+
});
62+
63+
test("deletion (after is empty) shows removed lines", () => {
64+
const diff = formatChangeDiff("gone.txt", "keep me\n", "");
65+
expect(diff).toBeDefined();
66+
expect(diff).toContain("-keep me");
67+
});
68+
});

0 commit comments

Comments
 (0)