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
This commit is contained in:
Roo Code 2026-02-17 10:10:14 +00:00
parent dc243e4cf9
commit 3f038ab6fc
3 changed files with 207 additions and 4 deletions

View file

@ -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)
})
})
})

View file

@ -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) {

View file

@ -3633,8 +3633,19 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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.",
),
)