diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md new file mode 100644 index 000000000000..aa0ddfcd11d6 --- /dev/null +++ b/packages/opencode/src/acp/README.md @@ -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. diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index 7d05fa6ee58c..55e15fa97c96 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -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, @@ -22,7 +23,7 @@ import { } from "./tool" type Connection = Pick & - Partial> + Partial> type GlobalEventEnvelope = { payload?: Event } @@ -41,6 +42,7 @@ export class Subscription { private readonly shellSnapshots = new Map() private readonly toolStarts = new Set() private readonly permission: ACPPermission.Handler + private readonly question: ACPQuestion.Handler private started = false constructor( @@ -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() { @@ -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": diff --git a/packages/opencode/src/acp/question.ts b/packages/opencode/src/acp/question.ts new file mode 100644 index 000000000000..5af8d54cc173 --- /dev/null +++ b/packages/opencode/src/acp/question.ts @@ -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 +type Connection = Partial> + +// 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>() + 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`; 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" diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 36e8375f5cc4..ea63db209bc3 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -49,7 +49,7 @@ export const AuthMethodID = "opencode-login" export type Error = ACPError.Error type ServiceConnection = Pick & - Partial> + Partial> export type Interface = { readonly initialize: (input: InitializeRequest) => Effect.Effect @@ -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: { @@ -1005,6 +1011,14 @@ function isSdkResponse(value: T | SdkResponse): value is SdkResponse { 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)) { diff --git a/packages/opencode/test/acp/question.test.ts b/packages/opencode/test/acp/question.test.ts new file mode 100644 index 000000000000..5701f0c4259e --- /dev/null +++ b/packages/opencode/test/acp/question.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "bun:test" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import type { Event, OpencodeClient, QuestionAnswer } from "@opencode-ai/sdk/v2" +import { Effect, ManagedRuntime } from "effect" +import { ACPEvent } from "@/acp/event" +import { ACPSession } from "@/acp/session" + +type QuestionEvent = Extract +type ExtMethodParams = { method: string; params: Record } +type QuestionReplyParams = Parameters[0] +type QuestionRejectParams = Parameters[0] +type SessionUpdateParams = Parameters[0] + +const pollUntil = async ( + check: () => boolean | Promise, + message: string, + opts?: { timeoutMs?: number; intervalMs?: number }, +) => { + const started = Date.now() + while (true) { + if (await check()) return + if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message) + await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5)) + } +} + +function makeSessionService() { + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function createHarness( + extMethod: (params: ExtMethodParams) => Promise> = () => + Promise.resolve({ rejected: true }), +) { + const replies: QuestionReplyParams[] = [] + const rejects: QuestionRejectParams[] = [] + const extCalls: ExtMethodParams[] = [] + const updates: SessionUpdateParams[] = [] + const session = makeSessionService() + const sdk = { + question: { + reply: (params: QuestionReplyParams) => { + replies.push(params) + return Promise.resolve({ data: true }) + }, + reject: (params: QuestionRejectParams) => { + rejects.push(params) + return Promise.resolve({ data: true }) + }, + }, + session: { + message: () => Promise.resolve({ data: undefined }), + }, + } as unknown as OpencodeClient + const connection = { + extMethod: (method: string, params: Record) => { + const entry = { method, params } + extCalls.push(entry) + return extMethod(entry) + }, + sessionUpdate: (params: SessionUpdateParams) => { + updates.push(params) + return Promise.resolve() + }, + } satisfies Pick + const subscription = new ACPEvent.Subscription({ sdk, connection, session }) + + return { connection, extCalls, replies, rejects, sdk, session, subscription, updates } +} + +async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") { + await Effect.runPromise(session.create({ id: sessionId, cwd })) +} + +function questionAsked( + sessionID: string, + id: string, + input: { questions?: QuestionEvent["properties"]["questions"]; tool?: { messageID: string; callID: string } } = {}, +): QuestionEvent { + return { + id: `evt_${id}`, + type: "question.asked", + properties: { + id, + sessionID, + questions: + input.questions ?? + [ + { + header: "Build", + question: "Start implementing?", + options: [ + { label: "Yes", description: "Start implementing now" }, + { label: "No", description: "Keep planning" }, + ], + }, + ], + ...(input.tool ? { tool: input.tool } : {}), + }, + } as QuestionEvent +} + +describe("acp questions", () => { + it("forwards question.asked to extMethod and replies with the answers when enabled", async () => { + const harness = createHarness(() => Promise.resolve({ answers: [["Yes"]] })) + await createSession(harness.session, "ses_a") + harness.subscription.setQuestionEnabled(true) + + harness.subscription.handle(questionAsked("ses_a", "que_1", { tool: { messageID: "msg_1", callID: "call_1" } })) + + await pollUntil(() => harness.replies.length === 1, "question was never replied") + + expect(harness.extCalls).toEqual([ + { + method: "opencode/question", + params: { + requestId: "que_1", + sessionId: "ses_a", + 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(harness.replies).toEqual([ + { requestID: "que_1", answers: [["Yes"]] as QuestionAnswer[], directory: "/workspace" }, + ]) + }) + + it("rejects when the ACP client declines a question", async () => { + const harness = createHarness(() => Promise.resolve({ rejected: true })) + await createSession(harness.session, "ses_a") + harness.subscription.setQuestionEnabled(true) + + harness.subscription.handle(questionAsked("ses_a", "que_2")) + + await pollUntil(() => harness.rejects.length === 1, "declined question was never rejected") + + // A `rejected: true` (or any response without `answers`) falls through to reject, + // so the agent is not left waiting on a question that will never be answered. + expect(harness.rejects).toEqual([{ requestID: "que_2", directory: "/workspace" }]) + expect(harness.replies).toHaveLength(0) + }) + + it("rejects without calling extMethod when question support is disabled", async () => { + const harness = createHarness(() => Promise.resolve({ answers: [["Yes"]] })) + await createSession(harness.session, "ses_a") + // question support left disabled (no capability advertised) + + harness.subscription.handle(questionAsked("ses_a", "que_3")) + + await pollUntil(() => harness.rejects.length === 1, "unsupported question was never rejected") + + expect(harness.extCalls).toHaveLength(0) + expect(harness.rejects).toEqual([{ requestID: "que_3", directory: "/workspace" }]) + expect(harness.replies).toHaveLength(0) + }) + + it("rejects when extMethod throws", async () => { + const harness = createHarness(() => Promise.reject(new Error("client disconnected"))) + await createSession(harness.session, "ses_a") + harness.subscription.setQuestionEnabled(true) + + harness.subscription.handle(questionAsked("ses_a", "que_4")) + + await pollUntil(() => harness.rejects.length === 1, "question was never rejected after extMethod threw") + + expect(harness.rejects).toEqual([{ requestID: "que_4", directory: "/workspace" }]) + expect(harness.replies).toHaveLength(0) + }) +})