From f57f68e1c8aca3a7e784979fcf3413583980e61c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Jul 2026 16:38:17 -0400 Subject: [PATCH] fix(session): reject empty assistant responses --- packages/opencode/src/session/prompt.ts | 30 ++++++++++++- packages/opencode/test/session/prompt.test.ts | 42 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8b025cc2f0..e8c8949f37 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1083,6 +1083,7 @@ const layer = Layer.effect( const ctx = yield* InstanceState.context let structured: unknown let step = 0 + let retriedEmptyResponse = false const session = yield* sessions.get(sessionID).pipe(Effect.orDie) while (true) { @@ -1107,12 +1108,17 @@ const layer = Layer.effect( lastAssistantMsg?.parts.some( (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part), ) ?? false + const hasVisibleOutput = + lastAssistantMsg?.parts.some( + (part) => part.type === "tool" || (part.type === "text" && part.text.trim().length > 0), + ) ?? false if ( lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && - lastUser.id < lastAssistant.id + lastUser.id < lastAssistant.id && + !(retriedEmptyResponse && !hasVisibleOutput) ) { const orphan = lastAssistantMsg?.parts.find( (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), @@ -1314,6 +1320,28 @@ const layer = Layer.effect( yield* sessions.updateMessage(handle.message) return "break" as const } + + const parts = yield* MessageV2.parts(handle.message.id).pipe( + Effect.provideService(Database.Service, database), + ) + const hasVisibleOutput = parts.some( + (part) => part.type === "tool" || (part.type === "text" && part.text.trim().length > 0), + ) + if (!hasVisibleOutput) { + if (!retriedEmptyResponse) { + retriedEmptyResponse = true + yield* Effect.logWarning("retrying empty assistant response", { + "session.id": sessionID, + messageID: handle.message.id, + }) + return "continue" as const + } + const error = new NamedError.Unknown({ message: "Model returned an empty response after retry" }) + handle.message.error = error.toObject() + yield* sessions.updateMessage(handle.message) + yield* events.publish(Session.Event.Error, { sessionID, error: handle.message.error }) + throw error + } } if (result === "stop") return "break" as const diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 420bd49324..03c6044b7f 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2201,6 +2201,48 @@ it.instance("does not loop empty assistant turns for a simple reply", () => }), ) +it.instance("retries a reasoning-only assistant response once", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Empty response retry" }) + + yield* llm.push(reply().reason("thinking").stop(), reply().text("done").stop()) + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Answer me" }], + }) + + expect(yield* llm.calls).toBe(2) + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + }), +) + +it.instance("fails after two reasoning-only assistant responses", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Empty response failure" }) + + yield* llm.push(reply().reason("thinking").stop(), reply().reason("still thinking").stop()) + + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Answer me" }], + }) + .pipe(Effect.exit) + + expect(yield* llm.calls).toBe(2) + expect(Exit.isFailure(exit)).toBe(true) + }), +) + it.instance("records aborted errors when prompt is cancelled mid-stream", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg)