diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cb1c2ef22..2b26f52ec2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,6 +27,7 @@ "scripts": { "build": "bun run script/build.ts", "dev": "bun run src/index.ts", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f8775fdb16..5b851d7f49 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -58,6 +58,6 @@ Effect.logInfo("cli starting", { Effect.provide(LoggingLayer), Effect.provide(NodeServices.layer), Effect.scoped, - Effect.tap(() => Effect.sync(() => process.exit(0))), + Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))), NodeRuntime.runMain, ) diff --git a/packages/cli/src/mini/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts index 1af0f51f62..d810c47077 100644 --- a/packages/cli/src/mini/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -14,10 +14,11 @@ type CurrentSkill = SkillListOutput["data"][number] type CurrentProvider = ProviderListOutput["data"][number] type CurrentModel = ModelListOutput["data"][number] -function location(directory: string) { +function location(directory: string, workspace?: string) { return { location: { directory, + workspace, }, } } @@ -98,13 +99,14 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[ export async function waitForCatalogReady(input: { sdk: OpenCodeClient directory: string + workspace?: string model: { providerID: string; modelID: string } timeoutMs?: number }) { const deadline = Date.now() + (input.timeoutMs ?? 5_000) while (Date.now() < deadline) { const models = await input.sdk.model - .list(location(input.directory)) + .list(location(input.directory, input.workspace)) .then((result) => result.data) .catch(() => undefined) if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 0645daac51..97d2fd91b6 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -132,8 +132,14 @@ export async function runNonInteractivePrompt(input: Input) { const consume = async () => { while (!controller.signal.aborted) { - const next = await stream.next() - if (next.done) throw new Error("Event stream disconnected during prompt execution") + const next = await stream.next().catch((error) => { + if (!emittedError) throw error + return { done: true as const, value: undefined } + }) + if (next.done) { + if (emittedError) return + throw new Error("Event stream disconnected during prompt execution") + } const event = next.value if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) { @@ -416,7 +422,7 @@ export async function runNonInteractivePrompt(input: Input) { } controller.abort() await completed?.catch(() => {}) - if (interrupted) return undefined + if (interrupted || emittedError) return undefined throw error }) admission = undefined diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index ee4fc1cb53..d8e3dcd228 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -8,7 +8,7 @@ import { open } from "node:fs/promises" import path from "node:path" import { Daemon } from "../daemon" import { Standalone } from "../services/standalone" -import { loadRunAgents, waitForCatalogReady, waitForDefaultModel } from "./catalog.shared" +import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" import { toolInlineInfo } from "./tool" import { UI } from "./ui" @@ -49,7 +49,11 @@ type Prepared = { const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 -export async function runNonInteractive(input: RunCommandInput) { +export function runNonInteractive(input: RunCommandInput) { + return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error))) +} + +async function run(input: RunCommandInput) { if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session") const root = process.env.PWD ?? process.cwd() const directory = input.server ? input.directory : localDirectory(input.directory, root) @@ -78,17 +82,22 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr if (!requestedDirectory) fail("Failed to resolve server directory") const session = await selectSession(client, requestedDirectory, input) const cwd = session?.location.directory ?? requestedDirectory + const workspace = session?.location.workspaceID const explicitModel = parseModel(input.model) const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined const defaultModel = - input.variant && !explicitModel && !sessionModel - ? await waitForDefaultModel({ sdk: client, directory: cwd }) + !explicitModel && !sessionModel + ? await client.model + .default({ location: { directory: cwd, workspace } }) + .then((result) => + result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined, + ) : undefined const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { - await waitForCatalogReady({ sdk: client, directory: cwd, model }) - const available = await client.model.list({ location: { directory: cwd } }) + 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) } @@ -109,26 +118,21 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr }) } - try { - await runNonInteractivePrompt({ - client, - sessionID: selected.id, - message: prepared.message, - files: prepared.files, - agent, - model, - variant: input.variant, - thinking: input.thinking ?? false, - format: input.format, - dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, - attached: !input.standaloneCommand, - renderTool, - renderToolError, - }) - } catch (error) { - UI.error(error instanceof Error ? error.message : String(error)) - process.exitCode = 1 - } + await runNonInteractivePrompt({ + client, + sessionID: selected.id, + message: prepared.message, + files: prepared.files, + agent, + model, + variant: input.variant, + thinking: input.thinking ?? false, + format: input.format, + dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, + attached: !input.standaloneCommand, + renderTool, + renderToolError, + }).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id)) } export function mergeInput(message: string | undefined, piped: string | undefined) { @@ -303,6 +307,5 @@ function reportError(input: RunCommandInput, message: string, sessionID?: string } function fail(message: string): never { - UI.error(message) - process.exit(1) + throw new Error(message) } diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 6f3af1f060..48471a573a 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -39,7 +39,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { Effect.gen(function* () { if (options.mode === "service") { const service = yield* ServiceConfig.options() - yield* Flock.effect("service-process", { + yield* Flock.effect(path.basename(service.file, ".json") + "-process", { dir: path.dirname(service.file), staleMs: 3_000, timeoutMs: 15_000, diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index 328eb0e302..c0a8d8b71d 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -42,7 +42,7 @@ const makeTransport = Effect.fn("cli.standalone.transport")( const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) const ready = yield* Effect.tryPromise(() => decodeReady(output)) - return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid } + return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid } }, Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 6149f614de..1ad311890a 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -8,7 +8,8 @@ await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const transport = yield* Standalone.transport() - console.log(`${transport.pid} ${transport.url}`) + const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers })) + console.log(`${transport.pid} ${transport.url} ${response.status}`) return yield* Effect.never }), ), diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 95b74d2404..4734b62476 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import path from "node:path" import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini" async function cli(args: string[]) { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { - cwd: new URL("..", import.meta.url).pathname, + cwd: path.join(import.meta.dir, ".."), stdout: "pipe", stderr: "pipe", }) @@ -61,6 +63,66 @@ describe("mini command", () => { expect(result.stderr).not.toContain("You must provide a message") }) + test("preserves a run failure exit code", async () => { + let modelRequests = 0 + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/api/health") + return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }) + if (url.pathname === "/api/model") { + modelRequests++ + return Response.json({ + location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } }, + data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [], + }) + } + return new Response(undefined, { status: 404 }) + }, + }) + + try { + const result = await cli([ + "run", + "--server", + server.url.toString(), + "--dir", + process.cwd(), + "--model", + "definitely/missing", + "hi", + ]) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("Model unavailable: definitely/missing") + } finally { + server.stop(true) + } + }) + + test("reports pre-admission errors as JSON", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + return Response.json({ healthy: true, version: "incompatible", pid: process.pid }) + }, + }) + + try { + const result = await cli(["run", "--format", "json", "--server", server.url.toString(), "hi"]) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + type: "error", + sessionID: "", + error: { type: "unknown", message: expect.stringContaining("requires") }, + }) + } finally { + server.stop(true) + } + }) + test("uses the shared V2 server option instead of an attach command", async () => { const result = await cli(["mini", "--help"]) diff --git a/packages/cli/test/standalone.test.ts b/packages/cli/test/standalone.test.ts index 1a309477f3..3cfe25855f 100644 --- a/packages/cli/test/standalone.test.ts +++ b/packages/cli/test/standalone.test.ts @@ -4,18 +4,19 @@ import path from "node:path" test("standalone server exits when its owner is killed", async () => { const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], { cwd: path.join(import.meta.dir, ".."), - env: process.env, + env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }) const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)]) - const [rawPID, url] = line?.split(" ") ?? [] + const [rawPID, url, status] = line?.split(" ") ?? [] const pid = Number(rawPID) try { expect(pid).toBeGreaterThan(0) expect(url).toStartWith("http://127.0.0.1:") + expect(status).toBe("200") expect(running(pid)).toBe(true) owner.kill("SIGKILL") diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 03e9c07237..9c5e833ee1 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -104,9 +104,10 @@ export const Info = Schema.Struct({ export type Info = typeof Info.Type const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) -const decodeHealth = Schema.decodeUnknownEffect( +const decodeHealth = Schema.decodeUnknownOption( Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }), ) +const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) })) // A missing or corrupt file means no valid info; callers treat both // the same (the registering server self-evicts, clients rediscover). @@ -122,7 +123,7 @@ type LocalService = { readonly transport: Transport } -const probe = Effect.fnUntraced(function* (info: Info, version?: string) { +const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { const headers = info.password === undefined ? undefined : auth(info.password) const response = yield* Effect.tryPromise(() => fetch(new URL("/api/health", info.url), { @@ -131,14 +132,20 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string) { }), ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) if (response === undefined || !response.ok) return undefined - const health = yield* Effect.tryPromise(() => response.json()).pipe( - Effect.flatMap(decodeHealth), - Effect.option, - Effect.map(Option.getOrUndefined), + const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + const health = decodeHealth(body) + if (Option.isSome(health)) { + if (health.value.pid !== info.pid) return undefined + if (info.version !== undefined && health.value.version !== info.version) return undefined + if (version !== undefined && health.value.version !== version) return undefined + return { info, transport: { url: info.url, headers } } satisfies LocalService + } + if ( + !allowLegacy || + Option.isNone(decodeLegacyHealth(body)) || + (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) ) - if (health?.pid !== info.pid) return undefined - if (info.version !== undefined && health.version !== info.version) return undefined - if (version !== undefined && health.version !== version) return undefined + return undefined return { info, transport: { url: info.url, headers } } satisfies LocalService }) @@ -147,7 +154,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string) { const find = Effect.fnUntraced(function* (options: Options) { const info = yield* read(options.file) if (info === undefined) return undefined - return yield* probe(info) + return yield* probe(info, undefined, true) }) // 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 5059804354..b5d6ef37cf 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,4 +1,6 @@ import { Catalog } from "@opencode-ai/core/catalog" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { ServiceUnavailableError } from "@opencode-ai/protocol/errors" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" @@ -17,6 +19,19 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) .handle( "model.default", Effect.fn(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.ready.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Model catalog initialization timed out", + service: "model.catalog", + }), + ), + }), + ) const catalog = yield* Catalog.Service return yield* response(catalog.model.default()) }), diff --git a/turbo.json b/turbo.json index 2ab43fc5ce..83bc4fbeae 100644 --- a/turbo.json +++ b/turbo.json @@ -12,6 +12,10 @@ "dependsOn": ["^build"], "outputs": [] }, + "@opencode-ai/cli#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/tui#test": { "dependsOn": ["^build"], "outputs": []