From 3f038ab6fc3ed007a731216c3d068d750b995f7a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 17 Feb 2026 10:10:14 +0000 Subject: [PATCH] fix: handle Gemini 3 Pro reasoning-only and blocked empty responses - Surface non-STOP finishReason (SAFETY, RECITATION, etc.) as descriptive errors instead of generic "no assistant messages" when Gemini returns no content - Treat reasoning-only responses (thinking content but no text/tool calls) as transient issues with free retry (no consecutive failure increment) - Auto-retry reasoning-only responses even without autoApproval enabled - Add tests for empty response, reasoning-only, and blocked response scenarios Closes #11492 --- .../__tests__/gemini-handler.spec.ts | 176 ++++++++++++++++++ src/api/providers/gemini.ts | 10 + src/core/task/Task.ts | 25 ++- 3 files changed, 207 insertions(+), 4 deletions(-) diff --git a/src/api/providers/__tests__/gemini-handler.spec.ts b/src/api/providers/__tests__/gemini-handler.spec.ts index a9544a0b97f..80e694d35b9 100644 --- a/src/api/providers/__tests__/gemini-handler.spec.ts +++ b/src/api/providers/__tests__/gemini-handler.spec.ts @@ -1,6 +1,15 @@ import { t } from "i18next" import { FunctionCallingConfigMode } from "@google/genai" +// Mock TelemetryService - must come before other imports +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) + import { GeminiHandler } from "../gemini" import type { ApiHandlerOptions } from "../../../shared/api" @@ -295,4 +304,171 @@ describe("GeminiHandler backend support", () => { expect(config.toolConfig).toBeUndefined() }) }) + + describe("empty and reasoning-only response handling", () => { + it("should throw when finishReason is non-STOP and no content was produced", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + finishReason: "SAFETY", + content: { parts: [] }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + await expect(async () => { + const messages = [] + for await (const chunk of handler.createMessage("test", [] as any)) { + messages.push(chunk) + } + }).rejects.toThrow( + t("common:errors.gemini.generate_stream", { + error: "Gemini response blocked or incomplete (finishReason: SAFETY). No content was returned.", + }), + ) + }) + + it("should not throw when finishReason is STOP even with no content", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + finishReason: "STOP", + content: { parts: [] }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + const messages = [] + for await (const chunk of handler.createMessage("test", [] as any)) { + messages.push(chunk) + } + + // Should complete without throwing, yielding only usage + expect(messages.some((m) => m.type === "usage")).toBe(true) + expect(messages.some((m) => m.type === "text")).toBe(false) + }) + + it("should yield reasoning chunks but no text for reasoning-only responses", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + finishReason: "STOP", + content: { + parts: [{ thought: true, text: "Let me think about this..." }], + }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0, thoughtsTokenCount: 20 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + const messages = [] + for await (const chunk of handler.createMessage("test", [] as any)) { + messages.push(chunk) + } + + // Should have reasoning but no text content + expect(messages.some((m) => m.type === "reasoning")).toBe(true) + expect(messages.some((m) => m.type === "text")).toBe(false) + // Should not throw since finishReason is STOP + }) + + it("should throw for RECITATION finishReason with no content", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + finishReason: "RECITATION", + content: { parts: [] }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + await expect(async () => { + for await (const chunk of handler.createMessage("test", [] as any)) { + // consume stream + } + }).rejects.toThrow( + t("common:errors.gemini.generate_stream", { + error: "Gemini response blocked or incomplete (finishReason: RECITATION). No content was returned.", + }), + ) + }) + + it("should not throw for non-STOP finishReason when content was produced", async () => { + const options = { + apiProvider: "gemini", + } as ApiHandlerOptions + const handler = new GeminiHandler(options) + + const mockStream = async function* () { + yield { + candidates: [ + { + finishReason: "MAX_TOKENS", + content: { parts: [{ text: "partial response..." }] }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, + } + } + + const stub = vi.fn().mockReturnValue(mockStream()) + // @ts-ignore access private client + handler["client"].models.generateContentStream = stub + + const messages = [] + for await (const chunk of handler.createMessage("test", [] as any)) { + messages.push(chunk) + } + + // Should have text content and not throw + expect(messages.some((m) => m.type === "text" && m.text === "partial response...")).toBe(true) + }) + }) }) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index db8041b9803..d478cfede30 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -305,6 +305,16 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl this.lastResponseId = finalResponse.responseId } + // Surface non-STOP finish reasons when the model produced no actionable content. + // This covers cases like SAFETY, RECITATION, MAX_TOKENS where the API + // silently returns nothing. Throwing here gives Task.ts retry logic a + // meaningful error message instead of the generic "no assistant messages". + if (!hasContent && finishReason && finishReason !== "STOP") { + throw new Error( + `Gemini response blocked or incomplete (finishReason: ${finishReason}). No content was returned.`, + ) + } + if (pendingGroundingMetadata) { const sources = this.extractGroundingSources(pendingGroundingMetadata) if (sources.length > 0) { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac3..595631ec8a6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3633,8 +3633,19 @@ export class Task extends EventEmitter implements TaskLike { // or tool_use content blocks from API which we should assume is // an error. - // Increment consecutive no-assistant-messages counter - this.consecutiveNoAssistantMessagesCount++ + // When the model returned reasoning/thinking content but no + // actionable text or tool calls (common with gemini-3-pro-preview), + // treat this as a transient issue and don't count it against the + // consecutive failure threshold. The model clearly attempted to + // respond but failed to produce actionable output, so we give it + // a free retry without triggering the error UI. + const hasReasoningOnly = reasoningMessage.length > 0 + + // Only increment the failure counter for truly empty responses + // (no reasoning either). Reasoning-only responses get a free retry. + if (!hasReasoningOnly) { + this.consecutiveNoAssistantMessagesCount++ + } // Only show error and count toward mistake limit after 2 consecutive failures // This provides a "grace retry" - first failure retries silently @@ -3657,12 +3668,18 @@ export class Task extends EventEmitter implements TaskLike { // Check if we should auto-retry or prompt the user // Reuse the state variable from above - if (state?.autoApprovalEnabled) { + // For reasoning-only responses, always auto-retry silently since the + // model clearly attempted to respond (produced thinking content) but + // just didn't generate actionable output. This avoids bothering the + // user with retry prompts for transient Gemini 3 Pro behavior. + if (state?.autoApprovalEnabled || hasReasoningOnly) { // Auto-retry with backoff - don't persist failure message when retrying await this.backoffAndAnnounce( currentItem.retryAttempt ?? 0, new Error( - "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", + hasReasoningOnly + ? "The model produced reasoning/thinking content but no actionable output. Retrying automatically." + : "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", ), )