Skip to content

Commit f32a78d

Browse files
committed
fix: handle Gemini thinking-only responses to prevent empty assistant message error
- Add tracking for whether any actual content (not just reasoning) was yielded in Gemini handler - Yield empty text chunk if only reasoning content was provided to ensure assistantMessage is not empty - Improve error message in Task.ts to be more informative for Gemini-specific issues - Add comprehensive tests for thinking-only response scenarios Fixes #6986
1 parent 12d1959 commit f32a78d

File tree

3 files changed

+266
-4
lines changed

3 files changed

+266
-4
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// npx vitest run src/api/providers/__tests__/gemini-thinking-only.spec.ts
2+
3+
import { describe, it, expect, vi, beforeEach } from "vitest"
4+
import { GeminiHandler } from "../gemini"
5+
import type { ApiHandlerOptions } from "../../../shared/api"
6+
7+
describe("GeminiHandler - Thinking-only responses", () => {
8+
let handler: GeminiHandler
9+
let mockClient: any
10+
11+
beforeEach(() => {
12+
// Create a mock client
13+
mockClient = {
14+
models: {
15+
generateContentStream: vi.fn(),
16+
},
17+
}
18+
19+
// Create handler with mocked client
20+
handler = new GeminiHandler({
21+
apiProvider: "gemini",
22+
geminiApiKey: "test-key",
23+
apiModelId: "gemini-2.5-pro",
24+
} as ApiHandlerOptions)
25+
26+
// Replace the client with our mock
27+
;(handler as any).client = mockClient
28+
})
29+
30+
it("should yield empty text when only reasoning content is provided", async () => {
31+
// Mock a stream that only contains reasoning/thinking content
32+
const mockStream = {
33+
async *[Symbol.asyncIterator]() {
34+
// First chunk with only thinking content
35+
yield {
36+
candidates: [
37+
{
38+
content: {
39+
parts: [
40+
{
41+
thought: true,
42+
text: "Let me think about this problem...",
43+
},
44+
],
45+
},
46+
},
47+
],
48+
}
49+
50+
// Second chunk with more thinking
51+
yield {
52+
candidates: [
53+
{
54+
content: {
55+
parts: [
56+
{
57+
thought: true,
58+
text: "I need to consider the tool usage...",
59+
},
60+
],
61+
},
62+
},
63+
],
64+
}
65+
66+
// Final chunk with usage metadata but no actual content
67+
yield {
68+
usageMetadata: {
69+
promptTokenCount: 100,
70+
candidatesTokenCount: 50,
71+
thoughtsTokenCount: 30,
72+
},
73+
}
74+
},
75+
}
76+
77+
mockClient.models.generateContentStream.mockResolvedValue(mockStream)
78+
79+
// Collect all chunks from the stream
80+
const chunks: any[] = []
81+
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Test message" }])
82+
83+
for await (const chunk of stream) {
84+
chunks.push(chunk)
85+
}
86+
87+
// Verify we got reasoning chunks
88+
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
89+
expect(reasoningChunks).toHaveLength(2)
90+
expect(reasoningChunks[0].text).toBe("Let me think about this problem...")
91+
expect(reasoningChunks[1].text).toBe("I need to consider the tool usage...")
92+
93+
// Verify we got at least one text chunk (even if empty) to prevent the error
94+
const textChunks = chunks.filter((c) => c.type === "text")
95+
expect(textChunks).toHaveLength(1)
96+
expect(textChunks[0].text).toBe("")
97+
98+
// Verify we got usage metadata
99+
const usageChunks = chunks.filter((c) => c.type === "usage")
100+
expect(usageChunks).toHaveLength(1)
101+
expect(usageChunks[0].inputTokens).toBe(100)
102+
expect(usageChunks[0].outputTokens).toBe(50)
103+
})
104+
105+
it("should not add empty text when actual content is provided", async () => {
106+
// Mock a stream that contains both reasoning and actual content
107+
const mockStream = {
108+
async *[Symbol.asyncIterator]() {
109+
// First chunk with thinking
110+
yield {
111+
candidates: [
112+
{
113+
content: {
114+
parts: [
115+
{
116+
thought: true,
117+
text: "Thinking about the response...",
118+
},
119+
],
120+
},
121+
},
122+
],
123+
}
124+
125+
// Second chunk with actual content
126+
yield {
127+
candidates: [
128+
{
129+
content: {
130+
parts: [
131+
{
132+
text: "Here is my actual response.",
133+
},
134+
],
135+
},
136+
},
137+
],
138+
}
139+
140+
// Usage metadata
141+
yield {
142+
usageMetadata: {
143+
promptTokenCount: 100,
144+
candidatesTokenCount: 50,
145+
},
146+
}
147+
},
148+
}
149+
150+
mockClient.models.generateContentStream.mockResolvedValue(mockStream)
151+
152+
// Collect all chunks from the stream
153+
const chunks: any[] = []
154+
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Test message" }])
155+
156+
for await (const chunk of stream) {
157+
chunks.push(chunk)
158+
}
159+
160+
// Verify we got reasoning chunk
161+
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
162+
expect(reasoningChunks).toHaveLength(1)
163+
164+
// Verify we got actual text content (not empty)
165+
const textChunks = chunks.filter((c) => c.type === "text")
166+
expect(textChunks).toHaveLength(1)
167+
expect(textChunks[0].text).toBe("Here is my actual response.")
168+
169+
// Should NOT have an additional empty text chunk
170+
const emptyTextChunks = textChunks.filter((c) => c.text === "")
171+
expect(emptyTextChunks).toHaveLength(0)
172+
})
173+
174+
it("should handle mixed thinking and content in same part", async () => {
175+
// Mock a stream with mixed content
176+
const mockStream = {
177+
async *[Symbol.asyncIterator]() {
178+
yield {
179+
candidates: [
180+
{
181+
content: {
182+
parts: [
183+
{
184+
thought: true,
185+
text: "Analyzing the request...",
186+
},
187+
{
188+
text: "I'll help you with that.",
189+
},
190+
{
191+
thought: true,
192+
text: "Considering tool usage...",
193+
},
194+
],
195+
},
196+
},
197+
],
198+
}
199+
},
200+
}
201+
202+
mockClient.models.generateContentStream.mockResolvedValue(mockStream)
203+
204+
// Collect all chunks from the stream
205+
const chunks: any[] = []
206+
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Test message" }])
207+
208+
for await (const chunk of stream) {
209+
chunks.push(chunk)
210+
}
211+
212+
// Verify we got both reasoning and text chunks
213+
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
214+
expect(reasoningChunks).toHaveLength(2)
215+
216+
const textChunks = chunks.filter((c) => c.type === "text")
217+
expect(textChunks).toHaveLength(1)
218+
expect(textChunks[0].text).toBe("I'll help you with that.")
219+
})
220+
221+
it("should handle empty stream gracefully", async () => {
222+
// Mock an empty stream
223+
const mockStream = {
224+
async *[Symbol.asyncIterator]() {
225+
// Yield nothing
226+
},
227+
}
228+
229+
mockClient.models.generateContentStream.mockResolvedValue(mockStream)
230+
231+
// Collect all chunks from the stream
232+
const chunks: any[] = []
233+
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Test message" }])
234+
235+
for await (const chunk of stream) {
236+
chunks.push(chunk)
237+
}
238+
239+
// Should yield at least an empty text chunk to prevent errors
240+
const textChunks = chunks.filter((c) => c.type === "text")
241+
expect(textChunks).toHaveLength(1)
242+
expect(textChunks[0].text).toBe("")
243+
})
244+
})

src/api/providers/gemini.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
9494

9595
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
9696
let pendingGroundingMetadata: GroundingMetadata | undefined
97+
let hasYieldedContent = false // Track if we've yielded any actual content
9798

9899
for await (const chunk of result) {
99100
// Process candidates and their parts to separate thoughts from content
@@ -115,6 +116,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
115116
// This is regular content
116117
if (part.text) {
117118
yield { type: "text", text: part.text }
119+
hasYieldedContent = true
118120
}
119121
}
120122
}
@@ -124,13 +126,20 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
124126
// Fallback to the original text property if no candidates structure
125127
else if (chunk.text) {
126128
yield { type: "text", text: chunk.text }
129+
hasYieldedContent = true
127130
}
128131

129132
if (chunk.usageMetadata) {
130133
lastUsageMetadata = chunk.usageMetadata
131134
}
132135
}
133136

137+
// If we only got reasoning content and no actual text, yield an empty text chunk
138+
// This ensures the assistant message won't be empty
139+
if (!hasYieldedContent) {
140+
yield { type: "text", text: "" }
141+
}
142+
134143
if (pendingGroundingMetadata) {
135144
const citations = this.extractCitationsOnly(pendingGroundingMetadata)
136145
if (citations) {

src/core/task/Task.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2019,10 +2019,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20192019
// If there's no assistant_responses, that means we got no text
20202020
// or tool_use content blocks from API which we should assume is
20212021
// an error.
2022-
await this.say(
2023-
"error",
2024-
"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.",
2025-
)
2022+
const modelId = getModelId(this.apiConfiguration)
2023+
const isGeminiModel = modelId?.includes("gemini") ?? false
2024+
2025+
let errorMessage = "Unexpected API Response: The language model did not provide any assistant messages."
2026+
2027+
if (isGeminiModel) {
2028+
errorMessage +=
2029+
" This can occur with Gemini models when they are in 'thinking' mode but don't produce any actual response content. The model may need to be prompted again or the request may need to be retried."
2030+
} else {
2031+
errorMessage += " This may indicate an issue with the API or the model's output."
2032+
}
2033+
2034+
await this.say("error", errorMessage)
20262035

20272036
await this.addToApiConversationHistory({
20282037
role: "assistant",

0 commit comments

Comments
 (0)