From 26d3f2f1e5166e0d7bd46d8b045c0830e234a63e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 22 Jul 2026 19:25:21 +0530 Subject: [PATCH] feat(opencode): expand acp v1 support --- packages/opencode/src/acp/agent.ts | 5 +++ packages/opencode/src/acp/event.ts | 8 +++- packages/opencode/src/acp/permission.ts | 4 +- packages/opencode/src/acp/service.ts | 28 +++++++++++++- packages/opencode/test/acp/event.test.ts | 7 +++- packages/opencode/test/acp/permission.test.ts | 38 +++++++++++++++++-- .../opencode/test/acp/service-session.test.ts | 16 ++++++++ .../test/cli/acp/initialize-auth.test.ts | 1 + .../opencode/test/cli/acp/lifecycle.test.ts | 20 ++++++++++ 9 files changed, 120 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 3f2d745b67..3f4ca8ade9 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -5,6 +5,7 @@ import { type AuthenticateRequest, type CancelNotification, type CloseSessionRequest, + type DeleteSessionRequest, type ForkSessionRequest, type InitializeRequest, type ListSessionsRequest, @@ -51,6 +52,10 @@ export class Agent implements ACPAgent { return run(this.service.listSessions(params)) } + deleteSession(params: DeleteSessionRequest) { + return run(this.service.deleteSession(params)) + } + resumeSession(params: ResumeSessionRequest) { return run(this.service.resumeSession(params)) } diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index 7d05fa6ee5..2dccd33b14 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -30,7 +30,12 @@ type GlobalEventStream = { stream: AsyncIterable } -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) subscription.start() return subscription @@ -48,6 +53,7 @@ export class Subscription { sdk: OpencodeClient connection: Connection session: ACPSession.Interface + capabilities: ACPPermission.Capabilities }, ) { this.permission = new ACPPermission.Handler(input) diff --git a/packages/opencode/src/acp/permission.ts b/packages/opencode/src/acp/permission.ts index 4eeca28f09..0d03cf06ba 100644 --- a/packages/opencode/src/acp/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -16,6 +16,7 @@ import { Effect } from "effect" type PermissionEvent = Extract type Reply = "once" | "always" | "reject" type Connection = Partial> +export type Capabilities = { writeTextFile: boolean } const permissionOptions: PermissionOption[] = [ { optionId: "once", kind: "allow_once", name: "Allow once" }, @@ -31,6 +32,7 @@ export class Handler { sdk: OpencodeClient connection: Connection session: ACPSession.Interface + capabilities: Capabilities }, ) {} @@ -99,7 +101,7 @@ export class Handler { private async writeProposedEdit(sessionId: string, metadata: ToolInput) { const filepath = stringValue(metadata.filepath) 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 next = applyPatch(content, diff) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 86e9eaafaa..2bfdabc21c 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -6,6 +6,8 @@ import { type CancelNotification, type CloseSessionRequest, type CloseSessionResponse, + type DeleteSessionRequest, + type DeleteSessionResponse, type ForkSessionRequest, type ForkSessionResponse, type InitializeRequest, @@ -56,6 +58,7 @@ export type Interface = { readonly newSession: (input: NewSessionRequest) => Effect.Effect readonly loadSession: (input: LoadSessionRequest) => Effect.Effect readonly listSessions: (input: ListSessionsRequest) => Effect.Effect + readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect readonly closeSession: (input: CloseSessionRequest) => Effect.Effect readonly forkSession: (input: ForkSessionRequest) => Effect.Effect @@ -81,13 +84,15 @@ export function make(input: { const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() const sessionSnapshots = new Map() + const capabilities = { writeTextFile: false } const events = input.connection - ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) + ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session, capabilities }) : undefined if (events) input.eventSubscription?.(events) const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { const started = performance.now() + capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -118,6 +123,7 @@ export function make(input: { }, sessionCapabilities: { close: {}, + delete: {}, fork: {}, list: {}, 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 snapshot = yield* directorySnapshot(params.cwd) yield* request( @@ -465,6 +490,7 @@ export function make(input: { newSession, loadSession, listSessions, + deleteSession, resumeSession, closeSession, forkSession, diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 8271b28265..e17798d27d 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -108,7 +108,12 @@ function createHarness(messages: Record = {}) { }, } satisfies Pick 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 } } diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index 99da5e6ac1..bb5cad9995 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -46,10 +46,12 @@ function makeSessionService() { function createHarness( requestPermission: (params: RequestPermissionRequest) => Promise = () => Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }), + writeTextFile = false, ) { const replies: PermissionReplyParams[] = [] const requests: RequestPermissionRequest[] = [] const updates: SessionUpdateParams[] = [] + const writes: Parameters[0][] = [] const session = makeSessionService() const sdk = { permission: { @@ -71,10 +73,19 @@ function createHarness( updates.push(params) return Promise.resolve() }, - } satisfies Pick - const subscription = new ACPEvent.Subscription({ sdk, connection, session }) + writeTextFile: (params: Parameters[0]) => { + writes.push(params) + return Promise.resolve({}) + }, + } satisfies Pick + 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") { @@ -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 () => { const first = await tempFile("first.ts", "one\n") const second = await tempFile("second.ts", "alpha\n") diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index caf75d8aa5..9d9dc95a4b 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -152,6 +152,7 @@ describe("ACP service sessions", () => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] const aborts: string[] = [] + const deletes: string[] = [] const forks: string[] = [] const prompts: unknown[] = [] const commands: unknown[] = [] @@ -196,6 +197,10 @@ describe("ACP service sessions", () => { data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, }), messages: () => Promise.resolve({ data: messages }), + delete: (input: { sessionID: string }) => { + deletes.push(input.sessionID) + return Promise.resolve({ data: true }) + }, prompt: options?.prompt ?? ((input: unknown) => { @@ -268,6 +273,7 @@ describe("ACP service sessions", () => { updates, mcpAdds, aborts, + deletes, forks, prompts, commands, @@ -382,6 +388,16 @@ describe("ACP service sessions", () => { 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 () => { const { service } = makeService() const first = await Effect.runPromise(service.listSessions({})) diff --git a/packages/opencode/test/cli/acp/initialize-auth.test.ts b/packages/opencode/test/cli/acp/initialize-auth.test.ts index 709c27f3a3..30aeff47a4 100644 --- a/packages/opencode/test/cli/acp/initialize-auth.test.ts +++ b/packages/opencode/test/cli/acp/initialize-auth.test.ts @@ -18,6 +18,7 @@ describe("opencode acp initialize/auth subprocess", () => { expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true) expect(initialized.agentCapabilities?.loadSession).toBe(true) expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) diff --git a/packages/opencode/test/cli/acp/lifecycle.test.ts b/packages/opencode/test/cli/acp/lifecycle.test.ts index 9f2558ea2f..1a4be179d8 100644 --- a/packages/opencode/test/cli/acp/lifecycle.test.ts +++ b/packages/opencode/test/cli/acp/lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import type { CloseSessionResponse, + DeleteSessionResponse, ListSessionsResponse, LoadSessionResponse, ResumeSessionResponse, @@ -82,6 +83,25 @@ describe("opencode acp lifecycle subprocess", () => { 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("session/delete", { sessionId: session.sessionId })) + const listed = expectOk(yield* acp.request("session/list", { cwd: home })) + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false) + }), + 60_000, + ) + cliIt.live( "resume capability advertisement", ({ opencode }) =>