mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
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:
parent
dc243e4cf9
commit
3f038ab6fc
3 changed files with 207 additions and 4 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue