From f15398efc34b65b69adefaff777ff56d27f4c6eb Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:11:04 -0500 Subject: [PATCH] feat(core): improve edit tool output (#39211) --- packages/core/src/tool/plugin/edit.ts | 85 +++++++++++---------------- packages/core/test/tool-edit.test.ts | 42 ++++++++++++- 2 files changed, 73 insertions(+), 54 deletions(-) diff --git a/packages/core/src/tool/plugin/edit.ts b/packages/core/src/tool/plugin/edit.ts index c55eb2e72b6..19123d7ea2c 100644 --- a/packages/core/src/tool/plugin/edit.ts +++ b/packages/core/src/tool/plugin/edit.ts @@ -60,23 +60,6 @@ const countOccurrences = (content: string, search: string) => { return count } -const previewLines = (value: string, prefix: "+" | "-") => { - const lines = normalizeLineEndings(value).split("\n") - const shown = lines.slice(0, 6).map((line) => `${prefix}${line.length > 240 ? `${line.slice(0, 240)}...` : line}`) - if (lines.length > shown.length) shown.push(`${prefix}...`) - return shown -} - -export const toModelOutput = (output: Output, oldString: string, newString: string) => - [ - `Edited file successfully: ${output.files[0]?.file}`, - `Replacements: ${output.replacements}`, - "```diff", - ...previewLines(oldString, "-"), - ...previewLines(newString, "+"), - "```", - ].join("\n") - /** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */ // TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review. // TODO: Add formatter integration after formatter runtime exists. @@ -103,11 +86,6 @@ export const Plugin = { input: Input, output: Output, execute: (input, context) => { - const unableToEdit = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError((error) => new ToolFailure({ message: `Unable to edit ${input.path}`, error })), - ) - return Effect.gen(function* () { const permissionSource = { type: "tool" as const, @@ -125,44 +103,46 @@ export const Plugin = { }) } - const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" })) + const target = yield* mutation.resolve({ path: input.path, kind: "file" }) const external = target.externalDirectory if (external) { - yield* unableToEdit( - permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source: permissionSource, - }), - ) - } - - yield* unableToEdit( - permission.assert({ - action: "edit", - resources: [target.resource], - save: ["*"], + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), sessionID: context.sessionID, agent: context.agent, source: permissionSource, - }), + }) + } + + yield* permission.assert({ + action: "edit", + resources: [target.resource], + save: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: permissionSource, + }) + const info = yield* fs.stat(target.canonical).pipe( + Effect.catchReason("PlatformError", "NotFound", () => + Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), + ), ) - const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical))) + if (info.type === "Directory") { + return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }) + } + const source = decodeUtf8(yield* fs.readFile(target.canonical)) const ending = detectLineEnding(source.text) const oldString = convertToLineEnding(input.oldString, ending) const newString = convertToLineEnding(input.newString, ending) const replacements = countOccurrences(source.text, oldString) if (replacements === 0) { return yield* new ToolFailure({ - message: - "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", + message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`, }) } if (replacements > 1 && input.replaceAll !== true) { return yield* new ToolFailure({ - message: - "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", + message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`, }) } @@ -178,12 +158,10 @@ export const Plugin = { { additions: 0, deletions: 0 }, ) const next = splitBom(replaced) - const result = yield* unableToEdit( - files.write({ - target, - content: joinBom(next.text, source.bom || next.bom), - }), - ) + const result = yield* files.write({ + target, + content: joinBom(next.text, source.bom || next.bom), + }) return { files: [ { @@ -198,9 +176,14 @@ export const Plugin = { }).pipe( Effect.map((output) => ({ output, - content: toModelOutput(output, input.oldString, input.newString), + content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`, metadata: { files: output.files }, })), + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to edit ${input.path}`, error }), + ), ) }, }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 97c3578839b..8116cb64a59 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -150,7 +150,7 @@ describe("EditTool", () => { expect(settled.content).toEqual([ { type: "text", - text: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", + text: "Edited hello.txt (1 replacement)", }, ]) // Compact UI metadata carries the file diffs the TUI renders. @@ -385,7 +385,7 @@ describe("EditTool", () => { error: { type: "tool.execution", message: - "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", + "Could not find oldString in matches.txt. It must match exactly, including whitespace and indentation.", }, }) expect( @@ -395,7 +395,7 @@ describe("EditTool", () => { error: { type: "tool.execution", message: - "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", + "Found 2 matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.", }, }) expect(writes).toEqual([]) @@ -408,6 +408,41 @@ describe("EditTool", () => { ), ) + it.live("returns specific missing file and directory errors", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const directory = path.join(tmp.path, "src") + return Effect.promise(() => fs.mkdir(directory)).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call({ path: "missing.ts", oldString: "before", newString: "after" }), + ), + ).toEqual({ + status: "error", + error: { type: "tool.execution", message: "File not found: missing.ts" }, + }) + expect( + yield* executeTool(registry, call({ path: "src", oldString: "before", newString: "after" })), + ).toEqual({ + status: "error", + error: { type: "tool.execution", message: "Path is a directory, not a file: src" }, + }) + expect(writes).toEqual([]) + }), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("replaces every exact occurrence when replaceAll is true", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -425,6 +460,7 @@ describe("EditTool", () => { expect(settled.status).toBe("completed") if (settled.status !== "completed") return expect(settled.output).toMatchObject({ replacements: 3 }) + expect(settled.content).toEqual([{ type: "text", text: "Edited all.txt (3 replacements)" }]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after") expect(writes).toHaveLength(1) }),