feat(acp): add initial acp-next skeleton behind runtime flag (#29226)
This commit is contained in:
@@ -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"
|
||||||
@@ -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" })
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ import { Effect } from "effect"
|
|||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd } from "../effect-cmd"
|
||||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||||
import { ACP } from "@/acp/agent"
|
import { ACP } from "@/acp/agent"
|
||||||
|
import { ACPNext } from "@/acp-next/agent"
|
||||||
import { Server } from "@/server/server"
|
import { Server } from "@/server/server"
|
||||||
import { ServerAuth } from "@/server/auth"
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
|
||||||
const log = Log.create({ service: "acp-command" })
|
const log = Log.create({ service: "acp-command" })
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ export const AcpCommand = effectCmd({
|
|||||||
},
|
},
|
||||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||||
process.env.OPENCODE_CLIENT = "acp"
|
process.env.OPENCODE_CLIENT = "acp"
|
||||||
|
const flags = yield* RuntimeFlags.Service
|
||||||
const opts = yield* resolveNetworkOptions(args)
|
const opts = yield* resolveNetworkOptions(args)
|
||||||
const server = yield* Effect.promise(() => Server.listen(opts))
|
const server = yield* Effect.promise(() => Server.listen(opts))
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ export const AcpCommand = effectCmd({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const stream = ndJsonStream(input, output)
|
const stream = ndJsonStream(input, output)
|
||||||
const agent = ACP.init({ sdk })
|
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
|
||||||
|
|
||||||
new AgentSideConnection((conn) => {
|
new AgentSideConnection((conn) => {
|
||||||
return agent.create(conn, { sdk })
|
return agent.create(conn, { sdk })
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||||
|
acpNext: bool("OPENCODE_ACP_NEXT"),
|
||||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||||
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user