Skip to content
Open
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
64 changes: 64 additions & 0 deletions packages/opencode/src/acp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ACP (Agent Client Protocol)

opencode can act as an [ACP](https://agentclientprotocol.com/) agent. Start it with:

```sh
opencode acp
```

## Question prompts

The `question` tool lets the agent ask the user multiple-choice questions. It is opt-in: the backing opencode server only registers the tool when `OPENCODE_ENABLE_QUESTION_TOOL=1` is set **before** the server starts:

```sh
OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp
```

Enable this only for ACP clients that support interactive question prompts.

Question support also requires the ACP client to advertise the following capability at initialize time:

```json
{
"clientCapabilities": {
"_meta": {
"opencode/question": {
"version": 1
}
}
}
}
```

When enabled, opencode sends question requests over the ACP extension method `opencode/question`:

```json
{
"requestId": "que_123",
"sessionId": "ses_123",
"questions": [
{
"header": "Build Agent",
"question": "Start implementing now?",
"options": [
{ "label": "Yes", "description": "Switch to build agent and start implementing" },
{ "label": "No", "description": "Stay in the current mode" }
]
}
]
}
```

The client should return either:

```json
{ "answers": [["Yes"]] }
```

or:

```json
{ "rejected": true }
```

If a client connects without the `opencode/question` capability, or declines a question, opencode rejects the underlying question request so the agent does not block waiting for an answer that will never arrive.
12 changes: 11 additions & 1 deletion packages/opencode/src/acp/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import { Effect } from "effect"
import { ACPSession } from "./session"
import { ACPPermission } from "./permission"
import { ACPQuestion } from "./question"
import { partsToContentChunks, type ReplayPart } from "./content"
import {
duplicateRunningToolUpdate,
Expand All @@ -22,7 +23,7 @@ import {
} from "./tool"

type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile" | "extMethod">>
type GlobalEventEnvelope = {
payload?: Event
}
Expand All @@ -41,6 +42,7 @@ export class Subscription {
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly permission: ACPPermission.Handler
private readonly question: ACPQuestion.Handler
private started = false

constructor(
Expand All @@ -51,6 +53,11 @@ export class Subscription {
},
) {
this.permission = new ACPPermission.Handler(input)
this.question = new ACPQuestion.Handler(input)
}

setQuestionEnabled(enabled: boolean) {
this.question.enable(enabled)
}

start() {
Expand All @@ -70,6 +77,9 @@ export class Subscription {
case "permission.asked":
this.permission.handle(event)
return
case "question.asked":
this.question.handle(event)
return
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/src/acp/question.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient, QuestionAnswer } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import type { ACPSession } from "./session"

type QuestionEvent = Extract<Event, { type: "question.asked" }>
type Connection = Partial<Pick<AgentSideConnection, "extMethod">>

// The ACP client advertises question support at initialize time; it stays
// false until `initialize()` sees the `opencode/question` capability and flips
// it on. Until then, question prompts are rejected so non-supporting clients
// are not left hanging.
export class Handler {
private readonly queues = new Map<string, Promise<void>>()
private enabled: boolean

constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPSession.Interface
},
) {
this.enabled = false
}

enable(value: boolean) {
this.enabled = value
}

handle(event: QuestionEvent) {
const question = event.properties
const previous = this.queues.get(question.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch(() => {})
.finally(() => {
if (this.queues.get(question.sessionID) === next) {
this.queues.delete(question.sessionID)
}
})
this.queues.set(question.sessionID, next)
}

private async process(event: QuestionEvent) {
const question = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(question.sessionID))
if (!session) return

if (!this.enabled || !this.input.connection.extMethod) {
await this.reject(question.id, session.cwd)
return
}

const response = await this.input.connection
.extMethod("opencode/question", {
requestId: question.id,
sessionId: question.sessionID,
questions: question.questions,
...(question.tool ? { tool: question.tool } : {}),
})
.catch(async () => {
await this.reject(question.id, session.cwd)
return undefined
})

if (!response) return

const answers = parseAnswers(response)
if (answers) {
await this.input.sdk.question.reply({
requestID: question.id,
answers,
directory: session.cwd,
})
return
}

await this.reject(question.id, session.cwd)
}

private async reject(requestID: string, directory: string) {
await this.input.sdk.question.reject({
requestID,
directory,
})
}
}

// extMethod returns `Record<string, unknown>`; validate the response shape
// before forwarding. `QuestionAnswer` is `string[]` (the selected labels for
// one question), and `answers` is one entry per question.
function parseAnswers(value: unknown): QuestionAnswer[] | undefined {
if (!value || typeof value !== "object") return undefined
const answers = (value as { answers?: unknown }).answers
if (!Array.isArray(answers)) return undefined
return answers.filter(isAnswer)
}

function isAnswer(value: unknown): value is QuestionAnswer {
return Array.isArray(value) && value.every((item) => typeof item === "string")
}

export * as ACPQuestion from "./question"
16 changes: 15 additions & 1 deletion packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const AuthMethodID = "opencode-login"

export type Error = ACPError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile" | "extMethod">>

export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
Expand Down Expand Up @@ -106,6 +106,12 @@ export function make(input: {
}
}

// ACP question prompts only bridge to clients that advertise the
// `opencode/question` extension; otherwise question requests are rejected.
if (events) {
events.setQuestionEnabled(hasQuestionCapability(params.clientCapabilities?._meta?.["opencode/question"]))
}

const response = {
protocolVersion: 1,
agentCapabilities: {
Expand Down Expand Up @@ -1005,6 +1011,14 @@ function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
return typeof value === "object" && value !== null && ("data" in value || "error" in value)
}

// A client advertises question support either as `true` or `{ version: n }`.
function hasQuestionCapability(value: unknown): boolean {
if (value === true) return true
if (!value || typeof value !== "object") return false
const version = (value as { version?: unknown }).version
return version === undefined || (typeof version === "number" && Number.isInteger(version) && version > 0)
}

function fromUnknownError(error: unknown, service?: string): Error {
if (isACPError(error)) return error
if (isAuthRequired(error)) {
Expand Down
Loading
Loading