import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise" import { runNonInteractivePrompt } from "../../src/run/noninteractive" type V2Event = EventSubscribeOutput type FormInfo = Extract["data"]["form"] function ok(data: T) { return Promise.resolve(data) } function form(id: string, sessionID: string): FormInfo { return { id, sessionID, title: "Input requested", fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }], } } function formCreated(info: FormInfo): V2Event { return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } } } function prompted(inputID: string): V2Event { return { id: "evt_prompted", created: 0, type: "session.input.promoted", durable: { aggregateID: "ses_1", seq: 0, version: 1 }, data: { sessionID: "ses_1", inputID }, } } function settled(outcome: "success" | "interrupted" = "success"): V2Event { if (outcome === "interrupted") return { id: "evt_interrupted", created: 0, type: "session.execution.interrupted", durable: { aggregateID: "ses_1", seq: 1, version: 1 }, data: { sessionID: "ses_1", reason: "user" }, } return { id: "evt_succeeded", created: 0, type: "session.execution.succeeded", durable: { aggregateID: "ses_1", seq: 1, version: 1 }, data: { sessionID: "ses_1" }, } } function stepStarted(): V2Event { return { id: "evt_step_started", created: 1, type: "session.step.started", durable: { aggregateID: "ses_1", seq: 1, version: 1 }, data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", agent: "build", model: { providerID: "test", id: "test-model" }, }, } } function stepFailed(message: string): V2Event { return { id: "evt_step_failed", created: 2, type: "session.step.failed", durable: { aggregateID: "ses_1", seq: 2, version: 1 }, data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", error: { type: "provider.transport", message }, }, } } function executionFailed(message: string): V2Event { return { id: "evt_execution_failed", created: 3, type: "session.execution.failed", durable: { aggregateID: "ses_1", seq: 3, version: 1 }, data: { sessionID: "ses_1", error: { type: "provider.transport", message }, }, } } // 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: (inputID: string) => V2Event[] pendingForms?: FormInfo[] attached?: boolean format?: "default" | "json" compatibility?: "v1" }) { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) 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.event, "subscribe").mockImplementation(() => stream) spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never) spyOn(sdk.form, "list").mockImplementation( (request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never, ) spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never) spyOn(sdk.session, "prompt").mockImplementation((request) => { const messageID = request.id ?? "msg_prompt" values.push(...input.turn(messageID)) wake?.() wake = undefined return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never }) await runNonInteractivePrompt({ client: sdk, sessionID: "ses_1", message: "hello", files: [], thinking: false, format: input.format ?? "default", auto: false, attached: input.attached ?? false, compatibility: input.compatibility, renderTool: () => Promise.resolve(), renderToolError: () => Promise.resolve(), }) return sdk } async function capture(input: Parameters[0]) { const stdout: string[] = [] const stderr: string[] = [] const exitCode = process.exitCode const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk) => { stdout.push(String(chunk)) return true }) const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => { stderr.push(String(chunk)) return true }) try { await run(input) return { stdout: stdout.join(""), stderr: stderr.join("") } } finally { process.exitCode = exitCode ?? 0 stdoutWrite.mockRestore() stderrWrite.mockRestore() } } 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.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) expect(sdk.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.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" }) expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }) expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }) }) test("V1 JSON output flushes step_start before an unrelated step failure", async () => { const output = await capture({ compatibility: "v1", format: "json", turn: (messageID) => [ prompted(messageID), stepStarted(), stepFailed("Provider request failed"), executionFailed("Provider request failed"), ], }) expect( output.stdout .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)), ).toEqual([ expect.objectContaining({ type: "step_start", part: expect.objectContaining({ type: "step-start" }) }), expect.objectContaining({ type: "error", error: { type: "provider.transport", message: "Provider request failed" }, }), ]) expect(output.stderr).toBe("") }) test("V1 default output flushes step_start before an unrelated execution failure", async () => { const output = await capture({ compatibility: "v1", turn: (messageID) => [prompted(messageID), stepStarted(), executionFailed("Execution failed")], }) expect(output.stdout).toBe("") expect(output.stderr).toContain("> build · test-model") expect(output.stderr).toContain("Error: \u001b[0mExecution failed") expect(output.stderr.indexOf("> build · test-model")).toBeLessThan(output.stderr.indexOf("Execution failed")) }) test("V1 preserves terminal-finish failure suppression before content", async () => { const output = await capture({ compatibility: "v1", format: "json", turn: (messageID) => [ prompted(messageID), stepStarted(), stepFailed("Provider stream ended without a terminal finish event"), executionFailed("Provider stream ended without a terminal finish event"), ], }) expect(output).toEqual({ stdout: "", stderr: "" }) }) })