From e24d1aa7f15df1ac42c85bf033b1331fbb5efc67 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 12 Aug 2025 15:27:26 +0000 Subject: [PATCH] fix: handle reasoning-only responses from Gemini 2.5 Pro - Add fallback text when Gemini 2.5 Pro returns only reasoning/thinking content - Prevents "no assistant messages" error for models with requiredReasoningBudget - Add comprehensive test coverage for reasoning-only and mixed content scenarios Fixes #6999 --- src/api/providers/__tests__/gemini.spec.ts | 111 +++++++++++++++++++++ src/api/providers/gemini.ts | 23 ++++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 812c1ae1a6..14bb0d5531 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -90,6 +90,95 @@ describe("GeminiHandler", () => { ) }) + it("should handle reasoning-only responses for Gemini 2.5 Pro", async () => { + // Mock a response with only reasoning/thinking content + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { + thought: true, + text: "Let me think about this problem step by step...", + }, + ], + }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, thoughtsTokenCount: 15 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have reasoning chunk, text chunk (fallback), and usage + expect(chunks.length).toBe(3) + expect(chunks[0]).toEqual({ type: "reasoning", text: "Let me think about this problem step by step..." }) + expect(chunks[1]).toEqual({ type: "text", text: "[Thinking process completed]" }) + expect(chunks[2]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: undefined, + reasoningTokens: 15, + totalCost: undefined, + }) + }) + + it("should handle mixed reasoning and text content", async () => { + // Mock a response with both reasoning and text content + ;(handler["client"].models.generateContentStream as any).mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { + thought: true, + text: "Analyzing the request...", + }, + { + text: "Here is my response", + }, + ], + }, + }, + ], + } + yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 25 } } + }, + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have reasoning, text, and usage chunks (no fallback text needed) + expect(chunks.length).toBe(3) + expect(chunks[0]).toEqual({ type: "reasoning", text: "Analyzing the request..." }) + expect(chunks[1]).toEqual({ type: "text", text: "Here is my response" }) + expect(chunks[2]).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 25, + cacheReadTokens: undefined, + reasoningTokens: undefined, + totalCost: undefined, + }) + }) + it("should handle API errors", async () => { const mockError = new Error("Gemini API error") ;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError) @@ -143,6 +232,28 @@ describe("GeminiHandler", () => { const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) + + it("should handle reasoning-only response in completePrompt", async () => { + // Mock a response with only reasoning/thinking content and no text + ;(handler["client"].models.generateContent as any).mockResolvedValue({ + text: "", + candidates: [ + { + content: { + parts: [ + { + thought: true, + text: "Let me analyze this request...", + }, + ], + }, + }, + ], + }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("[Thinking process completed]") + }) }) describe("getModel", () => { diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 5e547edbdc..4b88b7f455 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -94,6 +94,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined let pendingGroundingMetadata: GroundingMetadata | undefined + let hasTextContent = false + let hasReasoningContent = false for await (const chunk of result) { // Process candidates and their parts to separate thoughts from content @@ -110,11 +112,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // This is a thinking/reasoning part if (part.text) { yield { type: "reasoning", text: part.text } + hasReasoningContent = true } } else { // This is regular content if (part.text) { yield { type: "text", text: part.text } + hasTextContent = true } } } @@ -124,6 +128,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Fallback to the original text property if no candidates structure else if (chunk.text) { yield { type: "text", text: chunk.text } + hasTextContent = true } if (chunk.usageMetadata) { @@ -131,6 +136,12 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } } + // If we only got reasoning content but no text content, yield a minimal text response + // This prevents the "no assistant messages" error for Gemini 2.5 Pro with reasoning + if (hasReasoningContent && !hasTextContent) { + yield { type: "text", text: "[Thinking process completed]" } + } + if (pendingGroundingMetadata) { const citations = this.extractCitationsOnly(pendingGroundingMetadata) if (citations) { @@ -201,7 +212,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl async completePrompt(prompt: string): Promise { try { - const { id: model } = this.getModel() + const { id: model, reasoning: thinkingConfig } = this.getModel() const tools: GenerateContentConfig["tools"] = [] if (this.options.enableUrlContext) { @@ -215,6 +226,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl ? { baseUrl: this.options.googleGeminiBaseUrl } : undefined, temperature: this.options.modelTemperature ?? 0, + thinkingConfig, ...(tools.length > 0 ? { tools } : {}), } @@ -226,6 +238,15 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl let text = result.text ?? "" + // Handle case where model only returns reasoning/thinking content + // This can happen with Gemini 2.5 Pro when reasoning is enabled + if (!text && result.candidates?.[0]?.content?.parts) { + const hasThoughts = result.candidates[0].content.parts.some((part: any) => part.thought) + if (hasThoughts) { + text = "[Thinking process completed]" + } + } + const candidate = result.candidates?.[0] if (candidate?.groundingMetadata) { const citations = this.extractCitationsOnly(candidate.groundingMetadata)