feat(opencode): expand acp v1 support

This commit is contained in:
Shoubhit Dash 2026-07-22 19:25:21 +05:30
commit 26d3f2f1e5
9 changed files with 120 additions and 7 deletions

View file

@ -5,6 +5,7 @@ import {
type AuthenticateRequest, type AuthenticateRequest,
type CancelNotification, type CancelNotification,
type CloseSessionRequest, type CloseSessionRequest,
type DeleteSessionRequest,
type ForkSessionRequest, type ForkSessionRequest,
type InitializeRequest, type InitializeRequest,
type ListSessionsRequest, type ListSessionsRequest,
@ -51,6 +52,10 @@ export class Agent implements ACPAgent {
return run(this.service.listSessions(params)) return run(this.service.listSessions(params))
} }
deleteSession(params: DeleteSessionRequest) {
return run(this.service.deleteSession(params))
}
resumeSession(params: ResumeSessionRequest) { resumeSession(params: ResumeSessionRequest) {
return run(this.service.resumeSession(params)) return run(this.service.resumeSession(params))
} }

View file

@ -30,7 +30,12 @@ type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope> stream: AsyncIterable<GlobalEventEnvelope>
} }
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) { export function start(input: {
sdk: OpencodeClient
connection: Connection
session: ACPSession.Interface
capabilities: ACPPermission.Capabilities
}) {
const subscription = new Subscription(input) const subscription = new Subscription(input)
subscription.start() subscription.start()
return subscription return subscription
@ -48,6 +53,7 @@ export class Subscription {
sdk: OpencodeClient sdk: OpencodeClient
connection: Connection connection: Connection
session: ACPSession.Interface session: ACPSession.Interface
capabilities: ACPPermission.Capabilities
}, },
) { ) {
this.permission = new ACPPermission.Handler(input) this.permission = new ACPPermission.Handler(input)

View file

@ -16,6 +16,7 @@ import { Effect } from "effect"
type PermissionEvent = Extract<Event, { type: "permission.asked" }> type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject" type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">> type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
export type Capabilities = { writeTextFile: boolean }
const permissionOptions: PermissionOption[] = [ const permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "once", kind: "allow_once", name: "Allow once" },
@ -31,6 +32,7 @@ export class Handler {
sdk: OpencodeClient sdk: OpencodeClient
connection: Connection connection: Connection
session: ACPSession.Interface session: ACPSession.Interface
capabilities: Capabilities
}, },
) {} ) {}
@ -99,7 +101,7 @@ export class Handler {
private async writeProposedEdit(sessionId: string, metadata: ToolInput) { private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
const filepath = stringValue(metadata.filepath) const filepath = stringValue(metadata.filepath)
const diff = stringValue(metadata.diff) const diff = stringValue(metadata.diff)
if (!filepath || !diff || !this.input.connection.writeTextFile) return if (!filepath || !diff || !this.input.capabilities.writeTextFile || !this.input.connection.writeTextFile) return
const content = (await exists(filepath)) ? await readText(filepath) : "" const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff) const next = applyPatch(content, diff)

View file

@ -6,6 +6,8 @@ import {
type CancelNotification, type CancelNotification,
type CloseSessionRequest, type CloseSessionRequest,
type CloseSessionResponse, type CloseSessionResponse,
type DeleteSessionRequest,
type DeleteSessionResponse,
type ForkSessionRequest, type ForkSessionRequest,
type ForkSessionResponse, type ForkSessionResponse,
type InitializeRequest, type InitializeRequest,
@ -56,6 +58,7 @@ export type Interface = {
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error> readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error> readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Error> readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Error>
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, Error>
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Error> readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Error>
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Error> readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Error>
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Error> readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Error>
@ -81,13 +84,15 @@ export function make(input: {
const directoryService = input.directory ?? makeDirectoryService(input.sdk) const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>() const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>() const sessionSnapshots = new Map<string, Directory.Snapshot>()
const capabilities = { writeTextFile: false }
const events = input.connection const events = input.connection
? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session, capabilities })
: undefined : undefined
if (events) input.eventSubscription?.(events) if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) {
const started = performance.now() const started = performance.now()
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
const authMethod: AuthMethod = { const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal", description: "Run `opencode auth login` in the terminal",
name: "Login with opencode", name: "Login with opencode",
@ -118,6 +123,7 @@ export function make(input: {
}, },
sessionCapabilities: { sessionCapabilities: {
close: {}, close: {},
delete: {},
fork: {}, fork: {},
list: {}, list: {},
resume: {}, resume: {},
@ -284,6 +290,25 @@ export function make(input: {
} }
}) })
const deleteSession = Effect.fn("ACP.deleteSession")(function* (params: DeleteSessionRequest) {
const current = yield* session.tryGet(params.sessionId)
yield* request(
() =>
input.sdk.session.delete(
{
sessionID: params.sessionId,
...(current ? { directory: current.cwd } : {}),
},
{ throwOnError: true },
),
"session",
)
yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
return {}
})
const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) { const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd) const snapshot = yield* directorySnapshot(params.cwd)
yield* request( yield* request(
@ -465,6 +490,7 @@ export function make(input: {
newSession, newSession,
loadSession, loadSession,
listSessions, listSessions,
deleteSession,
resumeSession, resumeSession,
closeSession, closeSession,
forkSession, forkSession,

View file

@ -108,7 +108,12 @@ function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
}, },
} satisfies Pick<AgentSideConnection, "sessionUpdate"> } satisfies Pick<AgentSideConnection, "sessionUpdate">
const session = makeSessionService() const session = makeSessionService()
const subscription = new ACPEvent.Subscription({ sdk, connection, session }) const subscription = new ACPEvent.Subscription({
sdk,
connection,
session,
capabilities: { writeTextFile: false },
})
return { calls, connection, events, sdk, session, subscription, updates } return { calls, connection, events, sdk, session, subscription, updates }
} }

View file

@ -46,10 +46,12 @@ function makeSessionService() {
function createHarness( function createHarness(
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () => requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }), Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
writeTextFile = false,
) { ) {
const replies: PermissionReplyParams[] = [] const replies: PermissionReplyParams[] = []
const requests: RequestPermissionRequest[] = [] const requests: RequestPermissionRequest[] = []
const updates: SessionUpdateParams[] = [] const updates: SessionUpdateParams[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const session = makeSessionService() const session = makeSessionService()
const sdk = { const sdk = {
permission: { permission: {
@ -71,10 +73,19 @@ function createHarness(
updates.push(params) updates.push(params)
return Promise.resolve() return Promise.resolve()
}, },
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate"> writeTextFile: (params: Parameters<AgentSideConnection["writeTextFile"]>[0]) => {
const subscription = new ACPEvent.Subscription({ sdk, connection, session }) writes.push(params)
return Promise.resolve({})
},
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate" | "writeTextFile">
const subscription = new ACPEvent.Subscription({
sdk,
connection,
session,
capabilities: { writeTextFile },
})
return { connection, replies, requests, sdk, session, subscription, updates } return { connection, replies, requests, sdk, session, subscription, updates, writes }
} }
async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") { async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
@ -242,6 +253,27 @@ describe("acp permissions", () => {
}) })
}) })
it("syncs proposed edits only when the client advertised writeTextFile", async () => {
const filepath = await tempFile("sync.ts", "before\n")
const metadata = {
filepath,
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
}
const unsupported = createHarness(undefined, false)
const supported = createHarness(undefined, true)
await createSession(unsupported.session, "ses_unsupported")
await createSession(supported.session, "ses_supported")
unsupported.subscription.handle(
permissionAsked("ses_unsupported", "perm_unsupported", { permission: "edit", metadata }),
)
supported.subscription.handle(permissionAsked("ses_supported", "perm_supported", { permission: "edit", metadata }))
await pollUntil(() => unsupported.replies.length === 1 && supported.replies.length === 1, "edits were not replied")
expect(unsupported.writes).toEqual([])
expect(supported.writes).toEqual([{ sessionId: "ses_supported", path: filepath, content: "after\n" }])
})
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => { it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
const first = await tempFile("first.ts", "one\n") const first = await tempFile("first.ts", "one\n")
const second = await tempFile("second.ts", "alpha\n") const second = await tempFile("second.ts", "alpha\n")

View file

@ -152,6 +152,7 @@ describe("ACP service sessions", () => {
const updates: SessionNotification[] = [] const updates: SessionNotification[] = []
const mcpAdds: string[] = [] const mcpAdds: string[] = []
const aborts: string[] = [] const aborts: string[] = []
const deletes: string[] = []
const forks: string[] = [] const forks: string[] = []
const prompts: unknown[] = [] const prompts: unknown[] = []
const commands: unknown[] = [] const commands: unknown[] = []
@ -196,6 +197,10 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}), }),
messages: () => Promise.resolve({ data: messages }), messages: () => Promise.resolve({ data: messages }),
delete: (input: { sessionID: string }) => {
deletes.push(input.sessionID)
return Promise.resolve({ data: true })
},
prompt: prompt:
options?.prompt ?? options?.prompt ??
((input: unknown) => { ((input: unknown) => {
@ -268,6 +273,7 @@ describe("ACP service sessions", () => {
updates, updates,
mcpAdds, mcpAdds,
aborts, aborts,
deletes,
forks, forks,
prompts, prompts,
commands, commands,
@ -382,6 +388,16 @@ describe("ACP service sessions", () => {
expect(listed.sessions[0]?.cwd).toBe("/workspace") expect(listed.sessions[0]?.cwd).toBe("/workspace")
}) })
it("deletes sessions from backing and local storage", async () => {
const { service, deletes } = makeService()
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(await Effect.runPromise(service.deleteSession({ sessionId: created.sessionId }))).toEqual({})
expect(deletes).toEqual([created.sessionId])
const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
expect(listed.sessions.some((item) => item.sessionId === created.sessionId)).toBe(false)
})
it("lists all sessions with next cursor when the first page is full", async () => { it("lists all sessions with next cursor when the first page is full", async () => {
const { service } = makeService() const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({})) const first = await Effect.runPromise(service.listSessions({}))

View file

@ -18,6 +18,7 @@ describe("opencode acp initialize/auth subprocess", () => {
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true) expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { import type {
CloseSessionResponse, CloseSessionResponse,
DeleteSessionResponse,
ListSessionsResponse, ListSessionsResponse,
LoadSessionResponse, LoadSessionResponse,
ResumeSessionResponse, ResumeSessionResponse,
@ -82,6 +83,25 @@ describe("opencode acp lifecycle subprocess", () => {
60_000, 60_000,
) )
cliIt.live(
"delete capability and delete request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
const session = yield* newSession(acp, home)
expectOk(yield* acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId }))
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}),
60_000,
)
cliIt.live( cliIt.live(
"resume capability advertisement", "resume capability advertisement",
({ opencode }) => ({ opencode }) =>