feat(cli): support acp elicitation
This commit is contained in:
parent
a3e2cc0dcd
commit
a36c8392b2
8 changed files with 434 additions and 9 deletions
|
|
@ -1,6 +1,13 @@
|
||||||
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
|
import type {
|
||||||
|
AgentSideConnection,
|
||||||
|
CreateElicitationRequest,
|
||||||
|
ElicitationPropertySchema,
|
||||||
|
PromptResponse,
|
||||||
|
} from "@agentclientprotocol/sdk"
|
||||||
import type {
|
import type {
|
||||||
EventSubscribeOutput,
|
EventSubscribeOutput,
|
||||||
|
FormField,
|
||||||
|
FormInfo,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
SessionMessageAssistant,
|
SessionMessageAssistant,
|
||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
|
|
@ -18,7 +25,7 @@ import {
|
||||||
} from "./tool"
|
} from "./tool"
|
||||||
|
|
||||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
Partial<Pick<AgentSideConnection, "writeTextFile" | "unstable_createElicitation">>
|
||||||
|
|
||||||
export type TurnControl = {
|
export type TurnControl = {
|
||||||
cancelled: boolean
|
cancelled: boolean
|
||||||
|
|
@ -48,6 +55,7 @@ export async function streamTurn(input: {
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly start: TurnStart
|
readonly start: TurnStart
|
||||||
readonly userMessageID?: string | null
|
readonly userMessageID?: string | null
|
||||||
|
readonly elicitation: boolean
|
||||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||||
readonly control: TurnControl
|
readonly control: TurnControl
|
||||||
}): Promise<PromptResponse> {
|
}): Promise<PromptResponse> {
|
||||||
|
|
@ -84,8 +92,12 @@ export async function streamTurn(input: {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
|
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
|
||||||
await input.client.form
|
await replyForm({
|
||||||
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
|
client: input.client,
|
||||||
|
connection: input.connection,
|
||||||
|
form: event.data.form,
|
||||||
|
elicitation: input.elicitation,
|
||||||
|
})
|
||||||
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
||||||
continue
|
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(
|
export async function replayMessages(
|
||||||
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@ import { ACPError } from "./error"
|
||||||
|
|
||||||
export const AuthMethodID = "opencode-login"
|
export const AuthMethodID = "opencode-login"
|
||||||
|
|
||||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
|
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||||
|
Partial<Pick<AgentSideConnection, "unstable_createElicitation">>
|
||||||
|
|
||||||
type Catalog = {
|
type Catalog = {
|
||||||
readonly providers: ConfigOptionProvider[]
|
readonly providers: ConfigOptionProvider[]
|
||||||
|
|
@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||||
const catalogs = new Map<string, Promise<Catalog>>()
|
const catalogs = new Map<string, Promise<Catalog>>()
|
||||||
const registeredMcp = new Map<string, Set<string>>()
|
const registeredMcp = new Map<string, Set<string>>()
|
||||||
const active = new Map<string, TurnControl>()
|
const active = new Map<string, TurnControl>()
|
||||||
|
const capabilities = { elicitation: false }
|
||||||
|
|
||||||
const catalog = (cwd: string) => {
|
const catalog = (cwd: string) => {
|
||||||
const cached = catalogs.get(cwd)
|
const cached = catalogs.get(cwd)
|
||||||
|
|
@ -157,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialize: async (params) => {
|
initialize: async (params) => {
|
||||||
|
capabilities.elicitation = supportsFormElicitation(params)
|
||||||
const authMethod: AuthMethod = {
|
const authMethod: AuthMethod = {
|
||||||
description: "Run `opencode auth login` in the terminal",
|
description: "Run `opencode auth login` in the terminal",
|
||||||
name: "Login with opencode",
|
name: "Login with opencode",
|
||||||
|
|
@ -296,6 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
start: prepared.start,
|
start: prepared.start,
|
||||||
userMessageID: params.messageId,
|
userMessageID: params.messageId,
|
||||||
|
elicitation: capabilities.elicitation,
|
||||||
control,
|
control,
|
||||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||||
}).finally(() => {
|
}).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 {
|
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
|
||||||
const parts = promptContentToParts(prompt)
|
const parts = promptContentToParts(prompt)
|
||||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/even
|
||||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||||
|
|
||||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
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>
|
type Fixture = ReturnType<typeof createSseFixture>
|
||||||
|
|
||||||
describe("acp event behavior", () => {
|
describe("acp event behavior", () => {
|
||||||
|
|
@ -437,6 +438,7 @@ describe("acp event behavior", () => {
|
||||||
sessionID: "ses_cancel",
|
sessionID: "ses_cancel",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id: "input_cancel" },
|
start: { type: "input", id: "input_cancel" },
|
||||||
|
elicitation: false,
|
||||||
control,
|
control,
|
||||||
submit: async (signal) => {
|
submit: async (signal) => {
|
||||||
await fixture.client.session.prompt(
|
await fixture.client.session.prompt(
|
||||||
|
|
@ -479,6 +481,7 @@ describe("acp event behavior", () => {
|
||||||
sessionID: "ses_cancel_admission",
|
sessionID: "ses_cancel_admission",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id: "input_cancel_admission" },
|
start: { type: "input", id: "input_cancel_admission" },
|
||||||
|
elicitation: false,
|
||||||
control,
|
control,
|
||||||
submit: (signal) =>
|
submit: (signal) =>
|
||||||
fixture.client.session.prompt(
|
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({
|
const fixture = createSseFixture({
|
||||||
onPrompt({ id, send }) {
|
onPrompt({ id, send }) {
|
||||||
send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
|
send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
|
||||||
|
|
@ -514,7 +688,7 @@ describe("acp event behavior", () => {
|
||||||
sessionID: "ses_form",
|
sessionID: "ses_form",
|
||||||
title: "Questions",
|
title: "Questions",
|
||||||
metadata: { kind: "question" },
|
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([]),
|
connection: recordingConnection([]),
|
||||||
sessionID: "ses_form",
|
sessionID: "ses_form",
|
||||||
inputID: "input_form",
|
inputID: "input_form",
|
||||||
|
elicitation: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.stopReason).toBe("end_turn")
|
expect(response.stopReason).toBe("end_turn")
|
||||||
|
|
@ -557,6 +732,7 @@ function turn(input: {
|
||||||
readonly connection: Connection
|
readonly connection: Connection
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly inputID: string
|
readonly inputID: string
|
||||||
|
readonly elicitation?: boolean
|
||||||
}) {
|
}) {
|
||||||
return streamTurn({
|
return streamTurn({
|
||||||
client: input.fixture.client,
|
client: input.fixture.client,
|
||||||
|
|
@ -566,11 +742,34 @@ function turn(input: {
|
||||||
start: { type: "input", id: input.inputID },
|
start: { type: "input", id: input.inputID },
|
||||||
userMessageID: `client_${input.inputID}`,
|
userMessageID: `client_${input.inputID}`,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
|
elicitation: input.elicitation ?? false,
|
||||||
submit: (signal) =>
|
submit: (signal) =>
|
||||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { 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() {
|
function tokens() {
|
||||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
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",
|
cwd: "/workspace",
|
||||||
start: { type: "input", id },
|
start: { type: "input", id },
|
||||||
userMessageID,
|
userMessageID,
|
||||||
|
elicitation: false,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
|
||||||
sessionID,
|
sessionID,
|
||||||
cwd,
|
cwd,
|
||||||
start: { type: "input", id: inputID },
|
start: { type: "input", id: inputID },
|
||||||
|
elicitation: false,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
|
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type FixtureHandler = (
|
||||||
|
|
||||||
type FixtureOptions = {
|
type FixtureOptions = {
|
||||||
readonly fetch?: FixtureHandler
|
readonly fetch?: FixtureHandler
|
||||||
|
readonly createElicitation?: AgentSideConnection["unstable_createElicitation"]
|
||||||
readonly models?: readonly ModelInfo[]
|
readonly models?: readonly ModelInfo[]
|
||||||
readonly defaultModel?: ModelInfo
|
readonly defaultModel?: ModelInfo
|
||||||
readonly agents?: readonly AgentInfo[]
|
readonly agents?: readonly AgentInfo[]
|
||||||
|
|
@ -192,6 +193,7 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||||
updates.push(update)
|
updates.push(update)
|
||||||
},
|
},
|
||||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||||
|
...(options.createElicitation ? { unstable_createElicitation: options.createElicitation } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,79 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
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 { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||||
|
import { durableEvent, ephemeralEvent } from "./sse-fixture"
|
||||||
|
|
||||||
describe("acp service lifecycle", () => {
|
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 () => {
|
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||||
await using fixture = makeACPFixture({
|
await using fixture = makeACPFixture({
|
||||||
fetch(request) {
|
fetch(request) {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ type FixtureOptions = {
|
||||||
readonly formID: string
|
readonly formID: string
|
||||||
readonly send: (event: unknown) => void
|
readonly send: (event: unknown) => void
|
||||||
}) => void | Promise<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 }
|
const ids = { next: 0 }
|
||||||
|
|
@ -150,6 +156,17 @@ export function createSseFixture(options: FixtureOptions = {}) {
|
||||||
return new Response(null, { status: 204 })
|
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)
|
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
|
||||||
if (interrupt?.[1]) {
|
if (interrupt?.[1]) {
|
||||||
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
|
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue