summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBritt <[email protected]>2026-01-24 13:00:21 -0500
committerGitHub <[email protected]>2026-01-24 12:00:21 -0600
commitf4cf3f4976b8e8e60b90098bba57c11ffa115a6a (patch)
tree0c210ab26d2146d1ed75c0bbc673cdd1fe715df8
parente3c1861a3ee315e1c81bd7f0aee5e3c9faf7af83 (diff)
downloadopencode-f4cf3f4976b8e8e60b90098bba57c11ffa115a6a.tar.gz
opencode-f4cf3f4976b8e8e60b90098bba57c11ffa115a6a.zip
fix(web): construct apply_patch metadata before requesting permission (#10422)
-rw-r--r--packages/opencode/src/tool/apply_patch.ts27
-rw-r--r--packages/opencode/test/tool/apply_patch.test.ts63
-rw-r--r--packages/ui/src/components/message-part.tsx7
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