diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 11bf4c11c5..9419e12272 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -1,6 +1,13 @@ -import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk" +import type { + AgentSideConnection, + CreateElicitationRequest, + ElicitationPropertySchema, + PromptResponse, +} from "@agentclientprotocol/sdk" import type { EventSubscribeOutput, + FormField, + FormInfo, OpenCodeClient, SessionMessageAssistant, SessionMessageInfo, @@ -18,7 +25,7 @@ import { } from "./tool" type Connection = Pick & - Partial> + Partial> export type TurnControl = { cancelled: boolean @@ -48,6 +55,7 @@ export async function streamTurn(input: { readonly cwd: string readonly start: TurnStart readonly userMessageID?: string | null + readonly elicitation: boolean readonly submit: (signal: AbortSignal) => Promise readonly control: TurnControl }): Promise { @@ -84,8 +92,12 @@ export async function streamTurn(input: { continue } if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) { - await input.client.form - .cancel({ sessionID: input.sessionID, formID: event.data.form.id }) + await replyForm({ + client: input.client, + connection: input.connection, + form: event.data.form, + elicitation: input.elicitation, + }) .catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})) continue } @@ -258,6 +270,117 @@ export async function streamTurn(input: { } } +async function replyForm(input: { + readonly client: OpenCodeClient + readonly connection: Connection + readonly form: FormInfo + readonly elicitation: boolean +}) { + const request = input.elicitation ? formRequest(input.form) : undefined + if (!request || !input.connection.unstable_createElicitation) { + return input.client.form.cancel({ sessionID: input.form.sessionID, formID: input.form.id }) + } + const response = await input.connection.unstable_createElicitation(request).catch(() => undefined) + if (!response || response.action !== "accept") { + return input.client.form.cancel({ sessionID: input.form.sessionID, formID: input.form.id }) + } + await input.client.form + .reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: response.content ?? {} }) + .catch(() => input.client.form.cancel({ sessionID: input.form.sessionID, formID: input.form.id })) +} + +function formRequest(form: FormInfo): CreateElicitationRequest | undefined { + const properties = form.fields + .map((field) => [field.key, formProperty(field)] as const) + .filter((entry): entry is readonly [string, ElicitationPropertySchema] => entry[1] !== undefined) + if (properties.length !== form.fields.length) return undefined + const toolCallID = questionToolCallID(form.metadata) + return { + sessionId: form.sessionID, + ...(toolCallID ? { toolCallId: toolCallID } : {}), + mode: "form", + message: form.title, + requestedSchema: { + type: "object", + title: form.title, + properties: Object.fromEntries(properties), + required: form.fields.filter((field) => field.type !== "external" && field.required).map((field) => field.key), + }, + } +} + +function formProperty(field: FormField): ElicitationPropertySchema | undefined { + if (field.type === "external" || field.when?.length) return undefined + const base = { + ...(field.title ? { title: field.title } : {}), + ...(field.description ? { description: field.description } : {}), + } + if (field.type === "string") { + return { + ...base, + type: "string", + ...(field.format ? { format: field.format } : {}), + ...(field.minLength === undefined ? {} : { minLength: field.minLength }), + ...(field.maxLength === undefined ? {} : { maxLength: field.maxLength }), + ...(field.pattern === undefined ? {} : { pattern: field.pattern }), + ...(field.default === undefined ? {} : { default: field.default }), + ...(field.options?.length + ? { + oneOf: field.options.map((option) => ({ + const: option.value, + title: option.label, + ...(option.description ? { description: option.description } : {}), + })), + } + : {}), + } + } + if (field.type === "number" || field.type === "integer") { + if ( + [field.minimum, field.maximum, field.default].some( + (value) => value !== undefined && (typeof value !== "number" || !Number.isFinite(value)), + ) + ) + return undefined + const constraints = { + ...base, + ...(typeof field.minimum === "number" ? { minimum: field.minimum } : {}), + ...(typeof field.maximum === "number" ? { maximum: field.maximum } : {}), + ...(typeof field.default === "number" ? { default: field.default } : {}), + } + if (field.type === "number") return { ...constraints, type: "number" } + return { ...constraints, type: "integer" } + } + if (field.type === "boolean") { + return { + ...base, + type: "boolean", + ...(field.default === undefined ? {} : { default: field.default }), + } + } + return { + ...base, + type: "array", + items: { + anyOf: field.options.map((option) => ({ + const: option.value, + title: option.label, + ...(option.description ? { description: option.description } : {}), + })), + }, + ...(field.minItems === undefined ? {} : { minItems: field.minItems }), + ...(field.maxItems === undefined ? {} : { maxItems: field.maxItems }), + ...(field.default === undefined ? {} : { default: field.default }), + } +} + +function questionToolCallID(metadata: FormInfo["metadata"]) { + if (!metadata) return undefined + const tool = metadata.tool + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return undefined + return typeof tool.callID === "string" ? tool.callID : undefined +} + export async function replayMessages( connection: Pick, sessionID: string, diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index f20f31c9bc..668eecaecc 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -47,7 +47,8 @@ import { ACPError } from "./error" export const AuthMethodID = "opencode-login" -type Connection = Pick +type Connection = Pick & + Partial> type Catalog = { readonly providers: ConfigOptionProvider[] @@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti const catalogs = new Map>() const registeredMcp = new Map>() const active = new Map() + const capabilities = { elicitation: false } const catalog = (cwd: string) => { const cached = catalogs.get(cwd) @@ -157,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return { initialize: async (params) => { + capabilities.elicitation = supportsFormElicitation(params) const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -296,6 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti cwd: state.cwd, start: prepared.start, userMessageID: params.messageId, + elicitation: capabilities.elicitation, control, submit: (signal) => submitPrompt(input.client, state, prepared, signal), }).finally(() => { @@ -315,6 +319,13 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti } } +function supportsFormElicitation(input: InitializeRequest) { + const elicitation = input.clientCapabilities?.elicitation + if (!elicitation) return false + if (elicitation.form) return true + return elicitation.form === undefined && elicitation.url === undefined +} + function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt { const parts = promptContentToParts(prompt) const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored)) diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 9de17cf4e1..f62195ebc3 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -6,7 +6,8 @@ import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/even import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" type SessionUpdateParams = Parameters[0] -type Connection = Pick +type Connection = Pick & + Partial> type Fixture = ReturnType describe("acp event behavior", () => { @@ -437,6 +438,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel", cwd: "/workspace", start: { type: "input", id: "input_cancel" }, + elicitation: false, control, submit: async (signal) => { await fixture.client.session.prompt( @@ -479,6 +481,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel_admission", cwd: "/workspace", start: { type: "input", id: "input_cancel_admission" }, + elicitation: false, control, submit: (signal) => fixture.client.session.prompt( @@ -503,7 +506,178 @@ describe("acp event behavior", () => { } }) - test("cancels unsupported session forms so execution can continue", async () => { + test("replies to session forms through client elicitation", async () => { + const elicitation = Promise.withResolvers[0]>() + const fixture = createSseFixture({ + onPrompt({ id, send }) { + send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id })) + send( + ephemeralEvent("form.created", { + form: { + id: "frm_question", + sessionID: "ses_form", + title: "Questions", + metadata: { kind: "question", tool: { callID: "call_question", messageID: "msg_question" } }, + fields: [ + { + key: "choice", + title: "Strategy", + description: "Choose an approach", + type: "string", + options: [ + { value: "minimal", label: "Minimal", description: "Make the smallest change" }, + { value: "broad", label: "Broad", description: "Refactor nearby code" }, + ], + required: true, + }, + { key: "count", title: "Count", type: "integer", minimum: 1, maximum: 5, default: 2 }, + { key: "confirm", title: "Confirm", type: "boolean", default: true }, + { + key: "areas", + title: "Areas", + type: "multiselect", + options: [ + { value: "tests", label: "Tests" }, + { value: "docs", label: "Docs" }, + ], + minItems: 1, + }, + ], + }, + }), + ) + }, + onFormReply({ sessionID, formID, body, send }) { + expect({ sessionID, formID, body }).toEqual({ + sessionID: "ses_form", + formID: "frm_question", + body: { answer: { choice: "minimal", count: 2, confirm: true, areas: ["tests"] } }, + }) + send( + ephemeralEvent("form.replied", { + sessionID, + id: formID, + answer: { choice: "minimal", count: 2, confirm: true, areas: ["tests"] }, + }), + ) + send(durableEvent("session.execution.succeeded", { sessionID })) + }, + }) + const connection = { + ...recordingConnection([]), + unstable_createElicitation: async (request) => { + elicitation.resolve(request) + return { + action: "accept" as const, + content: { choice: "minimal", count: 2, confirm: true, areas: ["tests"] }, + } + }, + } satisfies Connection + + try { + const result = turn({ + fixture, + connection, + sessionID: "ses_form", + inputID: "input_form", + elicitation: true, + }) + + const request = await withTimeout(elicitation.promise, "elicitation was not requested") + const response = await result + const payload: unknown = request + expect(payload).toEqual({ + sessionId: "ses_form", + toolCallId: "call_question", + mode: "form", + message: "Questions", + requestedSchema: { + type: "object", + title: "Questions", + properties: { + choice: { + type: "string", + title: "Strategy", + description: "Choose an approach", + oneOf: [ + { const: "minimal", title: "Minimal", description: "Make the smallest change" }, + { const: "broad", title: "Broad", description: "Refactor nearby code" }, + ], + }, + count: { type: "integer", title: "Count", minimum: 1, maximum: 5, default: 2 }, + confirm: { type: "boolean", title: "Confirm", default: true }, + areas: { + type: "array", + title: "Areas", + items: { + anyOf: [ + { const: "tests", title: "Tests" }, + { const: "docs", title: "Docs" }, + ], + }, + minItems: 1, + }, + }, + required: ["choice"], + }, + }) + expect(response.stopReason).toBe("end_turn") + } finally { + await fixture.stop() + } + }) + + test("cancels session forms when client elicitation is cancelled", async () => { + const fixture = formCancellationFixture("ses_form_cancelled") + const connection = { + ...recordingConnection([]), + unstable_createElicitation: async () => ({ action: "cancel" as const }), + } satisfies Connection + + try { + const response = await turn({ + fixture, + connection, + sessionID: "ses_form_cancelled", + inputID: "input_form", + elicitation: true, + }) + + expect(response.stopReason).toBe("end_turn") + expect( + fixture.requests.some( + (request) => request.path === "/api/session/ses_form_cancelled/form/frm_question/cancel", + ), + ).toBe(true) + } finally { + await fixture.stop() + } + }) + + test("cancels session forms when client elicitation is unsupported", async () => { + const fixture = formCancellationFixture("ses_form_unsupported") + + try { + const response = await turn({ + fixture, + connection: recordingConnection([]), + sessionID: "ses_form_unsupported", + inputID: "input_form", + elicitation: false, + }) + + expect(response.stopReason).toBe("end_turn") + expect( + fixture.requests.some( + (request) => request.path === "/api/session/ses_form_unsupported/form/frm_question/cancel", + ), + ).toBe(true) + } finally { + await fixture.stop() + } + }) + + test("cancels unsupported session form shapes", async () => { const fixture = createSseFixture({ onPrompt({ id, send }) { send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id })) @@ -514,7 +688,7 @@ describe("acp event behavior", () => { sessionID: "ses_form", title: "Questions", metadata: { kind: "question" }, - fields: [{ key: "q0", title: "Choice", type: "string" }], + fields: [{ key: "external", title: "Authorize", type: "external", url: "https://example.com" }], }, }), ) @@ -531,6 +705,7 @@ describe("acp event behavior", () => { connection: recordingConnection([]), sessionID: "ses_form", inputID: "input_form", + elicitation: true, }) expect(response.stopReason).toBe("end_turn") @@ -557,6 +732,7 @@ function turn(input: { readonly connection: Connection readonly sessionID: string readonly inputID: string + readonly elicitation?: boolean }) { return streamTurn({ client: input.fixture.client, @@ -566,11 +742,34 @@ function turn(input: { start: { type: "input", id: input.inputID }, userMessageID: `client_${input.inputID}`, control: { cancelled: false, admission: new AbortController() }, + elicitation: input.elicitation ?? false, submit: (signal) => input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), }) } +function formCancellationFixture(sessionID: string) { + return createSseFixture({ + onPrompt({ id, send }) { + send(durableEvent("session.input.promoted", { sessionID, inputID: id })) + send( + ephemeralEvent("form.created", { + form: { + id: "frm_question", + sessionID, + title: "Questions", + fields: [{ key: "q0", title: "Choice", type: "string" }], + }, + }), + ) + }, + onFormCancel({ sessionID: current, formID, send }) { + send(ephemeralEvent("form.cancelled", { sessionID: current, id: formID })) + send(durableEvent("session.execution.succeeded", { sessionID: current })) + }, + }) +} + function tokens() { return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } } diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts index e6d8db9deb..7b45194e74 100644 --- a/packages/cli/test/acp/event.test.ts +++ b/packages/cli/test/acp/event.test.ts @@ -98,6 +98,7 @@ test("acp prompt resolves after ordered turn updates", async () => { cwd: "/workspace", start: { type: "input", id }, userMessageID, + elicitation: false, control: { cancelled: false, admission: new AbortController() }, submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }), }) diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index ad8ccd128a..f7f9840be1 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -465,6 +465,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string, sessionID, cwd, start: { type: "input", id: inputID }, + elicitation: false, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }), }) diff --git a/packages/cli/test/acp/service-fixture.ts b/packages/cli/test/acp/service-fixture.ts index 68deed29be..85e36f473b 100644 --- a/packages/cli/test/acp/service-fixture.ts +++ b/packages/cli/test/acp/service-fixture.ts @@ -30,6 +30,7 @@ type FixtureHandler = ( type FixtureOptions = { readonly fetch?: FixtureHandler + readonly createElicitation?: AgentSideConnection["unstable_createElicitation"] readonly models?: readonly ModelInfo[] readonly defaultModel?: ModelInfo readonly agents?: readonly AgentInfo[] @@ -192,6 +193,7 @@ export function makeACPFixture(options: FixtureOptions = {}) { updates.push(update) }, requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + ...(options.createElicitation ? { unstable_createElicitation: options.createElicitation } : {}), }, }) diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts index 2c4992b8e5..5d45881eb4 100644 --- a/packages/cli/test/acp/service-lifecycle.test.ts +++ b/packages/cli/test/acp/service-lifecycle.test.ts @@ -1,8 +1,79 @@ import { describe, expect, test } from "bun:test" -import type { SessionConfigOption } from "@agentclientprotocol/sdk" +import type { CreateElicitationRequest, SessionConfigOption } from "@agentclientprotocol/sdk" import { makeACPFixture, makeSession, secondModel } from "./service-fixture" +import { durableEvent, ephemeralEvent } from "./sse-fixture" describe("acp service lifecycle", () => { + test("enables form elicitation from negotiated client capabilities", async () => { + const elicitations: CreateElicitationRequest[] = [] + await using fixture = makeACPFixture({ + createElicitation: async (request) => { + elicitations.push(request) + return { action: "accept", content: { name: "Ada" } } + }, + fetch(request, context) { + if (request.method === "POST" && request.path === "/api/session") { + return Response.json({ data: makeSession("ses_elicitation") }) + } + if (request.method === "POST" && request.path === "/api/session/ses_elicitation/prompt") { + if (!request.body || typeof request.body !== "object") return new Response(null, { status: 400 }) + const inputID = Reflect.get(request.body, "id") + if (typeof inputID !== "string") return new Response(null, { status: 400 }) + context.send(durableEvent("session.input.promoted", { sessionID: "ses_elicitation", inputID })) + context.send( + ephemeralEvent("form.created", { + form: { + id: "frm_elicitation", + sessionID: "ses_elicitation", + title: "Profile", + fields: [{ key: "name", title: "Name", type: "string", required: true }], + }, + }), + ) + return Response.json({ data: { text: "hello" } }) + } + if ( + request.method === "POST" && + request.path === "/api/session/ses_elicitation/form/frm_elicitation/reply" + ) { + context.send( + ephemeralEvent("form.replied", { + id: "frm_elicitation", + sessionID: "ses_elicitation", + answer: { name: "Ada" }, + }), + ) + context.send(durableEvent("session.execution.succeeded", { sessionID: "ses_elicitation" })) + return new Response(null, { status: 204 }) + } + return undefined + }, + }) + await fixture.service.initialize({ + protocolVersion: 1, + clientCapabilities: { elicitation: { form: {} } }, + clientInfo: { name: "test", version: "1.0.0" }, + }) + const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }) + + const response = await fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hi" }] }) + + expect(response.stopReason).toBe("end_turn") + expect(elicitations).toEqual([ + { + sessionId: "ses_elicitation", + mode: "form", + message: "Profile", + requestedSchema: { + type: "object", + title: "Profile", + properties: { name: { type: "string", title: "Name" } }, + required: ["name"], + }, + }, + ]) + }) + test("loads and forks with paginated replay while resume does not replay", async () => { await using fixture = makeACPFixture({ fetch(request) { diff --git a/packages/cli/test/acp/sse-fixture.ts b/packages/cli/test/acp/sse-fixture.ts index 1f1e3f5d19..659ba184e3 100644 --- a/packages/cli/test/acp/sse-fixture.ts +++ b/packages/cli/test/acp/sse-fixture.ts @@ -33,6 +33,12 @@ type FixtureOptions = { readonly formID: string readonly send: (event: unknown) => void }) => void | Promise + readonly onFormReply?: (input: { + readonly sessionID: string + readonly formID: string + readonly body: unknown + readonly send: (event: unknown) => void + }) => void | Promise } const ids = { next: 0 } @@ -150,6 +156,17 @@ export function createSseFixture(options: FixtureOptions = {}) { return new Response(null, { status: 204 }) } + const formReply = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/reply$/.exec(url.pathname) + if (formReply?.[1] && formReply[2]) { + await options.onFormReply?.({ + sessionID: decodeURIComponent(formReply[1]), + formID: decodeURIComponent(formReply[2]), + body, + send, + }) + return new Response(null, { status: 204 }) + } + const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname) if (interrupt?.[1]) { await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })