cli: extract run from mini package (#37737)
This commit is contained in:
@@ -99,9 +99,9 @@ export const RunCommand = effectCmd({
|
||||
default: false,
|
||||
}),
|
||||
handler: Effect.fn("Cli.run")(function* (args) {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini"))
|
||||
const { runV1Bridge } = yield* Effect.promise(() => import("@opencode-ai/cli/run"))
|
||||
yield* Effect.promise(() =>
|
||||
runNonInteractive({
|
||||
runV1Bridge({
|
||||
message: [...args.message, ...(args["--"] || [])],
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
@@ -112,7 +112,6 @@ export const RunCommand = effectCmd({
|
||||
file: args.file ?? [],
|
||||
title: args.title,
|
||||
server: args.server ?? args.attach,
|
||||
// @ts-expect-error V1 does not consume the V2-only resolved server input.
|
||||
password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
|
||||
username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
|
||||
directory: args.dir,
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
||||
import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
|
||||
|
||||
type V2Event = EventSubscribeOutput
|
||||
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
|
||||
function ok<T>(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" },
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
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<V2Event, void, unknown> {
|
||||
while (true) {
|
||||
const value = values.shift()
|
||||
if (!value) {
|
||||
await new Promise<void>((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: "default",
|
||||
auto: 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.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" })
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,13 @@
|
||||
// an isolated test provider config under the fixture's temp home.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { reply } from "../../lib/llm-server"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { testProviderConfig } from "../../lib/test-provider"
|
||||
|
||||
const opencodeRoot = path.resolve(import.meta.dir, "../../..")
|
||||
|
||||
describe("opencode run (non-interactive subprocess)", () => {
|
||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||
// If this fails, all the others likely will too — debug here first.
|
||||
@@ -367,9 +370,12 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("variant response")
|
||||
const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
|
||||
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
|
||||
})
|
||||
const result = yield* opencode.spawn(
|
||||
["run", "--model", "test/test-model", "--variant", "default", "use the model"],
|
||||
{
|
||||
config: { ...testProviderConfig(llm.url), model: "test/test-model" },
|
||||
},
|
||||
)
|
||||
|
||||
opencode.expectExit(result, 0)
|
||||
expect(result.stdout).toBe("variant response\n")
|
||||
@@ -382,7 +388,15 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const source = `${home}/image.png`
|
||||
yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64")))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
source,
|
||||
Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* llm.text("attachment received")
|
||||
const config = testProviderConfig(llm.url)
|
||||
config.provider.test.models["test-model"].attachment = true
|
||||
@@ -395,7 +409,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
opencode.expectExit(result, 0)
|
||||
const input = JSON.stringify(yield* llm.inputs)
|
||||
expect(input).toContain("image/png")
|
||||
expect(input).not.toContain("<file name=\\\"image.png\\\">")
|
||||
expect(input).not.toContain('<file name=\\"image.png\\">')
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
@@ -422,6 +436,26 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"attach mode without --dir uses the remote server location",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(home).not.toBe(opencodeRoot)
|
||||
yield* llm.text("remote location used")
|
||||
const server = yield* opencode.serve()
|
||||
|
||||
const result = yield* opencode.run("use the server location", {
|
||||
extraArgs: ["--attach", server.url],
|
||||
})
|
||||
|
||||
opencode.expectExit(result, 0)
|
||||
const input = JSON.stringify(yield* llm.inputs)
|
||||
expect(input).toContain(`Working directory: ${opencodeRoot}`)
|
||||
expect(input).not.toContain(`Working directory: ${home}`)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.concurrent(
|
||||
"attach mode rejects local directories before prompt admission",
|
||||
({ home, opencode }) =>
|
||||
|
||||
Reference in New Issue
Block a user