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

fix(run): handle unattended form blockers (#35170)

Simon Klee 1 месяц назад
Родитель
Сommit
1fa605ad5e

+ 1 - 0
packages/opencode/src/cli/cmd/run.ts

@@ -958,6 +958,7 @@ export const RunCommand = effectCmd({
                 thinking,
                 format: args.format === "json" ? "json" : "default",
                 dangerouslySkipPermissions: args["dangerously-skip-permissions"],
+                attached: Boolean(args.attach),
                 renderTool: tool,
                 renderToolError: toolError,
               })

+ 34 - 4
packages/opencode/src/cli/cmd/run/noninteractive.ts

@@ -33,6 +33,8 @@ type Input = {
   thinking: boolean
   format: "default" | "json"
   dangerouslySkipPermissions: boolean
+  /** True when the client is attached to a shared server rather than an exclusive in-process one. */
+  attached: boolean
   renderTool: (part: ToolPart) => Promise<void>
   renderToolError: (part: ToolPart) => Promise<void>
 }
@@ -50,6 +52,13 @@ type ToolState = StartedPart & {
   provider?: unknown
 }
 
+type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
+
+// MCP elicitations are temporarily owned by the "global" sentinel instead of a real
+// session. An exclusive local process may treat them as this run's blockers; an
+// attached client must not cancel input that may belong to another session.
+const GLOBAL_FORM_SESSION_ID = "global"
+
 export async function runNonInteractivePrompt(input: Input) {
   const controller = new AbortController()
   const events = await input.client.v2.event.subscribe({
@@ -69,6 +78,7 @@ export async function runNonInteractivePrompt(input: Input) {
   let emittedError = false
   let questionRejected = false
   let permissionRejected = false
+  let formCancelled = false
   let interrupted = false
   let admission: AbortController | undefined
 
@@ -117,6 +127,11 @@ export async function runNonInteractivePrompt(input: Input) {
     await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
   }
 
+  const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
+    formCancelled = true
+    await input.client.v2.session.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
+  }
+
   const consume = async () => {
     while (!controller.signal.aborted) {
       const next = await stream.next()
@@ -131,6 +146,15 @@ export async function runNonInteractivePrompt(input: Input) {
         await rejectQuestion(event.data)
         continue
       }
+      if (
+        event.type === "form.created" &&
+        submitted &&
+        (event.data.form.sessionID === input.sessionID ||
+          (!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
+      ) {
+        await cancelForm(event.data.form)
+        continue
+      }
       if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
       const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
 
@@ -144,7 +168,7 @@ export async function runNonInteractivePrompt(input: Input) {
       if (
         event.type === "session.next.execution.settled" &&
         event.data.outcome === "interrupted" &&
-        (interrupted || permissionRejected || questionRejected)
+        (interrupted || permissionRejected || questionRejected || formCancelled)
       ) {
         return
       }
@@ -320,14 +344,14 @@ export async function runNonInteractivePrompt(input: Input) {
         continue
       }
       if (event.type === "session.next.step.failed") {
-        if (interrupted || permissionRejected || questionRejected) continue
+        if (interrupted || permissionRejected || questionRejected || formCancelled) continue
         emittedError = true
         process.exitCode = 1
         if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
         continue
       }
       if (event.type === "session.next.execution.settled") {
-        if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
+        if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
           emittedError = true
           process.exitCode = 1
           const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
@@ -406,13 +430,19 @@ export async function runNonInteractivePrompt(input: Input) {
     if (!response.data.data) throw new Error("Prompt was not admitted")
     if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
 
-    const [permissions, questions] = await Promise.all([
+    const [permissions, questions, forms] = await Promise.all([
       input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
       input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
+      Promise.all(
+        (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
+          input.client.v2.session.form.list({ sessionID }).catch(() => undefined),
+        ),
+      ),
     ])
     await Promise.all([
       ...(permissions?.data?.data ?? []).map(replyPermission),
       ...(questions?.data?.data ?? []).map(rejectQuestion),
+      ...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm),
     ])
     await completed
   } finally {

+ 121 - 0
packages/opencode/test/cli/run/noninteractive.test.ts

@@ -0,0 +1,121 @@
+import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
+import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
+import { runNonInteractivePrompt } from "@/cli/cmd/run/noninteractive"
+
+type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
+
+function ok<T>(data: T) {
+  return Promise.resolve({
+    data,
+    error: undefined,
+    request: new Request("https://opencode.test"),
+    response: new Response(),
+  })
+}
+
+function form(id: string, sessionID: string): FormInfo {
+  return { id, sessionID, mode: "form", fields: [] }
+}
+
+function formCreated(info: FormInfo): V2Event {
+  return { id: `evt_${info.id}`, type: "form.created", data: { form: info } }
+}
+
+function prompted(messageID: string): V2Event {
+  return {
+    id: "evt_prompted",
+    type: "session.next.prompted",
+    data: { timestamp: 1, sessionID: "ses_1", messageID, prompt: { text: "hello" }, delivery: "steer" },
+  }
+}
+
+function settled(outcome: "success" | "interrupted" = "success"): V2Event {
+  return {
+    id: "evt_settled",
+    type: "session.next.execution.settled",
+    data: { timestamp: 2, sessionID: "ses_1", outcome },
+  }
+}
+
+// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
+// live events the prompt admission triggers, keyed by the generated message ID.
+async function run(input: {
+  turn: (messageID: string) => V2Event[]
+  pendingForms?: FormInfo[]
+  attached?: boolean
+}) {
+  const sdk = new OpencodeClient()
+  const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
+  let wake: (() => void) | undefined
+  const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
+    while (true) {
+      const value = values.shift()
+      if (!value) {
+        await new Promise<void>((resolve) => {
+          wake = resolve
+        })
+        continue
+      }
+      yield value
+    }
+  })()
+  spyOn(sdk.v2.event, "subscribe").mockImplementation(
+    () => Promise.resolve({ stream }) as ReturnType<typeof sdk.v2.event.subscribe>,
+  )
+  spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never)
+  spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never)
+  spyOn(sdk.v2.session.form, "list").mockImplementation((request) =>
+    ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never,
+  )
+  spyOn(sdk.v2.session.form, "cancel").mockImplementation(() => ok(undefined) as never)
+  spyOn(sdk.v2.session, "prompt").mockImplementation((request) => {
+    const messageID = request.id ?? "msg_prompt"
+    values.push(...input.turn(messageID))
+    wake?.()
+    wake = undefined
+    return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 } }) as never
+  })
+  await runNonInteractivePrompt({
+    client: sdk,
+    sessionID: "ses_1",
+    message: "hello",
+    files: [],
+    thinking: false,
+    format: "default",
+    dangerouslySkipPermissions: false,
+    attached: input.attached ?? false,
+    renderTool: () => Promise.resolve(),
+    renderToolError: () => Promise.resolve(),
+  })
+  return sdk
+}
+
+afterEach(() => {
+  mock.restore()
+})
+
+describe("runNonInteractivePrompt", () => {
+  test("cancels session and global form blockers and exits on pre-promotion interrupt", async () => {
+    const sdk = await run({
+      pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
+      // No prompted event: the execution settles interrupted before promotion,
+      // which must not leave the consume loop waiting forever.
+      turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
+    })
+    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
+    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
+    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
+  })
+
+  test("attach mode cancels only session-owned forms", async () => {
+    const sdk = await run({
+      attached: true,
+      pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
+      turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
+    })
+    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
+    expect(sdk.v2.session.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
+    expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
+    expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
+  })
+})