Skip to content
Closed
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
45 changes: 45 additions & 0 deletions packages/opencode/src/acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,51 @@ 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 }
```

### Programmatic

```typescript
Expand Down
99 changes: 98 additions & 1 deletion packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,24 @@ import { Config } from "@/config/config"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import type {
AssistantMessage,
Event,
OpencodeClient,
QuestionRequest,
SessionMessageResponse,
ToolPart,
} from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { Flag } from "@/flag/flag"

type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }

const DEFAULT_VARIANT_VALUE = "default"
const QuestionCap = z.union([z.literal(true), z.object({ version: z.number().int().positive().optional() })])
const QuestionReply = z.object({ answers: z.array(z.array(z.string())) })
const QuestionReject = z.object({ rejected: z.literal(true) })

export namespace ACP {
const log = Log.create({ service: "acp-agent" })
Expand Down Expand Up @@ -136,9 +147,11 @@ export namespace ACP {
private sessionManager: ACPSessionManager
private eventAbort = new AbortController()
private eventStarted = false
private question = false
private bashSnapshots = new Map<string, string>()
private toolStarts = new Set<string>()
private permissionQueues = new Map<string, Promise<void>>()
private questionQueues = new Map<string, Promise<void>>()
private permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
Expand Down Expand Up @@ -262,6 +275,86 @@ export namespace ACP {
return
}

case "question.asked": {
const question = event.properties as QuestionRequest
const session = this.sessionManager.tryGet(question.sessionID)
if (!session) return

const prev = this.questionQueues.get(question.sessionID) ?? Promise.resolve()
const next = prev
.then(async () => {
const directory = session.cwd

if (!this.question) {
log.warn("question requested without ACP question support", {
questionID: question.id,
sessionID: question.sessionID,
})
await this.sdk.question.reject({
requestID: question.id,
directory,
})
return
}

const res = await this.connection
.extMethod("opencode/question", {
requestId: question.id,
sessionId: question.sessionID,
questions: question.questions,
tool: question.tool,
})
.catch((error) => {
log.error("failed to request question response from ACP", {
error,
questionID: question.id,
sessionID: question.sessionID,
})
return undefined
})

const reply = QuestionReply.safeParse(res)
if (reply.success) {
await this.sdk.question.reply({
requestID: question.id,
answers: reply.data.answers,
directory,
})
return
}

const reject = QuestionReject.safeParse(res)
if (reject.success) {
await this.sdk.question.reject({
requestID: question.id,
directory,
})
return
}

log.error("ACP question response was invalid", {
questionID: question.id,
sessionID: question.sessionID,
response: res,
})
await this.sdk.question.reject({
requestID: question.id,
directory,
})
})
.catch((error) => {
log.error("failed to handle question", { error, questionID: question.id })
})
.finally(() => {
if (this.questionQueues.get(question.sessionID) === next) {
this.questionQueues.delete(question.sessionID)
}
})

this.questionQueues.set(question.sessionID, next)
return
}

case "message.part.updated": {
log.info("message part updated", { event: event.properties })
const props = event.properties
Expand Down Expand Up @@ -517,6 +610,10 @@ export namespace ACP {

async initialize(params: InitializeRequest): Promise<InitializeResponse> {
log.info("initialize", { protocolVersion: params.protocolVersion })
this.question =
Flag.OPENCODE_ENABLE_QUESTION_TOOL &&
QuestionCap.safeParse(params.clientCapabilities?._meta?.["opencode/question"]).success
process.env.OPENCODE_ENABLE_QUESTION_TOOL = this.question ? "1" : "0"

const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export namespace Flag {
export declare const OPENCODE_CLIENT: string
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
export declare const OPENCODE_ENABLE_QUESTION_TOOL: boolean

// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
Expand Down Expand Up @@ -115,3 +115,11 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
enumerable: true,
configurable: false,
})

Object.defineProperty(Flag, "OPENCODE_ENABLE_QUESTION_TOOL", {
get() {
return truthy("OPENCODE_ENABLE_QUESTION_TOOL")
},
enumerable: true,
configurable: false,
})
Loading
Loading