feat(acp): add initial acp-next skeleton behind runtime flag (#29226)

This commit is contained in:
Shoubhit Dash
2026-05-25 20:33:40 +05:30
committed by GitHub
parent 8077e8a9d1
commit 7060cfa59b
5 changed files with 271 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
import {
RequestError,
type Agent as ACPAgent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type InitializeRequest,
type NewSessionRequest,
type PromptRequest,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import * as ACPNextService from "./service"
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
return {
create: (_connection: AgentSideConnection) => {
return new Agent(ACPNextService.make())
},
}
}
export class Agent implements ACPAgent {
constructor(private readonly service: ACPNextService.Interface) {}
initialize(params: InitializeRequest) {
return run(this.service.initialize(params))
}
authenticate(params: AuthenticateRequest) {
return run(this.service.authenticate(params))
}
newSession(params: NewSessionRequest) {
return run(this.service.newSession(params))
}
prompt(params: PromptRequest) {
return run(this.service.prompt(params))
}
cancel(params: CancelNotification) {
return run(this.service.cancel(params))
}
}
function run<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
return Effect.runPromise(effect.pipe(Effect.mapError(toRequestError)))
}
function toRequestError(error: ACPNextService.Error) {
switch (error._tag) {
case "ACPNextUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPNextUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
}
}
export * as ACPNext from "./agent"

View File

@@ -0,0 +1,102 @@
import {
type AuthenticateRequest,
type AuthenticateResponse,
type AuthMethod,
type CancelNotification,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Context, Effect, Schema } from "effect"
export const AuthMethodID = "opencode-login"
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPNextUnknownAuthMethodError",
{
methodId: Schema.String,
},
) {}
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
"ACPNextUnsupportedOperationError",
{
method: Schema.String,
},
) {}
export type Error = UnknownAuthMethodError | UnsupportedOperationError
export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
export function make(): Interface {
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: AuthMethodID,
}
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
authMethod._meta = {
"terminal-auth": {
command: "opencode",
args: ["auth", "login"],
label: "OpenCode Login",
},
}
}
return {
protocolVersion: 1,
agentCapabilities: {
mcpCapabilities: {
http: true,
sse: true,
},
promptCapabilities: {
embeddedContext: true,
image: true,
},
},
authMethods: [authMethod],
agentInfo: {
name: "OpenCode",
version: InstallationVersion,
},
}
})
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
if (params.methodId !== AuthMethodID) {
return yield* new UnknownAuthMethodError({ methodId: params.methodId })
}
return {}
})
return {
initialize,
authenticate,
newSession: Effect.fn("ACPNext.newSession")(function* (_input: NewSessionRequest) {
return yield* new UnsupportedOperationError({ method: "session/new" })
}),
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
return yield* new UnsupportedOperationError({ method: "session/prompt" })
}),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
return yield* new UnsupportedOperationError({ method: "session/cancel" })
}),
}
}

View File

@@ -3,10 +3,12 @@ import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { ACPNext } from "@/acp-next/agent"
import { Server } from "@/server/server"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "acp-command" })
@@ -22,6 +24,7 @@ export const AcpCommand = effectCmd({
},
handler: Effect.fn("Cli.acp")(function* (args) {
process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
@@ -54,7 +57,7 @@ export const AcpCommand = effectCmd({
})
const stream = ndJsonStream(input, output)
const agent = ACP.init({ sdk })
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
new AgentSideConnection((conn) => {
return agent.create(conn, { sdk })

View File

@@ -48,6 +48,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
acpNext: bool("OPENCODE_ACP_NEXT"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),

View File

@@ -0,0 +1,104 @@
import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpClient, expectOk } from "../acp/acp-test-client"
describe("opencode acp-next (subprocess)", () => {
cliIt.live(
"responds to initialize behind OPENCODE_ACP_NEXT",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
}),
)
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
expect(initialized.agentInfo?.name).toBe("OpenCode")
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
}),
60_000,
)
cliIt.live(
"authenticate succeeds for the advertised auth method and rejects unknown methods safely",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
const methodId = initialized.authMethods?.[0]?.id
expect(methodId).toBe("opencode-login")
expectOk(yield* acp.request<AuthenticateResponse>("authenticate", { methodId }))
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(errorCode(rejected.error)).toBe(-32602)
}),
60_000,
)
cliIt.live(
"SDK-required session stubs fail with safe unsupported errors",
({ home, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const newSession = yield* acp.request("session/new", { cwd: home, mcpServers: [] })
expect(errorCode(newSession.error)).toBe(-32601)
const prompt = yield* acp.request("session/prompt", {
sessionId: "ses_missing",
prompt: [{ type: "text", text: "hello" }],
})
expect(errorCode(prompt.error)).toBe(-32601)
}),
60_000,
)
cliIt.live(
"exits cleanly when flagged stdin is closed",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
cliIt.live(
"default unflagged path still uses production ACP",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
})
function errorCode(error: unknown) {
if (!error || typeof error !== "object") return undefined
if (!("code" in error)) return undefined
return typeof error.code === "number" ? error.code : undefined
}