From 466b75b19d8deea761593c207d398f618ec710da Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 23 Jul 2026 21:13:46 +0530 Subject: [PATCH] feat(cli): expand acp v1 support (#38325) --- packages/cli/src/acp/agent.ts | 2 ++ packages/cli/src/acp/event.ts | 2 ++ packages/cli/src/acp/permission.ts | 3 ++- packages/cli/src/acp/service.ts | 19 +++++++++++-- packages/cli/test/acp/event-behavior.test.ts | 3 +++ packages/cli/test/acp/event.test.ts | 1 + .../acp/initialize-auth.subprocess.test.ts | 1 + .../cli/test/acp/lifecycle.subprocess.test.ts | 15 +++++++++++ .../cli/test/acp/permission-behavior.test.ts | 23 ++++++++++++++++ .../cli/test/acp/service-lifecycle.test.ts | 27 +++++++++++++++++++ 10 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp/agent.ts b/packages/cli/src/acp/agent.ts index 89ec88e8ca5..c01ec940186 100644 --- a/packages/cli/src/acp/agent.ts +++ b/packages/cli/src/acp/agent.ts @@ -5,6 +5,7 @@ import { type AuthenticateRequest, type CancelNotification, type CloseSessionRequest, + type DeleteSessionRequest, type ForkSessionRequest, type InitializeRequest, type ListSessionsRequest, @@ -27,6 +28,7 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection) newSession: (params: NewSessionRequest) => run(service.newSession(params)), loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)), listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)), + deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)), resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)), closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)), unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)), diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 3b2d7bc23cd..15513538300 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -47,6 +47,7 @@ export async function streamTurn(input: { readonly sessionID: string readonly cwd: string readonly start: TurnStart + readonly writeTextFile: boolean readonly submit: (signal: AbortSignal) => Promise readonly control: TurnControl }): Promise { @@ -169,6 +170,7 @@ export async function streamTurn(input: { tools.delete(event.data.callID) await syncEditedFiles({ connection: input.connection, + writeTextFile: input.writeTextFile, sessionID: input.sessionID, cwd: input.cwd, toolName: current.name, diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index 71086bf9095..3c1d92957e4 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -53,13 +53,14 @@ export async function replyPermission(input: { export async function syncEditedFiles(input: { readonly connection: Partial> + readonly writeTextFile: boolean readonly sessionID: string readonly cwd: string readonly toolName: string readonly toolInput: ToolInput readonly structured: Readonly> }) { - if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return + if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return const files = Array.isArray(input.structured.files) ? input.structured.files.flatMap((file): string[] => { if (!file || typeof file !== "object") return [] diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index d9bf0ddc5b6..963b5c3ed47 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -16,6 +16,8 @@ import type { CancelNotification, CloseSessionRequest, CloseSessionResponse, + DeleteSessionRequest, + DeleteSessionResponse, ForkSessionRequest, ForkSessionResponse, InitializeRequest, @@ -45,7 +47,8 @@ import { ACPError } from "./error" export const AuthMethodID = "opencode-login" -type Connection = Pick +type Connection = Pick & + Partial> type Catalog = { readonly providers: ConfigOptionProvider[] @@ -81,6 +84,7 @@ export interface Interface { newSession(input: NewSessionRequest): Promise loadSession(input: LoadSessionRequest): Promise listSessions(input: ListSessionsRequest): Promise + deleteSession(input: DeleteSessionRequest): Promise resumeSession(input: ResumeSessionRequest): Promise closeSession(input: CloseSessionRequest): Promise forkSession(input: ForkSessionRequest): Promise @@ -95,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti const catalogs = new Map>() const registeredMcp = new Map>() const active = new Map() + const capabilities = { writeTextFile: false } const catalog = (cwd: string) => { const cached = catalogs.get(cwd) @@ -154,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return { initialize: async (params) => { + capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -170,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti loadSession: true, mcpCapabilities: { http: true, sse: false }, promptCapabilities: { embeddedContext: true, image: true }, - sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, + sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} }, }, authMethods: [authMethod], agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, @@ -213,6 +219,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}), } }, + deleteSession: async (params) => { + await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => { + if (!isSessionNotFoundError(error)) throw error + }) + sessions.delete(params.sessionId) + registeredMcp.delete(params.sessionId) + return {} + }, resumeSession: async (params) => { const session = await getSession(input.client, params.sessionId) const state = await attach(session, session.location.directory, params.mcpServers ?? []) @@ -285,6 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessionID: state.id, cwd: state.cwd, start: prepared.start, + writeTextFile: capabilities.writeTextFile, control, submit: (signal) => submitPrompt(input.client, state, prepared, signal), }).finally(() => { diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 46ecc552a44..ded7b7020aa 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -439,6 +439,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel", cwd: "/workspace", start: { type: "input", id: "input_cancel" }, + writeTextFile: false, control, submit: async (signal) => { await fixture.client.session.prompt( @@ -481,6 +482,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel_admission", cwd: "/workspace", start: { type: "input", id: "input_cancel_admission" }, + writeTextFile: false, control, submit: (signal) => fixture.client.session.prompt( @@ -566,6 +568,7 @@ function turn(input: { sessionID: input.sessionID, cwd: "/workspace", start: { type: "input", id: input.inputID }, + writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts index 0075f5c913b..575705b294e 100644 --- a/packages/cli/test/acp/event.test.ts +++ b/packages/cli/test/acp/event.test.ts @@ -96,6 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => { sessionID: "ses_test", cwd: "/workspace", start: { type: "input", id }, + writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }), }) diff --git a/packages/cli/test/acp/initialize-auth.subprocess.test.ts b/packages/cli/test/acp/initialize-auth.subprocess.test.ts index 2303b5b3a13..904ceadd5a3 100644 --- a/packages/cli/test/acp/initialize-auth.subprocess.test.ts +++ b/packages/cli/test/acp/initialize-auth.subprocess.test.ts @@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => { expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false) 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/cli/test/acp/lifecycle.subprocess.test.ts b/packages/cli/test/acp/lifecycle.subprocess.test.ts index ffd0332620e..4ae855a7f38 100644 --- a/packages/cli/test/acp/lifecycle.subprocess.test.ts +++ b/packages/cli/test/acp/lifecycle.subprocess.test.ts @@ -1,5 +1,6 @@ import type { CloseSessionResponse, + DeleteSessionResponse, ListSessionsResponse, LoadSessionResponse, ResumeSessionResponse, @@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => { expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) }, 60_000) + test("delete capability and delete request", async () => { + await using fixture = await createAcpFixture() + const acp = fixture.spawn() + const initialized = await initialize(acp) + expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({}) + const session = await newSession(acp, fixture.home) + + expect( + expectOk(await acp.request("session/delete", { sessionId: session.sessionId })), + ).toEqual({}) + const listed = expectOk(await acp.request("session/list", { cwd: fixture.home })) + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false) + }, 60_000) + test("resume capability advertisement", async () => { await using fixture = await createAcpFixture() const initialized = await initialize(fixture.spawn()) diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index ad8ccd128a2..355ff5bb410 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { streamTurn } from "../../src/acp/event" +import { syncEditedFiles } from "../../src/acp/permission" import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" type SessionUpdateParams = Parameters[0] @@ -12,6 +13,27 @@ type Connection = Pick describe("acp permission behavior", () => { + test("does not sync edits when writeTextFile was not advertised", async () => { + const writes: Parameters[0][] = [] + + await syncEditedFiles({ + connection: { + writeTextFile: async (input) => { + writes.push(input) + return {} + }, + }, + writeTextFile: false, + sessionID: "ses_no_write", + cwd: "/workspace", + toolName: "edit", + toolInput: { filePath: "/workspace/file.ts" }, + structured: {}, + }) + + expect(writes).toEqual([]) + }) + test("forwards allow-once and allow-always selections to the generated client", async () => { const permissionRequests: RequestPermissionRequest[] = [] const fixture = createSseFixture({ @@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string, sessionID, cwd, start: { type: "input", id: inputID }, + writeTextFile: true, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }), }) diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts index 2c4992b8e5b..35a46a0ee2f 100644 --- a/packages/cli/test/acp/service-lifecycle.test.ts +++ b/packages/cli/test/acp/service-lifecycle.test.ts @@ -225,6 +225,33 @@ describe("acp service lifecycle", () => { "/api/session/missing/interrupt", ]) }) + + test("deletes sessions from backing and local storage", async () => { + await using fixture = makeACPFixture({ + fetch(request) { + if (request.method === "POST" && request.path === "/api/session") { + return Response.json({ data: makeSession("ses_delete") }) + } + if (request.method === "DELETE" && request.path === "/api/session/ses_delete") { + return new Response(null, { status: 204 }) + } + return undefined + }, + }) + const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }) + + expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({}) + expect(fixture.requests).toContainEqual({ + method: "DELETE", + path: "/api/session/ses_delete", + query: {}, + body: undefined, + }) + const missing = await fixture.service + .setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" }) + .catch((error: unknown) => error) + expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId }) + }) }) function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {