feat(cli): support acp elicitation

This commit is contained in:
Shoubhit Dash 2026-07-22 18:37:07 +05:30
commit a36c8392b2
8 changed files with 434 additions and 9 deletions

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 } }
}

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" }),
})

View file

@ -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 }),
})

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 } : {}),
},
})

View file

@ -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) {

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 })