402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it } from "bun:test"
|
|
import type {
|
|
AgentSideConnection,
|
|
RequestPermissionRequest,
|
|
RequestPermissionResponse,
|
|
SessionUpdate,
|
|
} from "@agentclientprotocol/sdk"
|
|
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { createTwoFilesPatch } from "diff"
|
|
import { Effect, ManagedRuntime } from "effect"
|
|
import { mkdtemp, rm } from "node:fs/promises"
|
|
import { tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
import { ACPEvent } from "@/acp/event"
|
|
import { ACPSession } from "@/acp/session"
|
|
|
|
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
|
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
|
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
|
const cleanupDirs: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
|
})
|
|
|
|
const pollUntil = async (
|
|
check: () => boolean | Promise<boolean>,
|
|
message: string,
|
|
opts?: { timeoutMs?: number; intervalMs?: number },
|
|
) => {
|
|
const started = Date.now()
|
|
while (true) {
|
|
if (await check()) return
|
|
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
|
|
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
|
|
}
|
|
}
|
|
|
|
function makeSessionService() {
|
|
return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync(
|
|
ACPSession.Service.use((service) => Effect.succeed(service)),
|
|
)
|
|
}
|
|
|
|
function createHarness(
|
|
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
|
|
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
|
|
) {
|
|
const replies: PermissionReplyParams[] = []
|
|
const requests: RequestPermissionRequest[] = []
|
|
const updates: SessionUpdateParams[] = []
|
|
const session = makeSessionService()
|
|
const sdk = {
|
|
permission: {
|
|
reply: (params: PermissionReplyParams) => {
|
|
replies.push(params)
|
|
return Promise.resolve({ data: true })
|
|
},
|
|
},
|
|
session: {
|
|
message: () => Promise.resolve({ data: undefined }),
|
|
},
|
|
} as unknown as OpencodeClient
|
|
const connection = {
|
|
requestPermission: (params: RequestPermissionRequest) => {
|
|
requests.push(params)
|
|
return requestPermission(params)
|
|
},
|
|
sessionUpdate: (params: SessionUpdateParams) => {
|
|
updates.push(params)
|
|
return Promise.resolve()
|
|
},
|
|
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
|
|
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
|
|
|
|
return { connection, replies, requests, sdk, session, subscription, updates }
|
|
}
|
|
|
|
async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
|
|
await Effect.runPromise(session.create({ id: sessionId, cwd }))
|
|
}
|
|
|
|
async function createKnownTextPart(
|
|
session: ACPSession.Interface,
|
|
sessionId: string,
|
|
messageId: string,
|
|
partId: string,
|
|
) {
|
|
await Effect.runPromise(
|
|
session.recordPartMetadata({
|
|
sessionId,
|
|
messageId,
|
|
partId,
|
|
partType: "text",
|
|
role: "assistant",
|
|
}),
|
|
)
|
|
}
|
|
|
|
function permissionAsked(
|
|
sessionID: string,
|
|
id: string,
|
|
input: {
|
|
permission?: string
|
|
metadata?: Record<string, unknown>
|
|
tool?: { messageID: string; callID: string }
|
|
} = {},
|
|
) {
|
|
return {
|
|
id: `evt_${id}`,
|
|
type: "permission.asked",
|
|
properties: {
|
|
id,
|
|
sessionID,
|
|
permission: input.permission ?? "bash",
|
|
patterns: ["*"],
|
|
metadata: input.metadata ?? { command: "printf hello" },
|
|
always: [],
|
|
...(input.tool ? { tool: input.tool } : {}),
|
|
},
|
|
} as PermissionEvent
|
|
}
|
|
|
|
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
|
|
return {
|
|
id: `evt_${sessionID}_${messageID}_${partID}`,
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID,
|
|
messageID,
|
|
partID,
|
|
field: "text",
|
|
delta,
|
|
},
|
|
} as Event
|
|
}
|
|
|
|
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
|
return updates
|
|
.filter((item) => item.sessionId === sessionId)
|
|
.map((item) => item.update)
|
|
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
|
|
return update.sessionUpdate === "agent_message_chunk"
|
|
})
|
|
.map((update) => (update.content.type === "text" ? update.content.text : ""))
|
|
.join("")
|
|
}
|
|
|
|
async function tempFile(name: string, content: string) {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
|
|
cleanupDirs.push(dir)
|
|
const file = path.join(dir, name)
|
|
await Bun.write(file, content)
|
|
return file
|
|
}
|
|
|
|
describe("acp permissions", () => {
|
|
it("sends requestPermission and replies with the selected outcome", async () => {
|
|
const harness = createHarness()
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
|
|
|
|
expect(harness.requests[0]).toMatchObject({
|
|
sessionId: "ses_a",
|
|
toolCall: {
|
|
toolCallId: "call_1",
|
|
status: "pending",
|
|
title: "printf hello",
|
|
rawInput: { command: "printf hello" },
|
|
kind: "execute",
|
|
locations: [],
|
|
},
|
|
options: [
|
|
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
|
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
|
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
|
],
|
|
})
|
|
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
|
})
|
|
|
|
it("uses permission metadata for non-shell titles", async () => {
|
|
const harness = createHarness()
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(
|
|
permissionAsked("ses_a", "perm_fetch", {
|
|
permission: "webfetch",
|
|
metadata: {
|
|
url: "https://example.com/docs",
|
|
format: "markdown",
|
|
},
|
|
tool: { messageID: "msg_1", callID: "call_1" },
|
|
}),
|
|
)
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
|
|
|
|
expect(harness.requests[0]?.toolCall).toMatchObject({
|
|
toolCallId: "call_1",
|
|
title: "https://example.com/docs",
|
|
kind: "fetch",
|
|
rawInput: { url: "https://example.com/docs", format: "markdown" },
|
|
})
|
|
})
|
|
|
|
it("includes a diff content block for edit permission metadata", async () => {
|
|
const filepath = await tempFile("file.ts", "before\n")
|
|
const harness = createHarness()
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(
|
|
permissionAsked("ses_a", "perm_edit", {
|
|
permission: "edit",
|
|
metadata: {
|
|
filepath,
|
|
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
|
|
},
|
|
tool: { messageID: "msg_1", callID: "call_1" },
|
|
}),
|
|
)
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
|
|
|
|
expect(harness.requests[0]?.toolCall).toMatchObject({
|
|
toolCallId: "call_1",
|
|
title: filepath,
|
|
kind: "edit",
|
|
locations: [{ path: filepath }],
|
|
content: [
|
|
{
|
|
type: "diff",
|
|
path: filepath,
|
|
oldText: "before\n",
|
|
newText: "after\n",
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
|
|
const first = await tempFile("first.ts", "one\n")
|
|
const second = await tempFile("second.ts", "alpha\n")
|
|
const harness = createHarness()
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(
|
|
permissionAsked("ses_a", "perm_patch", {
|
|
permission: "edit",
|
|
metadata: {
|
|
filepath: "first.ts, second.ts",
|
|
files: [
|
|
{
|
|
filePath: first,
|
|
relativePath: "first.ts",
|
|
patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
|
|
},
|
|
{
|
|
filePath: second,
|
|
relativePath: "second.ts",
|
|
patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
|
|
},
|
|
],
|
|
},
|
|
tool: { messageID: "msg_1", callID: "call_1" },
|
|
}),
|
|
)
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
|
|
|
|
expect(harness.requests[0]?.toolCall).toMatchObject({
|
|
toolCallId: "call_1",
|
|
title: "2 files",
|
|
locations: [{ path: first }, { path: second }],
|
|
content: [
|
|
{
|
|
type: "diff",
|
|
path: first,
|
|
oldText: "one\n",
|
|
newText: "two\n",
|
|
},
|
|
{
|
|
type: "diff",
|
|
path: second,
|
|
oldText: "alpha\n",
|
|
newText: "beta\n",
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it("forwards external_directory metadata and locations to requestPermission", async () => {
|
|
const harness = createHarness()
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(
|
|
permissionAsked("ses_a", "perm_external", {
|
|
permission: "external_directory",
|
|
metadata: {
|
|
command: "mkdir -p /tmp/outside",
|
|
description: "Create external directory",
|
|
directories: ["/tmp/outside"],
|
|
patterns: ["/tmp/outside/*"],
|
|
},
|
|
tool: { messageID: "msg_1", callID: "call_1" },
|
|
}),
|
|
)
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "external_directory permission was never replied")
|
|
|
|
expect(harness.requests[0]).toMatchObject({
|
|
sessionId: "ses_a",
|
|
toolCall: {
|
|
toolCallId: "call_1",
|
|
status: "pending",
|
|
title: "Create external directory",
|
|
rawInput: {
|
|
command: "mkdir -p /tmp/outside",
|
|
description: "Create external directory",
|
|
directories: ["/tmp/outside"],
|
|
patterns: ["/tmp/outside/*"],
|
|
},
|
|
locations: [{ path: "/tmp/outside" }],
|
|
},
|
|
})
|
|
})
|
|
|
|
it("rejects non-selected outcomes", async () => {
|
|
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
|
|
|
|
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
|
|
})
|
|
|
|
it("rejects when requestPermission fails", async () => {
|
|
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
|
|
|
|
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
|
|
|
|
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
|
|
})
|
|
|
|
it("does not let a blocked session A permission block session B message updates", async () => {
|
|
let releasePermission: (() => void) | undefined
|
|
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
|
|
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
|
})
|
|
const harness = createHarness(() => blocked)
|
|
await createSession(harness.session, "ses_a")
|
|
await createSession(harness.session, "ses_b")
|
|
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
|
|
|
|
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
|
|
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
|
|
|
|
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
|
|
|
|
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
|
|
expect(harness.replies).toHaveLength(0)
|
|
|
|
releasePermission?.()
|
|
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
|
|
})
|
|
|
|
it("serializes permission requests per session", async () => {
|
|
let releaseFirst: (() => void) | undefined
|
|
const first = new Promise<RequestPermissionResponse>((resolve) => {
|
|
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
|
})
|
|
const harness = createHarness(() =>
|
|
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
|
|
)
|
|
await createSession(harness.session, "ses_a")
|
|
|
|
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
|
|
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
|
|
|
|
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
|
|
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
|
|
|
|
releaseFirst?.()
|
|
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
|
|
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
|
|
|
|
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
|
|
["perm_1", "once"],
|
|
["perm_2", "always"],
|
|
])
|
|
})
|
|
})
|