Files
opencode/packages/cli/test/acp/elicitation.test.ts

528 lines
21 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import type { AnyMessage, AnyRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk"
import { ACPElicitation } from "../../src/acp/elicitation"
import {
childCreated,
delivered,
durableEvent,
ephemeralEvent,
interrupted,
startSession,
succeeded,
textDelta,
toolStarted,
toolSucceeded,
} from "./wire-fixture"
const questions = (sessionID: string, id = "frm_question", tool = "call_question") =>
ephemeralEvent("form.created", {
form: {
id,
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_tools", id: tool } },
fields: [
{
key: "q0",
title: "Runtime",
description: "Which runtime?",
type: "string",
options: [
{ value: "Bun", label: "Bun", description: "Fast" },
{ value: "Node", label: "Node", description: "Stable" },
],
custom: true,
},
{
key: "q1",
title: "Goals",
description: "What matters?",
type: "multiselect",
options: [{ value: "Fast", label: "Fast", description: "Speed" }],
custom: true,
},
],
},
})
const capable = { childSessionUpdates: false, formElicitation: true, compaction: false }
const form = (
fields: ACPElicitation.AskedForm["fields"],
metadata: ACPElicitation.AskedForm["metadata"] = { kind: "question" },
) => ({ id: "frm_test", sessionID: "ses_test", title: "Test", metadata, fields })
const accept = (content: Record<string, string | number | boolean | string[]>): CreateElicitationResponse => ({
action: "accept",
content,
})
const pendingUntilAborted = (_request: unknown, signal: AbortSignal) =>
new Promise<CreateElicitationResponse>((resolve) => {
signal.addEventListener("abort", () => resolve({ action: "cancel" }), { once: true })
})
const firstElicitationCancel = (received: readonly AnyMessage[]): AnyMessage => {
const asked = received.find(
(message): message is AnyRequest =>
"method" in message && "id" in message && message.method === "elicitation/create",
)
return { jsonrpc: "2.0", method: "$/cancel_request", params: { requestId: asked?.id } }
}
describe("acp elicitation mapping", () => {
test("maps every representable field type", () => {
expect(
ACPElicitation.requestedSchema(
form([
{
key: "email",
title: "Email",
description: "Work address",
type: "string",
format: "email",
maxLength: 80,
pattern: ".+@.+",
placeholder: "[email protected]",
default: "[email protected]",
required: true,
},
{ key: "name", type: "string", minLength: 2 },
{
key: "plan",
type: "string",
options: [
{ value: "pro", label: "Pro", description: "Paid" },
{ value: "free", label: "Free" },
],
default: "free",
},
{ key: "ratio", type: "number", minimum: 0, maximum: 1, default: 0.5 },
{ key: "count", type: "integer", minimum: 1, required: true },
{ key: "confirm", type: "boolean", default: false },
{
key: "tags",
type: "multiselect",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
maxItems: 2,
default: ["a"],
required: true,
},
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://example.com" },
]),
capable,
),
).toEqual({
type: "object",
properties: {
email: {
type: "string",
title: "Email",
description: "Work address",
format: "email",
minLength: 1,
maxLength: 80,
pattern: ".+@.+",
default: "[email protected]",
},
name: { type: "string", minLength: 2 },
plan: {
type: "string",
oneOf: [
{ const: "pro", title: "Pro", description: "Paid" },
{ const: "free", title: "Free" },
],
default: "free",
},
ratio: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
count: { type: "integer", minimum: 1 },
confirm: { type: "boolean", default: false },
tags: {
type: "array",
items: {
anyOf: [
{ const: "a", title: "A" },
{ const: "b", title: "B" },
],
},
minItems: 1,
maxItems: 2,
default: ["a"],
},
},
required: ["email", "count", "tags"],
})
})
test("adds a free-text property next to options that accept a custom answer", () => {
expect(
ACPElicitation.requestedSchema(
form([
{
key: "q0",
title: "Runtime",
type: "string",
options: [{ value: "Bun", label: "Bun" }],
custom: true,
maxLength: 20,
},
{ key: "q1", type: "multiselect", options: [{ value: "Fast", label: "Fast" }], custom: true },
]),
capable,
)?.properties,
).toEqual({
q0: { type: "string", title: "Runtime", oneOf: [{ const: "Bun", title: "Bun" }] },
q0_custom: { type: "string", title: "Runtime (other)", description: "Type your own answer", maxLength: 20 },
q1: { type: "array", items: { anyOf: [{ const: "Fast", title: "Fast" }] } },
q1_custom: { type: "string", title: "q1 (other)", description: "Add your own answer" },
})
})
test("cancels forms from unsupported clients, unknown flows, and credential-looking fields", () => {
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
expect(ACPElicitation.requestedSchema(form(fields), { ...capable, formElicitation: false })).toBeUndefined()
expect(ACPElicitation.requestedSchema(form(fields, { kind: "mcp-elicitation" }), capable)).toBeUndefined()
expect(ACPElicitation.requestedSchema(form(fields, {}), capable)).toBeUndefined()
expect(ACPElicitation.requestedSchema(form(fields, { kind: "websearch.provider" }), capable)).toBeDefined()
const credentials: Array<ACPElicitation.AskedForm["fields"]> = [
[{ key: "api_key", type: "string" }],
[{ key: "q0", title: "GitHub token", type: "string" }],
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
[{ key: "q0", title: "Setup", description: "Paste your API key", type: "string" }],
[{ key: "q0", title: "Setup", type: "string", options: [{ value: "a", label: "Use my access token" }] }],
]
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
credentials.map(() => undefined),
)
})
test("cancels forms it cannot represent faithfully", () => {
const options = [{ value: "a", label: "A" }]
const unrepresentable: Array<ACPElicitation.AskedForm["fields"]> = [
[
{ key: "mode", type: "boolean" },
{ key: "detail", type: "string", when: [{ key: "mode", op: "eq", value: true }] },
],
[
{ key: "mode", type: "boolean" },
{ key: "detail", type: "string", hidden: true, default: "x", when: [{ key: "mode", op: "eq", value: true }] },
],
[{ key: "login", type: "external", url: "https://example.com/login" }],
[{ key: "server", type: "string", hidden: true, required: true }],
[{ key: "pick", type: "string", options, custom: true, required: true }],
[{ key: "pick", type: "multiselect", options, custom: true, maxItems: 1 }],
[{ key: "pick", type: "string", options, default: "b" }],
[{ key: "pick", type: "multiselect", options, default: ["a", "b"] }],
[
{ key: "pick", type: "string", options, custom: true },
{ key: "pick_custom", type: "string" },
],
]
expect(unrepresentable.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
unrepresentable.map(() => undefined),
)
expect(
ACPElicitation.requestedSchema(
form([
{ key: "name", type: "string" },
{ key: "pick", type: "string", options, default: "b", hidden: true },
]),
capable,
)?.properties,
).toEqual({ name: { type: "string" } })
})
test("maps an accepted response back to answers", () => {
const options = [{ value: "a", label: "A" }]
const fields: ACPElicitation.AskedForm["fields"] = [
{ key: "single", type: "string", options, custom: true },
{ key: "multi", type: "multiselect", options, custom: true },
{ key: "blank", type: "string", options, custom: true },
{ key: "count", type: "integer" },
{ key: "server", type: "string", hidden: true, default: "https://example.com" },
{ key: "region", type: "string", hidden: true },
]
expect(
ACPElicitation.answer(
form(fields),
accept({
single: "a",
single_custom: "typed",
multi: ["a"],
multi_custom: "extra",
blank: "a",
blank_custom: " ",
count: 3,
server: "https://other.example.com",
unknown: true,
}),
),
).toEqual({
single: "typed",
multi: ["a", "extra"],
blank: "a",
count: 3,
server: "https://example.com",
})
expect(ACPElicitation.answer(form(fields), accept({ multi_custom: "only" }))).toEqual({
multi: ["only"],
server: "https://example.com",
})
expect(ACPElicitation.answer(form(fields), { action: "accept" })).toEqual({ server: "https://example.com" })
})
test("has no answer unless the user accepted valid content", () => {
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
expect(ACPElicitation.answer(form(fields), { action: "decline" })).toBeUndefined()
expect(ACPElicitation.answer(form(fields), { action: "cancel" })).toBeUndefined()
expect(ACPElicitation.answer(form(fields), { action: "_custom" })).toBeUndefined()
expect(
ACPElicitation.answer(form(fields), { action: "accept", content: { name: { nested: true } } }),
).toBeUndefined()
})
})
describe("acp elicitation over the wire", () => {
test("answers a question form through elicitation and continues the turn", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
toolStarted(sessionID, "call_question", "question"),
questions(sessionID),
],
elicitation: () => accept({ q0: "Bun", q1: ["Fast"], q1_custom: "Small" }),
onFormReply: ({ sessionID, formID }) => [
ephemeralEvent("form.replied", { sessionID, id: formID, answer: {} }),
toolSucceeded(sessionID, "call_question", {}, "answered"),
textDelta(sessionID, "msg_after", "thanks"),
succeeded(sessionID),
],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toMatchObject([
{ mode: "form", sessionId: acp.sessionId, toolCallId: "call_question", message: "Questions" },
])
expect(acp.server.repliedForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_question", answer: { q0: "Bun", q1: ["Fast", "Small"] } },
])
expect(acp.server.cancelledForms).toEqual([])
expect(acp.updates.some((item) => item.update.sessionUpdate === "agent_message_chunk")).toBe(true)
})
test("cancels the form when the client declines, cancels, fails, or sends a wrong value type", async () => {
const responses: Array<() => CreateElicitationResponse> = [
() => ({ action: "decline" }),
() => ({ action: "cancel" }),
() => {
throw new Error("elicitation UI failed")
},
() => ({ action: "accept", content: { q0: { nested: true } } }),
]
const ids = ["frm_decline", "frm_cancel", "frm_fail", "frm_invalid"]
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...ids.map((form) => questions(sessionID, form))],
elicitation: () => responses[acp.elicitations.length - 1](),
onFormCancel: ({ sessionID }) => (acp.server.cancelledForms.length === ids.length ? [succeeded(sessionID)] : []),
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(ids.length)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
expect(acp.server.cancelledForms.map((item) => item.message)).toEqual(ids.map(() => undefined))
expect(acp.server.repliedForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("cancels forms outside the allowed flows without asking", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_plugin",
sessionID,
title: "Plugin",
metadata: { kind: "plugin" },
fields: [{ key: "name", type: "string" }],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
})
test("cancels a question that looks like it asks for a credential with a message for the model", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_token",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
fields: [
{
key: "q0",
title: "Setup",
description: "Paste your API key",
type: "string",
options: [{ value: "Later", label: "Later" }],
custom: true,
},
],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([
{ sessionID: acp.sessionId, formID: "frm_token", message: ACPElicitation.UnshownQuestionMessage },
])
})
test("cancels other forms it cannot show without a message", async () => {
await using acp = await startSession({
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
ephemeralEvent("form.created", {
form: {
id: "frm_websearch",
sessionID,
title: "Web search provider",
metadata: { kind: "websearch.provider" },
fields: [{ key: "provider", type: "string", options: [{ value: "exa", label: "Exa" }] }],
},
}),
],
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toEqual([])
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_websearch" }])
})
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
questions(sessionID, "frm_pending"),
questions(sessionID, "frm_queued"),
],
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
elicitation: pendingUntilAborted,
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
await acp.notify("session/cancel", { sessionId: acp.sessionId })
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
await acp.until(() => acp.server.cancelledForms.length === 2, "form cancellation")
expect(acp.elicitations).toHaveLength(1)
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(["frm_pending", "frm_queued"])
expect(acp.server.repliedForms).toEqual([])
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
})
test("withdraws an elicitation for a form settled elsewhere and moves on to the next ask", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID, "frm_elsewhere")],
elicitation: (request, signal) =>
acp.elicitations.length === 1 ? pendingUntilAborted(request, signal) : accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID)],
})
const prompt = acp.prompt(acp.sessionId, "hello")
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
acp.server.send(
ephemeralEvent("form.replied", { sessionID: acp.sessionId, id: "frm_elsewhere", answer: { q0: "Bun" } }),
questions(acp.sessionId, "frm_next"),
)
expect((await prompt).stopReason).toBe("end_turn")
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
expect(acp.elicitations).toHaveLength(2)
expect(acp.server.repliedForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_next", answer: { q0: "Node" } }])
expect(acp.server.cancelledForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("leaves the session alone when the user's answer arrives after the form settled", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID)],
elicitation: () => accept({ q0: "Bun" }),
fetch: (request) => {
if (!request.path.endsWith("/form/frm_question/reply")) return undefined
acp.server.send(succeeded(acp.sessionId))
return Response.json(
{ _tag: "FormAlreadySettledError", id: "frm_question", message: "Form already settled: frm_question" },
{ status: 409 },
)
},
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.server.requests.filter((request) => request.path.endsWith("/reply"))).toHaveLength(1)
expect(acp.server.cancelledForms).toEqual([])
expect(acp.server.interrupts).toEqual([])
})
test("prefixes a foreground child form's tool call and message with the child", async () => {
await using acp = await startSession({
capabilities: { elicitation: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_child", sessionID, "Review code"),
durableEvent("session.execution.started", { sessionID: "ses_child" }),
questions("ses_child", "frm_child", "call_child"),
],
elicitation: () => accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toMatchObject([
{ sessionId: acp.sessionId, toolCallId: "ses_child:call_child", message: "Review code: Questions" },
])
expect(acp.server.repliedForms).toEqual([{ sessionID: "ses_child", formID: "frm_child", answer: { q0: "Node" } }])
})
test("omits the tool call when the child's tool calls only reach the client as child updates", async () => {
await using acp = await startSession({
capabilities: { elicitation: true, childSessionUpdates: true },
onPrompt: ({ sessionID, id }) => [
delivered(sessionID, id),
childCreated("ses_child", sessionID, "Review code"),
questions("ses_child", "frm_child", "call_child"),
],
elicitation: () => accept({ q0: "Node" }),
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
})
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
expect(acp.elicitations).toHaveLength(1)
expect(acp.elicitations[0]).toMatchObject({ sessionId: acp.sessionId, message: "Review code: Questions" })
expect(acp.elicitations[0]).not.toHaveProperty("toolCallId")
})
})