diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..bbf1dbde6654 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1108,6 +1108,23 @@ const layer = Layer.effect( (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part), ) ?? false + const hasAssistantOutput = + (lastAssistantMsg?.parts.length ?? 0) > 2 || + (lastAssistantMsg?.parts.some( + (part) => part.type === "text" || part.type === "reasoning" || part.type === "tool", + ) ?? + false) + + if (lastAssistant?.finish === "unknown" && !hasAssistantOutput && !lastAssistant.error) { + lastAssistant.error = MessageV2.fromError(new Error("Provider returned an empty response"), { + providerID: lastAssistant.providerID, + }) + yield* sessions.updateMessage(lastAssistant) + yield* events.publish(Session.Event.Error, { sessionID, error: lastAssistant.error }) + yield* status.set(sessionID, { type: "idle" }) + break + } + if ( lastAssistant?.finish && !["tool-calls", "unknown"].includes(lastAssistant.finish) && diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index d2d4bae87921..85f7e5035077 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -104,6 +104,21 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + // Regression for empty provider responses: an unknown finish with no text, + // reasoning, or tool call must fail the turn instead of spinning forever. + cliIt.concurrent( + "exits nonzero promptly when the provider returns an empty unknown response", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.push(reply().unknown()) + const result = yield* opencode.run("empty response", { timeoutMs: 10_000 }) + expect(result.exitCode).not.toBe(0) + expect(result.durationMs).toBeLessThan(10_000) + expect(result.stderr).toContain("Provider returned an empty response") + }), + 30_000, + ) + // --format json puts one JSON object per line on stdout for each emitted // event. Consumers (CI scripts, tooling) parse this stream. Asserts the // shape so a future event-emit change has to update this expectation. diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280f5..1b9cdde26e93 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -493,6 +493,14 @@ export class Reply { return this } + unknown() { + this.#finish = "unknown" + this.#hang = false + this.#error = undefined + this.#reset = false + return this + } + contentFilter() { this.#finish = "content_filter" this.#hang = false