diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index fc495213c4..f56775e26c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/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, }) diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/opencode/src/cli/cmd/run/noninteractive.ts index 440436c704..fe6b95267c 100644 --- a/packages/opencode/src/cli/cmd/run/noninteractive.ts +++ b/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 renderToolError: (part: ToolPart) => Promise } @@ -50,6 +52,13 @@ type ToolState = StartedPart & { provider?: unknown } +type FormRequest = Extract["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) => { + 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 { diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts new file mode 100644 index 0000000000..15cf799c75 --- /dev/null +++ b/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["data"]["form"] + +function ok(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 { + while (true) { + const value = values.shift() + if (!value) { + await new Promise((resolve) => { + wake = resolve + }) + continue + } + yield value + } + })() + spyOn(sdk.v2.event, "subscribe").mockImplementation( + () => Promise.resolve({ stream }) as ReturnType, + ) + 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" }) + }) +})