feat(cli): support acp elicitation

This commit is contained in:
Shoubhit Dash
2026-07-22 18:37:07 +05:30
parent a3e2cc0dcd
commit a36c8392b2
8 changed files with 434 additions and 9 deletions
+127 -4
View File
@@ -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<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
Partial<Pick<AgentSideConnection, "writeTextFile" | "unstable_createElicitation">>
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<unknown>
readonly control: TurnControl
}): Promise<PromptResponse> {
@@ -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<AgentSideConnection, "sessionUpdate">,
sessionID: string,
+12 -1
View File
@@ -47,7 +47,8 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "unstable_createElicitation">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
@@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
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))
+202 -3
View File
@@ -6,7 +6,8 @@ import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/even
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "unstable_createElicitation">>
type Fixture = ReturnType<typeof createSseFixture>
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<Parameters<AgentSideConnection["unstable_createElicitation"]>[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 } }
}
+1
View File
@@ -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" }),
})
@@ -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 }),
})
+2
View File
@@ -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 } : {}),
},
})
@@ -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) {
+17
View File
@@ -33,6 +33,12 @@ type FixtureOptions = {
readonly formID: string
readonly send: (event: unknown) => void
}) => void | Promise<void>
readonly onFormReply?: (input: {
readonly sessionID: string
readonly formID: string
readonly body: unknown
readonly send: (event: unknown) => void
}) => void | Promise<void>
}
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 })