feat(cli): expand acp v1 support (#38325)
This commit is contained in:
parent
833dd2ed7f
commit
466b75b19d
10 changed files with 93 additions and 3 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,
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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" }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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({})
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue