fix(run): handle unattended form blockers (#35170)
This commit is contained in:
@@ -958,6 +958,7 @@ export const RunCommand = effectCmd({
|
|||||||
thinking,
|
thinking,
|
||||||
format: args.format === "json" ? "json" : "default",
|
format: args.format === "json" ? "json" : "default",
|
||||||
dangerouslySkipPermissions: args["dangerously-skip-permissions"],
|
dangerouslySkipPermissions: args["dangerously-skip-permissions"],
|
||||||
|
attached: Boolean(args.attach),
|
||||||
renderTool: tool,
|
renderTool: tool,
|
||||||
renderToolError: toolError,
|
renderToolError: toolError,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Input = {
|
|||||||
thinking: boolean
|
thinking: boolean
|
||||||
format: "default" | "json"
|
format: "default" | "json"
|
||||||
dangerouslySkipPermissions: boolean
|
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>
|
renderTool: (part: ToolPart) => Promise<void>
|
||||||
renderToolError: (part: ToolPart) => Promise<void>
|
renderToolError: (part: ToolPart) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,13 @@ type ToolState = StartedPart & {
|
|||||||
provider?: unknown
|
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) {
|
export async function runNonInteractivePrompt(input: Input) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const events = await input.client.v2.event.subscribe({
|
const events = await input.client.v2.event.subscribe({
|
||||||
@@ -69,6 +78,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
let emittedError = false
|
let emittedError = false
|
||||||
let questionRejected = false
|
let questionRejected = false
|
||||||
let permissionRejected = false
|
let permissionRejected = false
|
||||||
|
let formCancelled = false
|
||||||
let interrupted = false
|
let interrupted = false
|
||||||
let admission: AbortController | undefined
|
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(() => {})
|
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 () => {
|
const consume = async () => {
|
||||||
while (!controller.signal.aborted) {
|
while (!controller.signal.aborted) {
|
||||||
const next = await stream.next()
|
const next = await stream.next()
|
||||||
@@ -131,6 +146,15 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
await rejectQuestion(event.data)
|
await rejectQuestion(event.data)
|
||||||
continue
|
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
|
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||||
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
|
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
|
||||||
|
|
||||||
@@ -144,7 +168,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
if (
|
if (
|
||||||
event.type === "session.next.execution.settled" &&
|
event.type === "session.next.execution.settled" &&
|
||||||
event.data.outcome === "interrupted" &&
|
event.data.outcome === "interrupted" &&
|
||||||
(interrupted || permissionRejected || questionRejected)
|
(interrupted || permissionRejected || questionRejected || formCancelled)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,14 +344,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.next.step.failed") {
|
if (event.type === "session.next.step.failed") {
|
||||||
if (interrupted || permissionRejected || questionRejected) continue
|
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.next.execution.settled") {
|
if (event.type === "session.next.execution.settled") {
|
||||||
if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
|
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
|
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 (!response.data.data) throw new Error("Prompt was not admitted")
|
||||||
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
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.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||||
input.client.v2.session.question.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([
|
await Promise.all([
|
||||||
...(permissions?.data?.data ?? []).map(replyPermission),
|
...(permissions?.data?.data ?? []).map(replyPermission),
|
||||||
...(questions?.data?.data ?? []).map(rejectQuestion),
|
...(questions?.data?.data ?? []).map(rejectQuestion),
|
||||||
|
...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm),
|
||||||
])
|
])
|
||||||
await completed
|
await completed
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user