From 7060cfa59b3b3f6f29390b5d1f3aa0f645875575 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 25 May 2026 20:33:40 +0530 Subject: [PATCH] feat(acp): add initial acp-next skeleton behind runtime flag (#29226) --- packages/opencode/src/acp-next/agent.ts | 60 ++++++++++ packages/opencode/src/acp-next/service.ts | 102 +++++++++++++++++ packages/opencode/src/cli/cmd/acp.ts | 5 +- packages/opencode/src/effect/runtime-flags.ts | 1 + .../cli/acp-next/acp-next-process.test.ts | 104 ++++++++++++++++++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/acp-next/agent.ts create mode 100644 packages/opencode/src/acp-next/service.ts create mode 100644 packages/opencode/test/cli/acp-next/acp-next-process.test.ts diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts new file mode 100644 index 0000000000..b656b6331f --- /dev/null +++ b/packages/opencode/src/acp-next/agent.ts @@ -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(effect: Effect.Effect) { + 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" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts new file mode 100644 index 0000000000..b90c9c1754 --- /dev/null +++ b/packages/opencode/src/acp-next/service.ts @@ -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()( + "ACPNextUnknownAuthMethodError", + { + methodId: Schema.String, + }, +) {} + +export class UnsupportedOperationError extends Schema.TaggedErrorClass()( + "ACPNextUnsupportedOperationError", + { + method: Schema.String, + }, +) {} + +export type Error = UnknownAuthMethodError | UnsupportedOperationError + +export type Interface = { + readonly initialize: (input: InitializeRequest) => Effect.Effect + readonly authenticate: (input: AuthenticateRequest) => Effect.Effect + readonly newSession: (input: NewSessionRequest) => Effect.Effect + readonly prompt: (input: PromptRequest) => Effect.Effect + readonly cancel: (input: CancelNotification) => Effect.Effect +} + +export class Service extends Context.Service()("@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" }) + }), + } +} diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index b3b7df486b..b113a278f9 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -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 }) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index efa8a264ba..520bcb30f8 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -48,6 +48,7 @@ export class Service extends ConfigService.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"), diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts new file mode 100644 index 0000000000..08d15b9bfc --- /dev/null +++ b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts @@ -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("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("initialize", { protocolVersion: 1 })) + const methodId = initialized.authMethods?.[0]?.id + expect(methodId).toBe("opencode-login") + + expectOk(yield* acp.request("authenticate", { methodId })) + + const rejected = yield* acp.request("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("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("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 +}