From 840385cf1a260b3b78e2cb58c1e27d57886563bf Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 19 Jul 2026 10:45:55 +0200 Subject: [PATCH] cli: extract run from mini package Give non-interactive execution its own entry so v1 can bridge onto the v2 run path without pulling interactive mini code. Keep legacy output quirks behind an explicit compatibility mode. --- packages/cli/package.json | 1 + packages/cli/src/commands/handlers/mini.ts | 2 +- packages/cli/src/commands/handlers/run.ts | 2 +- packages/cli/src/mini/index.ts | 7 - packages/cli/src/run/index.ts | 2 + .../cli/src/{mini => run}/noninteractive.ts | 50 ++++++- packages/cli/src/{mini => run}/run.ts | 60 +++++--- packages/cli/src/{mini => run}/ui.ts | 0 packages/cli/src/run/v1.ts | 85 ++++++++++++ packages/cli/test/drive/run-smoke.drive.mjs | 87 ++++++++++++ packages/cli/test/import-boundaries.test.ts | 60 ++++++++ packages/cli/test/mini.test.ts | 14 +- .../test}/run/noninteractive.test.ts | 131 +++++++++++++++++- packages/opencode/src/cli/cmd/run.ts | 5 +- .../opencode/test/cli/run/run-process.test.ts | 44 +++++- 15 files changed, 500 insertions(+), 50 deletions(-) create mode 100644 packages/cli/src/run/index.ts rename packages/cli/src/{mini => run}/noninteractive.ts (91%) rename packages/cli/src/{mini => run}/run.ts (79%) rename packages/cli/src/{mini => run}/ui.ts (100%) create mode 100644 packages/cli/src/run/v1.ts create mode 100644 packages/cli/test/drive/run-smoke.drive.mjs create mode 100644 packages/cli/test/import-boundaries.test.ts rename packages/{opencode/test/cli => cli/test}/run/noninteractive.test.ts (54%) diff --git a/packages/cli/package.json b/packages/cli/package.json index 4859175a0197..8889599a86b6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,7 @@ "./mini/footer.view": "./src/mini/footer.view.tsx", "./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx", "./mini/*": "./src/mini/*.ts", + "./run": "./src/run/index.ts", "./server-process": "./src/server-process.ts" }, "scripts": { diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index e44f7e0b2df5..55312f037863 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection" export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { - const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) + const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini/mini")) yield* Effect.promise(async () => validateMiniTerminal()) const serverURL = Option.getOrUndefined(input.server) const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone }) diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index 50fbaa4e2c44..74a88e8e727c 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection" export default Runtime.handler(Commands.commands.run, (input) => Effect.gen(function* () { - const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) + const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run")) const separator = process.argv.indexOf("--", 2) const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), diff --git a/packages/cli/src/mini/index.ts b/packages/cli/src/mini/index.ts index 4a6ef986fa1d..28d6af0b5f35 100644 --- a/packages/cli/src/mini/index.ts +++ b/packages/cli/src/mini/index.ts @@ -1,8 +1 @@ export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini" -export { - runNonInteractive, - mergeInput as mergeNonInteractiveInput, - pickRunModel, - parseRunModel, - type RunCommandInput, -} from "./run" diff --git a/packages/cli/src/run/index.ts b/packages/cli/src/run/index.ts new file mode 100644 index 000000000000..68057239de80 --- /dev/null +++ b/packages/cli/src/run/index.ts @@ -0,0 +1,2 @@ +export { runNonInteractive, type RunCommandInput } from "./run" +export { runV1Bridge, type V1RunCommandInput } from "./v1" diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/run/noninteractive.ts similarity index 91% rename from packages/cli/src/mini/noninteractive.ts rename to packages/cli/src/run/noninteractive.ts index c31569b70b6b..c9539fc543fa 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -2,9 +2,9 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" import { readFile } from "node:fs/promises" -import { toolOutputText } from "./tool" +import { toolOutputText } from "../mini/tool" +import type { MiniToolPart } from "../mini/types" import { UI } from "./ui" -import type { MiniToolPart } from "./types" type Model = { providerID: string @@ -30,6 +30,7 @@ type Input = { auto: boolean /** True when the client is attached to a shared server rather than an exclusive in-process one. */ attached: boolean + compatibility?: "v1" renderTool: (part: MiniToolPart) => Promise renderToolError: (part: MiniToolPart) => Promise } @@ -71,7 +72,9 @@ export async function runNonInteractivePrompt(input: Input) { let permissionRejected = false let formCancelled = false let interrupted = false + let v1InvalidOutput = false let admission: AbortController | undefined + let pendingStep: { timestamp: number; part: Record; label: string } | undefined const emit = (type: string, timestamp: number, data: Record) => { if (input.format !== "json") return false @@ -92,6 +95,17 @@ export async function runNonInteractivePrompt(input: Input) { UI.empty() } + const flushStep = () => { + if (!pendingStep) return + const value = pendingStep + pendingStep = undefined + if (!emit("step_start", value.timestamp, { part: value.part }) && input.format !== "json") { + UI.empty() + UI.println(value.label) + UI.empty() + } + } + const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray }) => { if (!input.auto) { permissionRejected = true @@ -178,6 +192,14 @@ export async function runNonInteractivePrompt(input: Input) { type: "step-start", snapshot: event.data.snapshot, } + if (input.compatibility === "v1") { + pendingStep = { + timestamp: time, + part, + label: `> ${event.data.agent} · ${event.data.model.id}`, + } + continue + } if (!emit("step_start", time, { part }) && input.format !== "json") { UI.empty() UI.println(`> ${event.data.agent} · ${event.data.model.id}`) @@ -187,6 +209,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.text.started") { + flushStep() starts.set("text", { id: partID(event.id), timestamp: time }) continue } @@ -206,6 +229,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.reasoning.started") { + flushStep() starts.set("reasoning", { id: partID(event.id), timestamp: time }) continue } @@ -236,6 +260,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.tool.input.started") { + flushStep() tools.set(event.data.callID, { id: partID(event.id), timestamp: time, @@ -251,6 +276,7 @@ export async function runNonInteractivePrompt(input: Input) { continue } if (event.type === "session.tool.called") { + flushStep() const current = tools.get(event.data.callID) tools.set(event.data.callID, { id: current?.id ?? partID(event.id), @@ -316,6 +342,7 @@ export async function runNonInteractivePrompt(input: Input) { }, } tools.delete(event.data.callID) + if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue if (!emit("tool_use", time, { part })) { await input.renderToolError(part) UI.error(error) @@ -324,6 +351,7 @@ export async function runNonInteractivePrompt(input: Input) { } if (event.type === "session.step.ended") { + flushStep() const part = { id: partID(event.id), sessionID: input.sessionID, @@ -338,13 +366,28 @@ export async function runNonInteractivePrompt(input: Input) { continue } if (event.type === "session.step.failed") { + if ( + input.compatibility === "v1" && + event.data.error.message === "Provider stream ended without a terminal finish event" + ) { + pendingStep = undefined + v1InvalidOutput = true + continue + } if (interrupted || permissionRejected || questionRejected || formCancelled) continue + flushStep() emittedError = true process.exitCode = 1 if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) continue } if (event.type === "session.execution.failed") { + if ( + input.compatibility === "v1" && + (v1InvalidOutput || permissionRejected || questionRejected || formCancelled) + ) + return + flushStep() if (!emittedError && !questionRejected && !formCancelled) { emittedError = true process.exitCode = 1 @@ -353,6 +396,7 @@ export async function runNonInteractivePrompt(input: Input) { return } if (event.type === "session.execution.interrupted") { + if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return if (event.data.reason === "user" && interrupted) process.exitCode = 130 if (event.data.reason !== "user" && !emittedError) { emittedError = true @@ -478,7 +522,7 @@ async function prepareFile(file: File) { const uri = file.url.startsWith("data:") ? file.url : `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}` - return { attachment: { uri, mime: file.mime, name: file.filename } } + return { attachment: { uri, name: file.filename } } } const content = file.url.startsWith("data:") ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8") diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/run/run.ts similarity index 79% rename from packages/cli/src/mini/run.ts rename to packages/cli/src/run/run.ts index fd8f8c8f3a96..ca96d4d94a2b 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/run/run.ts @@ -6,10 +6,10 @@ import { open } from "node:fs/promises" import path from "node:path" import { readStdin } from "../util/io" import { ServerConnection } from "../services/server-connection" -import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" +import { loadRunAgents, waitForCatalogReady } from "../mini/catalog.shared" +import { toolInlineInfo } from "../mini/tool" +import type { MiniToolPart } from "../mini/types" import { runNonInteractivePrompt } from "./noninteractive" -import { toolInlineInfo } from "./tool" -import type { MiniToolPart } from "./types" import { UI } from "./ui" export type RunCommandInput = { @@ -39,24 +39,39 @@ type Prepared = { files: FilePart[] } +type ExecutionOptions = { + root?: string + directory?: string + useServerDirectory?: boolean + variant?: string + attached?: boolean + compatibility?: "v1" +} + const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 export function runNonInteractive(input: RunCommandInput) { - return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error))) + return runNonInteractiveWithOptions(input, {}) } -async function run(input: RunCommandInput) { +/** @internal Used only by the V1 command boundary. */ +export function runNonInteractiveWithOptions(input: RunCommandInput, options: ExecutionOptions) { + return run(input, options).catch((error) => reportRunError(input, errorMessage(error))) +} + +async function run(input: RunCommandInput, options: ExecutionOptions) { if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") - const root = process.env.PWD ?? process.cwd() - const directory = localDirectory(root) + const root = options.root ?? process.env.PWD ?? process.cwd() + const local = localDirectory(root) + const directory = options.useServerDirectory ? undefined : (options.directory ?? local) const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin()) if (!message?.trim()) fail("You must provide a message") - const files = await Promise.all(input.file.map((file) => prepareFile(file, root))) + const files = await Promise.all(input.file.map((file) => prepareFile(file, root, options))) const prepared = { directory, message, files } - return execute(input, prepared, input.server.endpoint) + return execute(input, prepared, input.server.endpoint, options) } -async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) { +async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) { const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const requestedDirectory = prepared.directory ?? (await client.location.get()).directory if (!requestedDirectory) fail("Failed to resolve server directory") @@ -65,7 +80,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End const workspace = session?.location.workspaceID const explicit = parseRunModel(input.model) const explicitModel = explicit?.model - const variant = explicit?.variant + const variant = options.variant ?? explicit?.variant const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined const defaultModel = !explicitModel && !sessionModel @@ -74,12 +89,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) - if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) const available = await client.model.list({ location: { directory: cwd, workspace } }) if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID)) - return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id) + return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id) } const agent = await validateAgent(client, cwd, input.agent) const selected = @@ -107,10 +122,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End thinking: input.thinking ?? false, format: input.format, auto: input.auto ?? false, - attached: true, + attached: options.attached ?? true, + compatibility: options.compatibility, renderTool, renderToolError, - }).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id)) + }).catch((error) => reportRunError(input, errorMessage(error), selected.id)) } export function mergeInput(message: string | undefined, piped: string | undefined) { @@ -185,11 +201,13 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R return client.session.fork({ sessionID: selected.id }) } -async function prepareFile(input: string, directory: string): Promise { +async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise { const file = path.resolve(directory, input) const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`)) try { const stat = await handle.stat() + if (options.compatibility === "v1" && options.attached && stat.isDirectory()) + fail(`Cannot attach local directory without a shared filesystem: ${input}`) if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES) fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`) const content = Buffer.alloc(Number(stat.size)) @@ -249,7 +267,15 @@ function warning(message: string) { UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message) } -function reportError(input: RunCommandInput, message: string, sessionID?: string) { +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") + return error.message + return String(error) +} + +/** @internal Used by the V1 command boundary before a Session exists. */ +export function reportRunError(input: Pick, message: string, sessionID?: string) { process.exitCode = 1 if (input.format === "json") { process.stdout.write( diff --git a/packages/cli/src/mini/ui.ts b/packages/cli/src/run/ui.ts similarity index 100% rename from packages/cli/src/mini/ui.ts rename to packages/cli/src/run/ui.ts diff --git a/packages/cli/src/run/v1.ts b/packages/cli/src/run/v1.ts new file mode 100644 index 000000000000..f58b73bc105f --- /dev/null +++ b/packages/cli/src/run/v1.ts @@ -0,0 +1,85 @@ +import type { Endpoint } from "@opencode-ai/client/effect/service" +import { Effect } from "effect" +import path from "node:path" +import { Standalone } from "../services/standalone" +import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run" + +export type V1RunCommandInput = { + message: string[] + continue?: boolean + session?: string + fork?: boolean + model?: string + agent?: string + format: "default" | "json" + file: string[] + title?: string + server?: string + password?: string + username?: string + directory?: string + variant?: string + thinking?: boolean + dangerouslySkipPermissions?: boolean + standaloneCommand?: ReadonlyArray +} + +export function runV1Bridge(input: V1RunCommandInput) { + const root = process.env.PWD ?? process.cwd() + const attached = input.server !== undefined + const local = !attached && input.directory ? path.resolve(root, input.directory) : root + try { + process.chdir(local) + } catch { + reportRunError(input, `Failed to change directory to ${local}`) + return Promise.resolve() + } + + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const endpoint = attached + ? explicitEndpoint(input) + : yield* Standalone.start({ command: input.standaloneCommand }) + yield* Effect.promise(() => + runNonInteractiveWithOptions(nativeInput(input, endpoint), { + root: local, + directory: attached ? input.directory : local, + useServerDirectory: attached && input.directory === undefined, + variant: input.variant, + attached, + compatibility: "v1", + }), + ) + }), + ), + ).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error))) +} + +function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput { + return { + server: { endpoint }, + message: input.message, + continue: input.continue, + session: input.session, + fork: input.fork, + model: input.model, + agent: input.agent, + format: input.format, + file: input.file, + title: input.title, + thinking: input.thinking, + auto: input.dangerouslySkipPermissions, + } +} + +function explicitEndpoint(input: V1RunCommandInput): Endpoint { + const url = input.server + if (!url) throw new Error("Missing V1 server URL") + return { + url, + auth: input.password + ? { type: "basic", username: input.username ?? "opencode", password: input.password } + : undefined, + } +} diff --git a/packages/cli/test/drive/run-smoke.drive.mjs b/packages/cli/test/drive/run-smoke.drive.mjs new file mode 100644 index 000000000000..d462a2feb3af --- /dev/null +++ b/packages/cli/test/drive/run-smoke.drive.mjs @@ -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" + ) +} diff --git a/packages/cli/test/import-boundaries.test.ts b/packages/cli/test/import-boundaries.test.ts new file mode 100644 index 000000000000..c7d9bd347f63 --- /dev/null +++ b/packages/cli/test/import-boundaries.test.ts @@ -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 }) + } +} diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 4b4dc24aa827..4e3f0c1f523c 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -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") diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts similarity index 54% rename from packages/opencode/test/cli/run/noninteractive.test.ts rename to packages/cli/test/run/noninteractive.test.ts index 2b13155403dc..49dafabe8cc8 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -1,6 +1,6 @@ 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" +import { runNonInteractivePrompt } from "../../src/run/noninteractive" type V2Event = EventSubscribeOutput type FormInfo = Extract["data"]["form"] @@ -50,9 +50,57 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event { } } +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 }) { +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 @@ -88,15 +136,38 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: message: "hello", files: [], thinking: false, - format: "default", + 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() }) @@ -125,4 +196,58 @@ describe("runNonInteractivePrompt", () => { 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: "" }) + }) }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 9478357a2d36..ee1fd6b2cfe7 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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, diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 6f4b01a8492e..3e9a29c3b676 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -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("") + expect(input).not.toContain('') }), 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 }) =>