+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
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"],
|
|
])
|
|
})
|
|
})
|