From f4cf3f4976b8e8e60b90098bba57c11ffa115a6a Mon Sep 17 00:00:00 2001 From: Britt Date: Sat, 24 Jan 2026 13:00:21 -0500 Subject: [PATCH] fix(web): construct apply_patch metadata before requesting permission (#10422) --- packages/opencode/src/tool/apply_patch.ts | 27 ++++---- .../opencode/test/tool/apply_patch.test.ts | 63 ++++++++++++++++++- packages/ui/src/components/message-part.tsx | 7 ++- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 9883f20a2..8028ee8de 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -158,6 +158,19 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } } + // Build per-file metadata for UI rendering (used for both permission and result) + const files = fileChanges.map((change) => ({ + filePath: change.filePath, + relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath), + type: change.type, + diff: change.diff, + before: change.oldContent, + after: change.newContent, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + })) + // Check permissions if needed const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)) await ctx.ask({ @@ -167,6 +180,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { metadata: { filepath: relativePaths.join(", "), diff: totalDiff, + files, }, }) @@ -253,19 +267,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } } - // Build per-file metadata for UI rendering - const files = fileChanges.map((change) => ({ - filePath: change.filePath, - relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath), - type: change.type, - diff: change.diff, - before: change.oldContent, - after: change.newContent, - additions: change.additions, - deletions: change.deletions, - movePath: change.movePath, - })) - return { title: output, metadata: { diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 6445c6845..de48b074f 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -18,7 +18,21 @@ type AskInput = { permission: string patterns: string[] always: string[] - metadata: { diff: string } + metadata: { + diff: string + filepath: string + files: Array<{ + filePath: string + relativePath: string + type: "add" | "update" | "delete" | "move" + diff: string + before: string + after: string + additions: number + deletions: number + movePath?: string + }> + } } type ToolCtx = typeof baseCtx & { @@ -60,7 +74,7 @@ describe("tool.apply_patch freeform", () => { }) test("applies add/update/delete in one patch", async () => { - await using fixture = await tmpdir() + await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() await Instance.provide({ @@ -81,6 +95,21 @@ describe("tool.apply_patch freeform", () => { expect(result.metadata.diff).toContain("Index:") expect(calls.length).toBe(1) + // Verify permission metadata includes files array for UI rendering + const permissionCall = calls[0] + expect(permissionCall.metadata.files).toHaveLength(3) + expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"]) + + const addFile = permissionCall.metadata.files.find((f) => f.type === "add") + expect(addFile).toBeDefined() + expect(addFile!.relativePath).toBe("nested/new.txt") + expect(addFile!.after).toBe("created\n") + + const updateFile = permissionCall.metadata.files.find((f) => f.type === "update") + expect(updateFile).toBeDefined() + expect(updateFile!.before).toContain("line2") + expect(updateFile!.after).toContain("changed") + const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") expect(added).toBe("created\n") expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") @@ -89,6 +118,36 @@ describe("tool.apply_patch freeform", () => { }) }) + test("permission metadata includes move file info", async () => { + await using fixture = await tmpdir({ git: true }) + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.writeFile(original, "old content\n", "utf-8") + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const permissionCall = calls[0] + expect(permissionCall.metadata.files).toHaveLength(1) + + const moveFile = permissionCall.metadata.files[0] + expect(moveFile.type).toBe("move") + expect(moveFile.relativePath).toBe("renamed/dir/name.txt") + expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) + expect(moveFile.before).toBe("old content\n") + expect(moveFile.after).toBe("new content\n") + }, + }) + }) + test("applies multiple hunks to one file", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d5d844161..8129e13a7 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -605,7 +605,12 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const input = () => part.state?.input ?? emptyInput // @ts-expect-error - const metadata = () => part.state?.metadata ?? emptyMetadata + const partMetadata = () => part.state?.metadata ?? emptyMetadata + const metadata = () => { + const perm = permission() + if (perm?.metadata) return { ...perm.metadata, ...partMetadata() } + return partMetadata() + } const render = ToolRegistry.render(part.tool) ?? GenericTool