diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 9e025ead380..36ea78e8dc5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -779,6 +779,14 @@ export const RunCommand = effectCmd({ const drain = KiloRunDrain.create(sessionID) if (!args.attach && !args.auto && !skipPermissions) KiloHeadless.mark(sessionID) // kilocode_change - --yolo skips too // kilocode_change end + // kilocode_change start - remember whether the model produced any assistant output, + // so a run that ends without one does not exit 0 + let assistantOutput = false + // the raced request (prompt, command, or summarize) itself failed; the + // result.error handler below already reported the real cause, so the + // empty-output diagnostic must not claim a silent model on top of it + let promptFailed = false + // kilocode_change end function emit(type: string, data: Record) { if (args.format === "json") { @@ -831,6 +839,14 @@ export const RunCommand = effectCmd({ // kilocode_change end if (part.sessionID !== sessionID) continue + // kilocode_change start - text, reasoning, and tool parts are the + // model's response; step markers are not + if (part.type === "tool") assistantOutput = true + else if ((part.type === "text" || part.type === "reasoning") && part.time?.end && part.text.trim()) { + assistantOutput = true + } + // kilocode_change end + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { if (emit("tool_use", { part })) continue if (part.state.status === "completed") { @@ -1075,11 +1091,21 @@ export const RunCommand = effectCmd({ }), ) if (result.error) { + promptFailed = true if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error)) process.exitCode = 1 } await drain.wait(client, cwd) + // kilocode_change start - an empty model response must not exit 0: a caller + // cannot tell an empty run from a successful one otherwise if (await completed) process.exitCode = 1 + else if (!assistantOutput && !promptFailed) { + const message = "run ended without an assistant message; the model returned no output" + UI.error(message) + emit("error", { error: message }) + process.exitCode = 1 + } + // kilocode_change end } catch (error) { const text = error instanceof Error ? error.message : String(error) if (!emit("error", { error: text })) UI.error(text) diff --git a/packages/opencode/test/cli/run/run-empty.process.test.ts b/packages/opencode/test/cli/run/run-empty.process.test.ts new file mode 100644 index 00000000000..0d7f6ac10f0 --- /dev/null +++ b/packages/opencode/test/cli/run/run-empty.process.test.ts @@ -0,0 +1,101 @@ +// kilocode_change - new file +// Subprocess tests for `kilo run` when the model produces no assistant message. +// Same harness as run-process.test.ts — see `test/lib/cli-process.ts`. +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { reply } from "../../lib/llm-server" +import { cliIt } from "../../lib/cli-process" + +// kilocode_change start - a run where the model returns no assistant message used to +// exit 0, so a caller could not tell an empty run from a successful one +describe("opencode run with an empty model completion (non-interactive subprocess)", () => { + cliIt.concurrent( + "exits nonzero with a stderr diagnostic", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push(reply().stop()) + const result = yield* opencode.run("say hi", { timeoutMs: 30_000 }) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("run ended without an assistant message") + expect(result.stdout).toBe("") + }), + 60_000, + ) + + cliIt.concurrent( + "emits an error record under --format json", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push(reply().stop()) + const result = yield* opencode.run("say hi", { format: "json", timeoutMs: 30_000 }) + opencode.expectExit(result, 1) + expect(result.stderr).toContain("run ended without an assistant message") + const events = opencode.parseJsonEvents(result.stdout) + expect(events.at(-1)).toEqual({ + type: "error", + timestamp: expect.any(Number), + sessionID: expect.any(String), + error: "run ended without an assistant message; the model returned no output", + }) + }), + 60_000, + ) + + // The prompt request itself can fail before the model ever runs (here: an + // attached image whose base64 exceeds the server's limit → 400 BadRequest, + // with no session.error event). The real error is reported once; the + // empty-output diagnostic must not claim a silent model on top of it. + cliIt.live( + "request failure reports the real error without the empty-output diagnostic", + ({ home, opencode }) => + Effect.gen(function* () { + // 4 MiB binary content with a .png extension: under the CLI's 10 MiB + // attach cap, but its ~5.33 MiB base64 exceeds the server's 5 MiB + // image limit, so the prompt request fails with a 400 BadRequest. + const big = Buffer.alloc(4 * 1024 * 1024, 7) + Buffer.from([0x89, 0x50, 0x4e, 0x47]).copy(big, 0) + yield* Effect.promise(() => Bun.write(`${home}/big.png`, big)) + const server = yield* opencode.serve() + + const result = yield* opencode.run("read the attachment", { + extraArgs: ["--attach", server.url, `--file=${home}/big.png`, "--"], + timeoutMs: 30_000, + }) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("BadRequest") + expect(result.stderr).not.toContain("run ended without an assistant message") + }), + 60_000, + ) + + cliIt.live( + "request failure emits only the real error record under --format json", + ({ home, opencode }) => + Effect.gen(function* () { + const big = Buffer.alloc(4 * 1024 * 1024, 7) + Buffer.from([0x89, 0x50, 0x4e, 0x47]).copy(big, 0) + yield* Effect.promise(() => Bun.write(`${home}/big.json.png`, big)) + const server = yield* opencode.serve() + + const result = yield* opencode.run("read the attachment", { + format: "json", + extraArgs: ["--attach", server.url, `--file=${home}/big.json.png`, "--"], + timeoutMs: 30_000, + }) + + opencode.expectExit(result, 1) + expect(result.stderr).not.toContain("run ended without an assistant message") + const events = opencode.parseJsonEvents(result.stdout) + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: "error", + timestamp: expect.any(Number), + sessionID: expect.any(String), + error: expect.objectContaining({ _tag: "BadRequest" }), + }) + }), + 60_000, + ) +}) +// kilocode_change end