cli: extract run from mini package (#37737)
This commit is contained in:
parent
cf6e5b3604
commit
3f5ad8441f
15 changed files with 500 additions and 50 deletions
87
packages/cli/test/drive/run-smoke.drive.mjs
Normal file
87
packages/cli/test/drive/run-smoke.drive.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { defineScript } from "opencode-drive"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
setup({ config }) {
|
||||
config.autoupdate = false
|
||||
},
|
||||
async run({ artifacts, llm, server }) {
|
||||
await configureServicePort(artifacts)
|
||||
llm.queue(llm.text("drive noninteractive smoke ok"))
|
||||
await server.launch()
|
||||
|
||||
const registration = await serviceRegistration(artifacts)
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const directory = path.join(artifacts, "files")
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
"drive smoke",
|
||||
],
|
||||
{
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: directory,
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
|
||||
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function configureServicePort(artifacts) {
|
||||
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const port = probe.port
|
||||
await probe.stop(true)
|
||||
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await Bun.write(file, JSON.stringify({ port }))
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function serviceRegistration(artifacts) {
|
||||
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
for (const name of ["service-local.json", "service.json"]) {
|
||||
const value = await Bun.file(path.join(directory, name))
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (isRegistration(value)) return value
|
||||
}
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Drive service registration was not written")
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function isRegistration(value) {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"url" in value &&
|
||||
typeof value.url === "string" &&
|
||||
"password" in value &&
|
||||
typeof value.password === "string"
|
||||
)
|
||||
}
|
||||
60
packages/cli/test/import-boundaries.test.ts
Normal file
60
packages/cli/test/import-boundaries.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
|
||||
describe("CLI frontend import boundaries", () => {
|
||||
test("exposes only the run entrypoints from the run package export", async () => {
|
||||
const entrypoint = await import("@opencode-ai/cli/run")
|
||||
const mini = await import("@opencode-ai/cli/mini")
|
||||
|
||||
expect(Object.keys(entrypoint).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["mergeInteractiveInput", "runMini", "validateMiniTerminal"])
|
||||
})
|
||||
|
||||
test("keeps run and Mini handlers on separate leaf graphs", async () => {
|
||||
const run = await bundleInputs("src/commands/handlers/run.ts")
|
||||
expect(run).toContain("src/run/run.ts")
|
||||
expect(run).not.toContain("src/mini/mini.ts")
|
||||
expect(run).not.toContain("src/mini/runtime.ts")
|
||||
|
||||
const mini = await bundleInputs("src/commands/handlers/mini.ts")
|
||||
expect(mini).toContain("src/mini/mini.ts")
|
||||
expect(mini).not.toContain("src/run/run.ts")
|
||||
expect(mini).not.toContain("src/run/noninteractive.ts")
|
||||
expect(mini).not.toContain("src/run/ui.ts")
|
||||
})
|
||||
})
|
||||
|
||||
async function bundleInputs(entrypoint: string) {
|
||||
const temporary = await mkdtemp(path.join(import.meta.dir, ".import-boundary-"))
|
||||
const metafile = path.join(temporary, "meta.json")
|
||||
try {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"build",
|
||||
entrypoint,
|
||||
"--target=bun",
|
||||
"--format=esm",
|
||||
"--packages=external",
|
||||
`--metafile=${metafile}`,
|
||||
`--outdir=${path.join(temporary, "out")}`,
|
||||
],
|
||||
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
const metadata = await Bun.file(metafile).json()
|
||||
return Object.keys(metadata.inputs).map((input) =>
|
||||
path.relative(root, path.resolve(root, input)).replaceAll(path.sep, "/"),
|
||||
)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "node:path"
|
||||
import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
|
||||
import { mergeInput as mergeInteractiveInput } from "../src/mini/mini"
|
||||
import { toolInlineInfo, toolOutputText, toolView } from "../src/mini/tool"
|
||||
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
|
|
@ -59,7 +60,7 @@ describe("mini command", () => {
|
|||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||
})
|
||||
|
||||
test("keeps run as mini's non-interactive input mode", () => {
|
||||
test("merges non-interactive argument and stdin input", () => {
|
||||
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
||||
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
||||
})
|
||||
|
|
@ -128,14 +129,7 @@ describe("mini command", () => {
|
|||
})
|
||||
|
||||
try {
|
||||
const result = await cli([
|
||||
"run",
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
"--model",
|
||||
"definitely/missing",
|
||||
"hi",
|
||||
])
|
||||
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||
|
|
|
|||
253
packages/cli/test/run/noninteractive.test.ts
Normal file
253
packages/cli/test/run/noninteractive.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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<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" },
|
||||
}
|
||||
}
|
||||
|
||||
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<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: 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<typeof run>[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: "" })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue