feat(opencode): expand acp v1 support
This commit is contained in:
parent
bb84f71338
commit
26d3f2f1e5
9 changed files with 120 additions and 7 deletions
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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({}))
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
||||||
|
|
|
||||||
|
|
@ -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 }) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue