Skip to content

Commit ebf4007

Browse files
authored
fix(acp): enrich permission prompts (anomalyco#34079)
1 parent 1aea999 commit ebf4007

2 files changed

Lines changed: 269 additions & 12 deletions

File tree

packages/opencode/src/acp/permission.ts

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
1+
import type {
2+
AgentSideConnection,
3+
PermissionOption,
4+
RequestPermissionResponse,
5+
ToolCallContent,
6+
ToolCallLocation,
7+
ToolCallUpdate,
8+
} from "@agentclientprotocol/sdk"
29
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
310
import { applyPatch } from "diff"
411
import { exists, readText } from "@/util/filesystem"
512
import type { ACPSession } from "./session"
6-
import { toLocations, toToolKind, type ToolInput } from "./tool"
13+
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
714
import { Effect } from "effect"
815

916
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
@@ -54,14 +61,11 @@ export class Handler {
5461
const result = await this.input.connection
5562
.requestPermission({
5663
sessionId: permission.sessionID,
57-
toolCall: {
64+
toolCall: await permissionToolCall({
5865
toolCallId: permission.tool?.callID ?? permission.id,
59-
status: "pending",
60-
title: permission.permission,
61-
rawInput: permission.metadata,
62-
kind: toToolKind(permission.permission),
63-
locations: toLocations(permission.permission, permission.metadata),
64-
},
66+
toolName: permission.permission,
67+
input: permission.metadata,
68+
}),
6569
options: permissionOptions,
6670
})
6771
.catch(async () => {
@@ -111,6 +115,107 @@ export class Handler {
111115
}
112116
}
113117

118+
async function permissionToolCall(input: {
119+
readonly toolCallId: string
120+
readonly toolName: string
121+
readonly input: ToolInput
122+
}): Promise<ToolCallUpdate> {
123+
const toolCall = pendingToolCall({
124+
toolCallId: input.toolCallId,
125+
toolName: input.toolName,
126+
state: {
127+
input: input.input,
128+
title: permissionTitle(input.toolName, input.input),
129+
},
130+
})
131+
const content = await permissionContent(input.toolName, input.input)
132+
return {
133+
...toolCall,
134+
locations: permissionLocations(input.toolName, input.input),
135+
...(content.length ? { content } : {}),
136+
}
137+
}
138+
139+
function permissionTitle(toolName: string, input: ToolInput) {
140+
const tool = toolName.toLocaleLowerCase()
141+
switch (tool) {
142+
case "external_directory":
143+
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
144+
145+
case "webfetch":
146+
return stringValue(input.url)
147+
148+
case "websearch":
149+
return stringValue(input.query)
150+
151+
case "grep":
152+
case "glob":
153+
return stringValue(input.pattern)
154+
155+
case "read":
156+
case "edit":
157+
case "write":
158+
return editTitle(input)
159+
160+
default:
161+
return undefined
162+
}
163+
}
164+
165+
function editTitle(input: ToolInput) {
166+
const files = fileMetadata(input)
167+
if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath
168+
if (files.length > 1) return `${files.length} files`
169+
return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path)
170+
}
171+
172+
function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
173+
const files = fileMetadata(input)
174+
if (files.length) {
175+
return Array.from(
176+
new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))),
177+
(path) => ({ path }),
178+
)
179+
}
180+
return toLocations(toolName, input)
181+
}
182+
183+
async function permissionContent(toolName: string, input: ToolInput): Promise<ToolCallContent[]> {
184+
if (toolName.toLocaleLowerCase() !== "edit") return []
185+
186+
const files = fileMetadata(input)
187+
if (files.length) return diffContentForFiles(files)
188+
189+
const filepath = stringValue(input.filepath) ?? stringValue(input.filePath)
190+
const diff = stringValue(input.diff)
191+
if (!filepath || !diff) return []
192+
const content = await diffContentForPatch(filepath, diff)
193+
return content ? [content] : []
194+
}
195+
196+
async function diffContentForFiles(files: PermissionFileMetadata[]) {
197+
const content = await Promise.all(
198+
files.map(async (file) => {
199+
if (!file.patch) return []
200+
const content = await diffContentForPatch(file.filePath, file.patch, file.movePath)
201+
return content ? [content] : []
202+
}),
203+
)
204+
return content.flat()
205+
}
206+
207+
async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) {
208+
const content = (await exists(filepath)) ? await readText(filepath) : ""
209+
const next = applyPatch(content, diff)
210+
if (next === false) return undefined
211+
return {
212+
type: "diff" as const,
213+
path: displayPath,
214+
oldText: content,
215+
newText: next,
216+
}
217+
}
218+
114219
function selectedReply(result: RequestPermissionResponse): Reply {
115220
if (result.outcome.outcome !== "selected") return "reject"
116221
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
@@ -121,4 +226,29 @@ function stringValue(value: unknown) {
121226
return typeof value === "string" ? value : undefined
122227
}
123228

229+
type PermissionFileMetadata = {
230+
readonly filePath: string
231+
readonly relativePath?: string
232+
readonly movePath?: string
233+
readonly patch?: string
234+
}
235+
236+
function fileMetadata(input: ToolInput): PermissionFileMetadata[] {
237+
if (!Array.isArray(input.files)) return []
238+
return input.files.flatMap((file): PermissionFileMetadata[] => {
239+
if (!file || typeof file !== "object") return []
240+
const info = file as Record<string, unknown>
241+
const filePath = stringValue(info.filePath)
242+
if (!filePath) return []
243+
return [
244+
{
245+
filePath,
246+
relativePath: stringValue(info.relativePath),
247+
movePath: stringValue(info.movePath),
248+
patch: stringValue(info.patch),
249+
},
250+
]
251+
})
252+
}
253+
124254
export * as ACPPermission from "./permission"

packages/opencode/test/acp/permission.test.ts

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
1-
import { describe, expect, it } from "bun:test"
1+
import { afterEach, describe, expect, it } from "bun:test"
22
import type {
33
AgentSideConnection,
44
RequestPermissionRequest,
55
RequestPermissionResponse,
66
SessionUpdate,
77
} from "@agentclientprotocol/sdk"
88
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
9+
import { createTwoFilesPatch } from "diff"
910
import { Effect, ManagedRuntime } from "effect"
11+
import { mkdtemp, rm } from "node:fs/promises"
12+
import { tmpdir } from "node:os"
13+
import path from "node:path"
1014
import { ACPEvent } from "@/acp/event"
1115
import { ACPSession } from "@/acp/session"
1216

1317
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
1418
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
1519
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
20+
const cleanupDirs: string[] = []
21+
22+
afterEach(async () => {
23+
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
24+
})
1625

1726
const pollUntil = async (
1827
check: () => boolean | Promise<boolean>,
@@ -137,6 +146,14 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
137146
.join("")
138147
}
139148

149+
async function tempFile(name: string, content: string) {
150+
const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
151+
cleanupDirs.push(dir)
152+
const file = path.join(dir, name)
153+
await Bun.write(file, content)
154+
return file
155+
}
156+
140157
describe("acp permissions", () => {
141158
it("sends requestPermission and replies with the selected outcome", async () => {
142159
const harness = createHarness()
@@ -151,7 +168,7 @@ describe("acp permissions", () => {
151168
toolCall: {
152169
toolCallId: "call_1",
153170
status: "pending",
154-
title: "bash",
171+
title: "printf hello",
155172
rawInput: { command: "printf hello" },
156173
kind: "execute",
157174
locations: [],
@@ -165,6 +182,116 @@ describe("acp permissions", () => {
165182
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
166183
})
167184

185+
it("uses permission metadata for non-shell titles", async () => {
186+
const harness = createHarness()
187+
await createSession(harness.session, "ses_a")
188+
189+
harness.subscription.handle(
190+
permissionAsked("ses_a", "perm_fetch", {
191+
permission: "webfetch",
192+
metadata: {
193+
url: "https://example.com/docs",
194+
format: "markdown",
195+
},
196+
tool: { messageID: "msg_1", callID: "call_1" },
197+
}),
198+
)
199+
200+
await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
201+
202+
expect(harness.requests[0]?.toolCall).toMatchObject({
203+
toolCallId: "call_1",
204+
title: "https://example.com/docs",
205+
kind: "fetch",
206+
rawInput: { url: "https://example.com/docs", format: "markdown" },
207+
})
208+
})
209+
210+
it("includes a diff content block for edit permission metadata", async () => {
211+
const filepath = await tempFile("file.ts", "before\n")
212+
const harness = createHarness()
213+
await createSession(harness.session, "ses_a")
214+
215+
harness.subscription.handle(
216+
permissionAsked("ses_a", "perm_edit", {
217+
permission: "edit",
218+
metadata: {
219+
filepath,
220+
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
221+
},
222+
tool: { messageID: "msg_1", callID: "call_1" },
223+
}),
224+
)
225+
226+
await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
227+
228+
expect(harness.requests[0]?.toolCall).toMatchObject({
229+
toolCallId: "call_1",
230+
title: filepath,
231+
kind: "edit",
232+
locations: [{ path: filepath }],
233+
content: [
234+
{
235+
type: "diff",
236+
path: filepath,
237+
oldText: "before\n",
238+
newText: "after\n",
239+
},
240+
],
241+
})
242+
})
243+
244+
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
245+
const first = await tempFile("first.ts", "one\n")
246+
const second = await tempFile("second.ts", "alpha\n")
247+
const harness = createHarness()
248+
await createSession(harness.session, "ses_a")
249+
250+
harness.subscription.handle(
251+
permissionAsked("ses_a", "perm_patch", {
252+
permission: "edit",
253+
metadata: {
254+
filepath: "first.ts, second.ts",
255+
files: [
256+
{
257+
filePath: first,
258+
relativePath: "first.ts",
259+
patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
260+
},
261+
{
262+
filePath: second,
263+
relativePath: "second.ts",
264+
patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
265+
},
266+
],
267+
},
268+
tool: { messageID: "msg_1", callID: "call_1" },
269+
}),
270+
)
271+
272+
await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
273+
274+
expect(harness.requests[0]?.toolCall).toMatchObject({
275+
toolCallId: "call_1",
276+
title: "2 files",
277+
locations: [{ path: first }, { path: second }],
278+
content: [
279+
{
280+
type: "diff",
281+
path: first,
282+
oldText: "one\n",
283+
newText: "two\n",
284+
},
285+
{
286+
type: "diff",
287+
path: second,
288+
oldText: "alpha\n",
289+
newText: "beta\n",
290+
},
291+
],
292+
})
293+
})
294+
168295
it("forwards external_directory metadata and locations to requestPermission", async () => {
169296
const harness = createHarness()
170297
await createSession(harness.session, "ses_a")
@@ -189,7 +316,7 @@ describe("acp permissions", () => {
189316
toolCall: {
190317
toolCallId: "call_1",
191318
status: "pending",
192-
title: "external_directory",
319+
title: "Create external directory",
193320
rawInput: {
194321
command: "mkdir -p /tmp/outside",
195322
description: "Create external directory",

0 commit comments

Comments
 (0)