fix(acp): enrich permission prompts (#34079)

This commit is contained in:
Shoubhit Dash
2026-06-26 21:25:52 +05:30
committed by GitHub
parent 1aea999d7c
commit ebf4007efd
2 changed files with 269 additions and 12 deletions
+139 -9
View File
@@ -1,9 +1,16 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type {
AgentSideConnection,
PermissionOption,
RequestPermissionResponse,
ToolCallContent,
ToolCallLocation,
ToolCallUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
import { Effect } from "effect"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
@@ -54,14 +61,11 @@ export class Handler {
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCall: await permissionToolCall({
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
toolName: permission.permission,
input: permission.metadata,
}),
options: permissionOptions,
})
.catch(async () => {
@@ -111,6 +115,107 @@ export class Handler {
}
}
async function permissionToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
}): Promise<ToolCallUpdate> {
const toolCall = pendingToolCall({
toolCallId: input.toolCallId,
toolName: input.toolName,
state: {
input: input.input,
title: permissionTitle(input.toolName, input.input),
},
})
const content = await permissionContent(input.toolName, input.input)
return {
...toolCall,
locations: permissionLocations(input.toolName, input.input),
...(content.length ? { content } : {}),
}
}
function permissionTitle(toolName: string, input: ToolInput) {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "external_directory":
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
case "webfetch":
return stringValue(input.url)
case "websearch":
return stringValue(input.query)
case "grep":
case "glob":
return stringValue(input.pattern)
case "read":
case "edit":
case "write":
return editTitle(input)
default:
return undefined
}
}
function editTitle(input: ToolInput) {
const files = fileMetadata(input)
if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath
if (files.length > 1) return `${files.length} files`
return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path)
}
function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
const files = fileMetadata(input)
if (files.length) {
return Array.from(
new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))),
(path) => ({ path }),
)
}
return toLocations(toolName, input)
}
async function permissionContent(toolName: string, input: ToolInput): Promise<ToolCallContent[]> {
if (toolName.toLocaleLowerCase() !== "edit") return []
const files = fileMetadata(input)
if (files.length) return diffContentForFiles(files)
const filepath = stringValue(input.filepath) ?? stringValue(input.filePath)
const diff = stringValue(input.diff)
if (!filepath || !diff) return []
const content = await diffContentForPatch(filepath, diff)
return content ? [content] : []
}
async function diffContentForFiles(files: PermissionFileMetadata[]) {
const content = await Promise.all(
files.map(async (file) => {
if (!file.patch) return []
const content = await diffContentForPatch(file.filePath, file.patch, file.movePath)
return content ? [content] : []
}),
)
return content.flat()
}
async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) {
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) return undefined
return {
type: "diff" as const,
path: displayPath,
oldText: content,
newText: next,
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
@@ -121,4 +226,29 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
type PermissionFileMetadata = {
readonly filePath: string
readonly relativePath?: string
readonly movePath?: string
readonly patch?: string
}
function fileMetadata(input: ToolInput): PermissionFileMetadata[] {
if (!Array.isArray(input.files)) return []
return input.files.flatMap((file): PermissionFileMetadata[] => {
if (!file || typeof file !== "object") return []
const info = file as Record<string, unknown>
const filePath = stringValue(info.filePath)
if (!filePath) return []
return [
{
filePath,
relativePath: stringValue(info.relativePath),
movePath: stringValue(info.movePath),
patch: stringValue(info.patch),
},
]
})
}
export * as ACPPermission from "./permission"