mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:25:32 -04:00
feat(cli): support acp elicitation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user