Просмотр исходного кода

feat(cli): support acp elicitation

Shoubhit Dash 3 недель назад
Родитель
Сommit
a36c8392b2

+ 127 - 4
packages/cli/src/acp/event.ts

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

+ 12 - 1
packages/cli/src/acp/service.ts

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

+ 202 - 3
packages/cli/test/acp/event-behavior.test.ts

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

+ 1 - 0
packages/cli/test/acp/event.test.ts

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

+ 1 - 0
packages/cli/test/acp/permission-behavior.test.ts

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

+ 2 - 0
packages/cli/test/acp/service-fixture.ts

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

+ 72 - 1
packages/cli/test/acp/service-lifecycle.test.ts

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

+ 17 - 0
packages/cli/test/acp/sse-fixture.ts

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