From 8cbe437de1c63ced6c727c4398df3e308b2352aa Mon Sep 17 00:00:00 2001 From: Haohao-end <2227625024@qq.com> Date: Mon, 23 Mar 2026 02:16:56 +0800 Subject: [PATCH] fix(acp): handle question prompts in ACP mode --- packages/opencode/src/acp/agent.ts | 186 +++++++++++++- .../test/acp/event-subscription.test.ts | 227 +++++++++++++++++- 2 files changed, 406 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e444..950312cc8c3f 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -43,7 +43,16 @@ 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, + QuestionAnswer, + QuestionInfo, + QuestionRequest, + SessionMessageResponse, + ToolPart, +} from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" type ModeOption = { id: string; name: string; description?: string } @@ -140,7 +149,7 @@ export namespace ACP { private eventStarted = false private bashSnapshots = new Map() private toolStarts = new Set() - private permissionQueues = new Map>() + private promptQueues = new Map>() private permissionOptions: PermissionOption[] = [ { optionId: "once", kind: "allow_once", name: "Allow once" }, { optionId: "always", kind: "allow_always", name: "Always allow" }, @@ -188,7 +197,7 @@ export namespace ACP { const session = this.sessionManager.tryGet(permission.sessionID) if (!session) return - const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() + const prev = this.promptQueues.get(permission.sessionID) ?? Promise.resolve() const next = prev .then(async () => { const directory = session.cwd @@ -256,11 +265,62 @@ export namespace ACP { log.error("failed to handle permission", { error, permissionID: permission.id }) }) .finally(() => { - if (this.permissionQueues.get(permission.sessionID) === next) { - this.permissionQueues.delete(permission.sessionID) + if (this.promptQueues.get(permission.sessionID) === next) { + this.promptQueues.delete(permission.sessionID) } }) - this.permissionQueues.set(permission.sessionID, next) + this.promptQueues.set(permission.sessionID, next) + return + } + + case "question.asked": { + const question = event.properties + const session = this.sessionManager.tryGet(question.sessionID) + if (!session) return + + const prev = this.promptQueues.get(question.sessionID) ?? Promise.resolve() + const next = prev + .then(async () => { + const directory = session.cwd + const reject = () => + this.sdk.question.reject({ requestID: question.id, directory }).catch((error) => { + log.error("failed to reject question", { + error, + requestID: question.id, + sessionID: question.sessionID, + }) + }) + + const answers = await requestQuestion(this.connection, question) + if (!answers) { + await reject() + return + } + + await this.sdk.question + .reply({ + requestID: question.id, + answers, + directory, + }) + .catch(async (error) => { + log.error("failed to reply to question", { + error, + requestID: question.id, + sessionID: question.sessionID, + }) + await reject() + }) + }) + .catch((error) => { + log.error("failed to handle question", { error, requestID: question.id, sessionID: question.sessionID }) + }) + .finally(() => { + if (this.promptQueues.get(question.sessionID) === next) { + this.promptQueues.delete(question.sessionID) + } + }) + this.promptQueues.set(question.sessionID, next) return } @@ -1527,6 +1587,120 @@ export namespace ACP { } } + async function requestQuestion(connection: AgentSideConnection, request: QuestionRequest) { + const answers: QuestionAnswer[] = [] + + for (const [i, item] of request.questions.entries()) { + const opts = toQuestionOptions(item) + if (!opts) { + log.error("failed to map question to ACP", { + requestID: request.id, + sessionID: request.sessionID, + index: i, + }) + return + } + + const res = await connection + .requestPermission({ + sessionId: request.sessionID, + toolCall: { + toolCallId: toQuestionToolCallID(request, i), + status: "pending", + title: item.header, + kind: "other", + rawInput: { + requestID: request.id, + index: i, + total: request.questions.length, + question: item, + tool: request.tool, + }, + content: [ + { + type: "content", + content: { + type: "text", + text: toQuestionText(item, i, request.questions.length), + }, + }, + ], + }, + options: opts, + }) + .catch((error) => { + log.error("failed to request question from ACP", { + error, + requestID: request.id, + sessionID: request.sessionID, + index: i, + }) + return undefined + }) + + if (!res) return + if (res.outcome.outcome !== "selected") return + if (res.outcome.optionId === "reject") return + + const answer = toQuestionAnswer(item, res.outcome.optionId) + if (!answer) { + log.error("failed to map ACP answer to question reply", { + requestID: request.id, + sessionID: request.sessionID, + index: i, + optionID: res.outcome.optionId, + }) + return + } + + answers.push(answer) + } + + return answers + } + + function toQuestionOptions(item: QuestionInfo) { + if (item.multiple === true) return + if (item.options.length === 0) return + return [ + ...item.options.map((opt, i) => ({ + optionId: String(i), + kind: "allow_once" as const, + name: opt.label, + })), + { + optionId: "reject", + kind: "reject_once" as const, + name: "Dismiss", + }, + ] + } + + function toQuestionToolCallID(request: QuestionRequest, i: number) { + const id = request.tool?.callID ?? request.id + if (request.questions.length === 1) return id + return `${id}:${i}` + } + + function toQuestionAnswer(item: QuestionInfo, id: string): QuestionAnswer | undefined { + const index = Number(id) + if (!Number.isInteger(index)) return + const opt = item.options[index] + if (!opt) return + return [opt.label] + } + + function toQuestionText(item: QuestionInfo, i: number, total: number) { + return [ + total > 1 ? `Question ${i + 1} of ${total}` : undefined, + item.question, + ...item.options.map((opt, index) => `${index + 1}. ${opt.label}: ${opt.description}`), + item.custom === false ? undefined : "Custom answers are not supported in this ACP prompt.", + ] + .filter((item): item is string => !!item) + .join("\n") + } + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 1abf578281df..e612d1a373d7 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -198,7 +198,15 @@ function createFakeAgent() { }, }, permission: { - respond: async () => { + reply: async () => { + return { data: true } + }, + }, + question: { + reply: async () => { + return { data: true } + }, + reject: async () => { return { data: true } }, }, @@ -406,6 +414,223 @@ describe("acp.agent event subscription", () => { }) }) + test("question.asked events are handled and replied", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const questionReplies: Array<{ requestID: string; answers: string[][] }> = [] + const prompts: RequestPermissionParams[] = [] + const { agent, controller, stop, sdk, connection } = createFakeAgent() + connection.requestPermission = async (params: RequestPermissionParams) => { + prompts.push(params) + return { outcome: { outcome: "selected", optionId: prompts.length === 1 ? "1" : "0" } } as RequestPermissionResult + } + sdk.question.reply = async (params: any) => { + questionReplies.push({ + requestID: params.requestID, + answers: params.answers, + }) + return { data: true } + } + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "question.asked", + properties: { + id: "question_1", + sessionID: sessionA, + questions: [ + { + header: "First", + question: "Pick a first option", + options: [ + { label: "Alpha", description: "Pick alpha" }, + { label: "Beta", description: "Pick beta" }, + ], + }, + { + header: "Second", + question: "Pick a second option", + options: [ + { label: "Gamma", description: "Pick gamma" }, + { label: "Delta", description: "Pick delta" }, + ], + custom: false, + }, + ], + tool: { + messageID: "msg_question", + callID: "call_question", + }, + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(prompts).toHaveLength(2) + expect(prompts[0]?.toolCall.toolCallId).toBe("call_question:0") + expect(prompts[1]?.toolCall.toolCallId).toBe("call_question:1") + expect(questionReplies).toEqual([ + { + requestID: "question_1", + answers: [["Beta"], ["Gamma"]], + }, + ]) + + stop() + }, + }) + }) + + test("unsupported question.asked events are rejected", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const questionRejects: string[] = [] + const { agent, controller, stop, sdk } = createFakeAgent() + sdk.question.reject = async (params: any) => { + questionRejects.push(params.requestID) + return { data: true } + } + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "question.asked", + properties: { + id: "question_unsupported", + sessionID: sessionA, + questions: [ + { + header: "Multi", + question: "Pick more than one option", + options: [ + { label: "Alpha", description: "Pick alpha" }, + { label: "Beta", description: "Pick beta" }, + ], + multiple: true, + }, + ], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(questionRejects).toContain("question_unsupported") + + stop() + }, + }) + }) + + test("question prompt waits behind an existing permission prompt in the same session", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let open: (() => void) | undefined + const wait = new Promise((resolve) => { + open = resolve + }) + const calls: string[] = [] + const permissionReplies: string[] = [] + const questionReplies: Array<{ requestID: string; answers: string[][] }> = [] + const { agent, controller, stop, sdk, connection } = createFakeAgent() + + connection.requestPermission = async (params: RequestPermissionParams) => { + calls.push(params.toolCall.toolCallId) + if (params.toolCall.toolCallId === "perm_queued") { + await wait + return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult + } + return { outcome: { outcome: "selected", optionId: "0" } } as RequestPermissionResult + } + + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + + sdk.question.reply = async (params: any) => { + questionReplies.push({ + requestID: params.requestID, + answers: params.answers, + }) + return { data: true } + } + + const cwd = "/tmp/opencode-acp-test" + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_queued", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 10)) + + controller.push({ + directory: cwd, + payload: { + type: "question.asked", + properties: { + id: "question_after_permission", + sessionID: sessionA, + questions: [ + { + header: "After", + question: "Pick an option after permission", + options: [{ label: "Alpha", description: "Pick alpha" }], + }, + ], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(calls).toEqual(["perm_queued"]) + expect(permissionReplies).not.toContain("perm_queued") + expect(questionReplies).toHaveLength(0) + + open!() + await new Promise((r) => setTimeout(r, 30)) + + expect(calls).toEqual(["perm_queued", "question_after_permission"]) + expect(permissionReplies).toContain("perm_queued") + expect(questionReplies).toEqual([ + { + requestID: "question_after_permission", + answers: [["Alpha"]], + }, + ]) + + stop() + }, + }) + }) + test("permission prompt on session A does not block message updates for session B", async () => { await using tmp = await tmpdir() await Instance.provide({