Browse Source

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

Shoubhit Dash 3 months ago
parent
commit
7060cfa59b

+ 60 - 0
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<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"

+ 102 - 0
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<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" })
+    }),
+  }
+}

+ 4 - 1
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 })

+ 1 - 0
packages/opencode/src/effect/runtime-flags.ts

@@ -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"),

+ 104 - 0
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<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
+}