feat(cli): expand acp v1 support (#38325)

This commit is contained in:
Shoubhit Dash 2026-07-23 21:13:46 +05:30 committed by GitHub
commit 466b75b19d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 93 additions and 3 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,
@ -27,6 +28,7 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection)
newSession: (params: NewSessionRequest) => run(service.newSession(params)), newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)), loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)), listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)), resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)), closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)), unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),

View file

@ -47,6 +47,7 @@ export async function streamTurn(input: {
readonly sessionID: string readonly sessionID: string
readonly cwd: string readonly cwd: string
readonly start: TurnStart readonly start: TurnStart
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown> readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl readonly control: TurnControl
}): Promise<PromptResponse> { }): Promise<PromptResponse> {
@ -169,6 +170,7 @@ export async function streamTurn(input: {
tools.delete(event.data.callID) tools.delete(event.data.callID)
await syncEditedFiles({ await syncEditedFiles({
connection: input.connection, connection: input.connection,
writeTextFile: input.writeTextFile,
sessionID: input.sessionID, sessionID: input.sessionID,
cwd: input.cwd, cwd: input.cwd,
toolName: current.name, toolName: current.name,

View file

@ -53,13 +53,14 @@ export async function replyPermission(input: {
export async function syncEditedFiles(input: { export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">> readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean
readonly sessionID: string readonly sessionID: string
readonly cwd: string readonly cwd: string
readonly toolName: string readonly toolName: string
readonly toolInput: ToolInput readonly toolInput: ToolInput
readonly structured: Readonly<Record<string, unknown>> readonly structured: Readonly<Record<string, unknown>>
}) { }) {
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) const files = Array.isArray(input.structured.files)
? input.structured.files.flatMap((file): string[] => { ? input.structured.files.flatMap((file): string[] => {
if (!file || typeof file !== "object") return [] if (!file || typeof file !== "object") return []

View file

@ -16,6 +16,8 @@ import type {
CancelNotification, CancelNotification,
CloseSessionRequest, CloseSessionRequest,
CloseSessionResponse, CloseSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
ForkSessionRequest, ForkSessionRequest,
ForkSessionResponse, ForkSessionResponse,
InitializeRequest, InitializeRequest,
@ -45,7 +47,8 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login" export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Catalog = { type Catalog = {
readonly providers: ConfigOptionProvider[] readonly providers: ConfigOptionProvider[]
@ -81,6 +84,7 @@ export interface Interface {
newSession(input: NewSessionRequest): Promise<NewSessionResponse> newSession(input: NewSessionRequest): Promise<NewSessionResponse>
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse> loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse> listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse> resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse> closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse> forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
@ -95,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>() const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>() const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>() const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false }
const catalog = (cwd: string) => { const catalog = (cwd: string) => {
const cached = catalogs.get(cwd) const cached = catalogs.get(cwd)
@ -154,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return { return {
initialize: async (params) => { initialize: async (params) => {
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",
@ -170,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
loadSession: true, loadSession: true,
mcpCapabilities: { http: true, sse: false }, mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true }, promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
}, },
authMethods: [authMethod], authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, 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 } : {}), ...(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) => { resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId) const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? []) 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, sessionID: state.id,
cwd: state.cwd, cwd: state.cwd,
start: prepared.start, start: prepared.start,
writeTextFile: capabilities.writeTextFile,
control, control,
submit: (signal) => submitPrompt(input.client, state, prepared, signal), submit: (signal) => submitPrompt(input.client, state, prepared, signal),
}).finally(() => { }).finally(() => {

View file

@ -439,6 +439,7 @@ describe("acp event behavior", () => {
sessionID: "ses_cancel", sessionID: "ses_cancel",
cwd: "/workspace", cwd: "/workspace",
start: { type: "input", id: "input_cancel" }, start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control, control,
submit: async (signal) => { submit: async (signal) => {
await fixture.client.session.prompt( await fixture.client.session.prompt(
@ -481,6 +482,7 @@ describe("acp event behavior", () => {
sessionID: "ses_cancel_admission", sessionID: "ses_cancel_admission",
cwd: "/workspace", cwd: "/workspace",
start: { type: "input", id: "input_cancel_admission" }, start: { type: "input", id: "input_cancel_admission" },
writeTextFile: false,
control, control,
submit: (signal) => submit: (signal) =>
fixture.client.session.prompt( fixture.client.session.prompt(
@ -566,6 +568,7 @@ function turn(input: {
sessionID: input.sessionID, sessionID: input.sessionID,
cwd: "/workspace", cwd: "/workspace",
start: { type: "input", id: input.inputID }, start: { type: "input", id: input.inputID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() }, control: { cancelled: false, admission: new AbortController() },
submit: (signal) => submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),

View file

@ -96,6 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
sessionID: "ses_test", sessionID: "ses_test",
cwd: "/workspace", cwd: "/workspace",
start: { type: "input", id }, start: { type: "input", id },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() }, control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }), submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
}) })

View file

@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => {
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false) expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
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,5 +1,6 @@
import type { import type {
CloseSessionResponse, CloseSessionResponse,
DeleteSessionResponse,
ListSessionsResponse, ListSessionsResponse,
LoadSessionResponse, LoadSessionResponse,
ResumeSessionResponse, ResumeSessionResponse,
@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => {
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}, 60_000) }, 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<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
).toEqual({})
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000)
test("resume capability advertisement", async () => { test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture() await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn()) const initialized = await initialize(fixture.spawn())

View file

@ -4,6 +4,7 @@ import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { streamTurn } from "../../src/acp/event" import { streamTurn } from "../../src/acp/event"
import { syncEditedFiles } from "../../src/acp/permission"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0] type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@ -12,6 +13,27 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
type Fixture = ReturnType<typeof createSseFixture> type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => { describe("acp permission behavior", () => {
test("does not sync edits when writeTextFile was not advertised", async () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[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 () => { test("forwards allow-once and allow-always selections to the generated client", async () => {
const permissionRequests: RequestPermissionRequest[] = [] const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({ const fixture = createSseFixture({
@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
sessionID, sessionID,
cwd, cwd,
start: { type: "input", id: inputID }, start: { type: "input", id: inputID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() }, control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }), submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
}) })

View file

@ -225,6 +225,33 @@ describe("acp service lifecycle", () => {
"/api/session/missing/interrupt", "/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) { function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {