feat(cli): support acp elicitation
This commit is contained in:
parent
a3e2cc0dcd
commit
a36c8392b2
8 changed files with 434 additions and 9 deletions
|
|
@ -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 } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue