From f6db4c37b7b1d8fe94f1726f70b662d0b119d1ae Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 2 Apr 2026 13:58:38 +0000 Subject: [PATCH] fix: surface Gemini blocked/empty response reasons instead of generic error When the Gemini API returns a non-STOP finishReason (SAFETY, RECITATION, PROHIBITED_CONTENT) and no content was produced, throw a descriptive error with the actual finish reason instead of silently yielding nothing and falling into the generic "no assistant messages" error path. Also handle reasoning-only responses (thinking models like gemini-3.1-pro-preview that return only reasoning without actionable text/tool calls) by yielding a placeholder text so downstream retry logic can re-prompt the model. Closes #12045 --- src/api/providers/__tests__/gemini.spec.ts | 223 +++++++++++++++++++++ src/api/providers/gemini.ts | 24 +++ 2 files changed, 247 insertions(+) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 47ee79dd0d6..0d13772b93b 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -257,6 +257,229 @@ describe("GeminiHandler", () => { }) }) + describe("empty response handling", () => { + const mockMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + ] + + const systemPrompt = "You are a helpful assistant" + + it("should throw a descriptive error when finishReason is SAFETY and no content was produced", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + finishReason: "SAFETY", + content: { parts: [] }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // collect + } + }).rejects.toThrow( + t("common:errors.gemini.generate_stream", { + error: "Gemini response blocked: finishReason=SAFETY. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.", + }), + ) + }) + + it("should throw a descriptive error when finishReason is RECITATION and no content was produced", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + finishReason: "RECITATION", + content: { parts: [] }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // collect + } + }).rejects.toThrow( + t("common:errors.gemini.generate_stream", { + error: "Gemini response blocked: finishReason=RECITATION. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.", + }), + ) + }) + + it("should throw a descriptive error when finishReason is PROHIBITED_CONTENT", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + finishReason: "PROHIBITED_CONTENT", + content: { parts: [] }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // collect + } + }).rejects.toThrow( + t("common:errors.gemini.generate_stream", { + error: "Gemini response blocked: finishReason=PROHIBITED_CONTENT. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.", + }), + ) + }) + + it("should NOT throw when finishReason is STOP even without content", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + finishReason: "STOP", + content: { parts: [] }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should not throw; just yield usage + expect(chunks.some((c) => c.type === "usage")).toBe(true) + }) + + it("should NOT throw when finishReason is SAFETY but content was produced", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + content: { parts: [{ text: "Some content" }] }, + }, + ], + } + yield { + candidates: [ + { + finishReason: "SAFETY", + content: { parts: [] }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should not throw because content was produced + expect(chunks.some((c) => c.type === "text")).toBe(true) + }) + + it("should yield a placeholder text when only reasoning content is produced (no actionable content)", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + finishReason: "STOP", + content: { + parts: [{ thought: true, text: "Let me think about this..." }], + }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have reasoning chunk, a placeholder text chunk, and usage + expect(chunks.some((c) => c.type === "reasoning")).toBe(true) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBe(1) + expect(textChunks[0].text).toContain("reasoning but no actionable response") + }) + + it("should NOT yield a placeholder when reasoning AND text content are produced", async () => { + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + content: { + parts: [{ thought: true, text: "Let me think..." }, { text: "Here is my answer" }], + }, + }, + ], + } + yield { + candidates: [ + { + finishReason: "STOP", + content: { parts: [] }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have reasoning and real text, but NOT the placeholder + expect(chunks.some((c) => c.type === "reasoning")).toBe(true) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBe(1) + expect(textChunks[0].text).toBe("Here is my answer") + }) + }) + describe("error telemetry", () => { const mockMessages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index a49073ea334..b1798172157 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -305,6 +305,30 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl this.lastResponseId = finalResponse.responseId } + // When the Gemini API returns a non-STOP finishReason (e.g. SAFETY, + // RECITATION, MAX_TOKENS, PROHIBITED_CONTENT) and no usable content + // was produced, surface a descriptive error instead of silently + // yielding nothing and falling into the generic "no assistant + // messages" error path downstream. + if (!hasContent && finishReason && finishReason !== "STOP" && finishReason !== "MAX_TOKENS") { + throw new Error( + `Gemini response blocked: finishReason=${finishReason}. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.`, + ) + } + + // When a thinking/reasoning model (e.g. gemini-3.1-pro-preview) + // returns only reasoning content without any actionable text or + // tool calls, yield a minimal text chunk so that the downstream + // "no assistant messages" check in Task.ts is not triggered. This + // gives the retry logic a chance to re-prompt the model instead of + // treating it as a hard failure. + if (!hasContent && hasReasoning) { + yield { + type: "text", + text: "[The model produced reasoning but no actionable response. Retrying...]", + } + } + if (pendingGroundingMetadata) { const sources = this.extractGroundingSources(pendingGroundingMetadata) if (sources.length > 0) {