From bb84f7133828ea5ef96399d1bd8f394b4080f328 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 22 Jul 2026 19:10:29 +0530 Subject: [PATCH 1/2] chore(opencode): upgrade acp sdk --- bun.lock | 4 +-- packages/opencode/package.json | 2 +- packages/opencode/src/acp/agent.ts | 5 --- packages/opencode/src/acp/service.ts | 33 +++---------------- .../opencode/test/acp/service-session.test.ts | 2 -- 5 files changed, 8 insertions(+), 38 deletions(-) diff --git a/bun.lock b/bun.lock index 5c7a792abc..b69009836c 100644 --- a/bun.lock +++ b/bun.lock @@ -565,7 +565,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.21.0", + "@agentclientprotocol/sdk": "1.2.1", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", @@ -1167,7 +1167,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e702acf8cf..fd6729bfff 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -54,7 +54,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.21.0", + "@agentclientprotocol/sdk": "1.2.1", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index a7c59a2615..3f2d745b67 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -13,7 +13,6 @@ import { type PromptRequest, type ResumeSessionRequest, type SetSessionConfigOptionRequest, - type SetSessionModelRequest, type SetSessionModeRequest, } from "@agentclientprotocol/sdk" import { Effect } from "effect" @@ -72,10 +71,6 @@ export class Agent implements ACPAgent { return run(this.service.setSessionMode(params)) } - unstable_setSessionModel(params: SetSessionModelRequest) { - return run(this.service.setSessionModel(params)) - } - prompt(params: PromptRequest) { return run(this.service.prompt(params)) } diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 7257efa765..86e9eaafaa 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -24,8 +24,6 @@ import { type SessionInfo, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, - type SetSessionModelRequest, - type SetSessionModelResponse, type SetSessionModeRequest, type SetSessionModeResponse, } from "@agentclientprotocol/sdk" @@ -65,7 +63,6 @@ export type Interface = { input: SetSessionConfigOptionRequest, ) => Effect.Effect readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect - readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect readonly prompt: (input: PromptRequest) => Effect.Effect readonly cancel: (input: CancelNotification) => Effect.Effect } @@ -462,21 +459,6 @@ export function make(input: { return {} }) - const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) { - const current = yield* session.get(params.sessionId) - const snapshot = yield* configSnapshot(current) - const selected = yield* parseSelectedModel(snapshot, params.modelId) - yield* session - .setVariant( - params.sessionId, - Directory.variants(snapshot, selected.model) - ? (selected.variant ?? selectVariant(snapshot, selected.model)) - : undefined, - ) - .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) - return {} - }) - return { initialize, authenticate, @@ -488,7 +470,6 @@ export function make(input: { forkSession, setSessionConfigOption, setSessionMode, - setSessionModel, prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* directorySnapshot(current.cwd) @@ -521,7 +502,7 @@ export function make(input: { "session", ) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return yield* promptResponse(response.info, params.messageId) + return yield* promptResponse(response.info) } const known = snapshot.availableCommands.find((item) => item.name === command.name) @@ -543,7 +524,7 @@ export function make(input: { "session", ) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return yield* promptResponse(response.info, params.messageId) + return yield* promptResponse(response.info) } if (command.name === "compact") { @@ -563,7 +544,7 @@ export function make(input: { } yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) - return yield* promptResponse(undefined, params.messageId) + return yield* promptResponse(undefined) }), cancel, } @@ -813,22 +794,17 @@ function detectSlashCommand(parts: ReturnType) { return { name, args: rest.join(" ").trim() } } -const promptResponse = Effect.fn("ACP.promptResponse")(function* ( - info: AssistantInfo, - messageId: string | null | undefined, -) { +const promptResponse = Effect.fn("ACP.promptResponse")(function* (info: AssistantInfo) { if (!info?.error) { return { stopReason: "end_turn" as const, ...(info ? { usage: UsageService.buildUsage(info) } : {}), - ...(messageId ? { userMessageId: messageId } : {}), _meta: {}, } } const base = { usage: UsageService.buildUsage(info), - ...(messageId ? { userMessageId: messageId } : {}), _meta: {}, } @@ -1004,6 +980,7 @@ function mcpRegistrationKey(name: string, config: ReturnType) function mcpConfig(server: McpServer) { if ("type" in server) { + if (server.type === "acp") throw new Error("MCP-over-ACP is not supported") return { type: "remote" as const, url: server.url, diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 8dd25492c0..caf75d8aa5 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -987,7 +987,6 @@ describe("ACP service sessions", () => { const result = await Effect.runPromise( service.prompt({ sessionId: session.sessionId, - messageId: "00000000-0000-4000-8000-000000000001", prompt: [{ type: "text", text: "hello" }], }), ) @@ -1012,7 +1011,6 @@ describe("ACP service sessions", () => { cachedWriteTokens: 13, totalTokens: 171, }, - userMessageId: "00000000-0000-4000-8000-000000000001", _meta: {}, }) expect(usageUpdates).toEqual([session.sessionId]) From 26d3f2f1e5166e0d7bd46d8b045c0830e234a63e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 22 Jul 2026 19:25:21 +0530 Subject: [PATCH 2/2] 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 }) =>