Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions src/api/providers/__tests__/gemini-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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)
})
})
})
10 changes: 10 additions & 0 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 21 additions & 4 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.",
),
)

Expand Down
Loading