Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/opencode/src/acp-next/agent.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 102 additions & 0 deletions packages/opencode/src/acp-next/service.ts
Original file line number Diff line number Diff line change
@@ -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" })
}),
}
}
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand All @@ -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))

Expand Down Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/test/cli/acp-next/acp-next-process.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading