From 2af5f8dc5505c1e1e26d142cfe09d62e3511f107 Mon Sep 17 00:00:00 2001 From: hikaruczl Date: Mon, 30 Mar 2026 15:56:11 +0800 Subject: [PATCH] fix: support ACP question prompts via extMethod --- packages/opencode/src/acp/README.md | 45 +++++ packages/opencode/src/acp/agent.ts | 99 +++++++++- packages/opencode/src/flag/flag.ts | 10 +- .../test/acp/event-subscription.test.ts | 182 ++++++++++++++++++ 4 files changed, 334 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md index aab33259bb18..6638ee7183bc 100644 --- a/packages/opencode/src/acp/README.md +++ b/packages/opencode/src/acp/README.md @@ -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 diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2552682dbe07..8465befc6305 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -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" }) @@ -136,9 +147,11 @@ export namespace ACP { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false + private question = false private bashSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() + private questionQueues = new Map>() private permissionOptions: PermissionOption[] = [ { optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "always", kind: "allow_always", name: "Always allow" }, @@ -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 @@ -517,6 +610,10 @@ export namespace ACP { async initialize(params: InitializeRequest): Promise { 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", diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 30929bd9268f..69452aaf6c71 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -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") @@ -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, +}) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 1abf578281df..2a0f707e1e64 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -8,6 +8,7 @@ import { tmpdir } from "../fixture/fixture" type SessionUpdateParams = Parameters[0] type RequestPermissionParams = Parameters[0] type RequestPermissionResult = Awaited> +type ExtMethodResult = Awaited> type GlobalEventEnvelope = { directory?: string @@ -143,6 +144,9 @@ function createFakeAgent() { async requestPermission(_params: RequestPermissionParams): Promise { return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult }, + async extMethod(_method: string, _params: Record): Promise { + return { rejected: true } + }, } as unknown as AgentSideConnection const { controller, stream } = createEventStream() @@ -202,6 +206,14 @@ function createFakeAgent() { return { data: true } }, }, + question: { + reply: async () => { + return { data: true } + }, + reject: async () => { + return { data: true } + }, + }, config: { providers: async () => { return { @@ -493,6 +505,176 @@ describe("acp.agent event subscription", () => { }) }) + test("question.asked events use ACP extMethod replies when enabled", async () => { + await using tmp = await tmpdir() + const prev = process.env.OPENCODE_ENABLE_QUESTION_TOOL + process.env.OPENCODE_ENABLE_QUESTION_TOOL = "1" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const replies: any[] = [] + const ext: any[] = [] + const { agent, controller, stop, sdk, connection } = createFakeAgent() + sdk.question.reply = async (params: any) => { + replies.push(params) + return { data: true } + } + connection.extMethod = async (method: string, params: Record) => { + ext.push({ method, params }) + return { answers: [["Yes"]] } + } + + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + _meta: { + "opencode/question": { + version: 1, + }, + }, + }, + } as any) + + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "question.asked", + properties: { + id: "que_1", + sessionID: sessionId, + questions: [ + { + header: "Build", + question: "Start implementing?", + options: [ + { label: "Yes", description: "Start implementing now" }, + { label: "No", description: "Keep planning" }, + ], + }, + ], + tool: { + messageID: "msg_1", + callID: "call_1", + }, + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(ext).toEqual([ + { + method: "opencode/question", + params: { + requestId: "que_1", + sessionId, + questions: [ + { + header: "Build", + question: "Start implementing?", + options: [ + { label: "Yes", description: "Start implementing now" }, + { label: "No", description: "Keep planning" }, + ], + }, + ], + tool: { + messageID: "msg_1", + callID: "call_1", + }, + }, + }, + ]) + expect(replies).toEqual([ + { + requestID: "que_1", + answers: [["Yes"]], + directory: cwd, + }, + ]) + + stop() + }, + }) + + if (prev === undefined) delete process.env.OPENCODE_ENABLE_QUESTION_TOOL + else process.env.OPENCODE_ENABLE_QUESTION_TOOL = prev + }) + + test("question.asked events reject when ACP client declines", async () => { + await using tmp = await tmpdir() + const prev = process.env.OPENCODE_ENABLE_QUESTION_TOOL + process.env.OPENCODE_ENABLE_QUESTION_TOOL = "1" + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const rejects: any[] = [] + const { agent, controller, stop, sdk, connection } = createFakeAgent() + sdk.question.reject = async (params: any) => { + rejects.push(params) + return { data: true } + } + connection.extMethod = async () => { + return { rejected: true } + } + + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + _meta: { + "opencode/question": { + version: 1, + }, + }, + }, + } as any) + + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "question.asked", + properties: { + id: "que_2", + sessionID: sessionId, + questions: [ + { + header: "Build", + question: "Start implementing?", + options: [ + { label: "Yes", description: "Start implementing now" }, + { label: "No", description: "Keep planning" }, + ], + }, + ], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(rejects).toEqual([ + { + requestID: "que_2", + directory: cwd, + }, + ]) + + stop() + }, + }) + + if (prev === undefined) delete process.env.OPENCODE_ENABLE_QUESTION_TOOL + else process.env.OPENCODE_ENABLE_QUESTION_TOOL = prev + }) + test("streams running bash output snapshots and de-dupes identical snapshots", async () => { await using tmp = await tmpdir() await Instance.provide({