diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index e4091e3ad..03f63d167 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -617,17 +617,17 @@ describe('GeminiChat', async () => { } }); - it('should throw InvalidStreamError when no tool call and empty response text', async () => { + it('should throw InvalidStreamError when there is finish reason but truly empty response (no text, no thought)', async () => { vi.useFakeTimers(); try { - // Setup: Stream with finish reason but empty response (only thoughts) + // Setup: Stream with finish reason but completely empty parts const streamWithEmptyResponse = (async function* () { yield { candidates: [ { content: { role: 'model', - parts: [{ thought: 'thinking...' }], + parts: [], }, finishReason: 'STOP', }, @@ -650,6 +650,58 @@ describe('GeminiChat', async () => { } }); + it('should succeed when there is finish reason and only thought content (reasoning models)', async () => { + // This test verifies that responses containing only thought/reasoning content + // are accepted as valid. + const thoughtOnlyStream = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + thought: true, + text: 'Let me think through this problem step by step...', + }, + ], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + thoughtOnlyStream, + ); + + const stream = await chat.sendMessageStream( + 'test-model', + { message: 'test' }, + 'prompt-id-thought-only', + ); + + // Should NOT throw - thought-only responses are valid + await expect( + (async () => { + for await (const _ of stream) { + // consume stream + } + })(), + ).resolves.not.toThrow(); + + // Verify history contains the thought content + const history = chat.getHistory(); + expect(history.length).toBe(2); // user turn + model turn + const modelTurn = history[1]!; + expect(modelTurn.parts?.length).toBe(1); + expect(modelTurn.parts![0]).toEqual({ + thought: true, + text: 'Let me think through this problem step by step...', + }); + }); + it('should succeed when there is finish reason and response text', async () => { // Setup: Stream with both finish reason and text content const validStream = (async function* () { @@ -730,6 +782,109 @@ describe('GeminiChat', async () => { ).resolves.not.toThrow(); }); + it('should succeed for thought-only content when finish reason arrives in a later chunk', async () => { + const streamWithDelayedFinishReason = (async function* () { + // First chunk contains only thought content. + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'Thinking through options...' }], + }, + }, + ], + } as unknown as GenerateContentResponse; + + // Second chunk carries only finishReason. + yield { + candidates: [ + { + content: { + role: 'model', + parts: [], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + streamWithDelayedFinishReason, + ); + + const stream = await chat.sendMessageStream( + 'test-model', + { message: 'test' }, + 'prompt-id-thought-delayed-finish', + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream + } + })(), + ).resolves.not.toThrow(); + + const history = chat.getHistory(); + expect(history.length).toBe(2); + expect(history[1]!.parts).toEqual([ + { thought: true, text: 'Thinking through options...' }, + ]); + }); + + it('should succeed for thought-only responses with finish reason followed by usage-only chunk', async () => { + const thoughtThenUsageOnlyStream = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [{ thought: true, text: 'Let me reason this out...' }], + }, + finishReason: 'STOP', + }, + ], + } as unknown as GenerateContentResponse; + + // Provider can emit trailing usage-only chunk after finish. + yield { + candidates: [], + usageMetadata: { + promptTokenCount: 12, + candidatesTokenCount: 4, + totalTokenCount: 16, + }, + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + thoughtThenUsageOnlyStream, + ); + + const stream = await chat.sendMessageStream( + 'test-model', + { message: 'test' }, + 'prompt-id-thought-usage-tail', + ); + + await expect( + (async () => { + for await (const _ of stream) { + // consume stream + } + })(), + ).resolves.not.toThrow(); + + const history = chat.getHistory(); + expect(history.length).toBe(2); + expect(history[1]!.parts).toEqual([ + { thought: true, text: 'Let me reason this out...' }, + ]); + }); + it('should call generateContentStream with the correct parameters', async () => { const response = (async function* () { yield { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index cfdb2c867..51785f198 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -931,12 +931,16 @@ export class GeminiChat { // Stream validation logic: A stream is considered successful if: // 1. There's a tool call (tool calls can end without explicit finish reasons), OR - // 2. There's a finish reason AND we have non-empty response text + // 2. There's a finish reason AND we have non-empty response text or thought text // // We throw an error only when there's no tool call AND: // - No finish reason, OR - // - Empty response text (e.g., only thoughts with no actual content) - if (!hasToolCall && (!hasFinishReason || !contentText)) { + // - Empty response text (e.g., no actual content and no thoughts) + // + // Note: Thoughts-only responses are valid for models that use thinking modes + // These models may send only reasoning content without explicit text output. + const hasAnyContent = contentText || thoughtText; + if (!hasToolCall && (!hasFinishReason || !hasAnyContent)) { if (!hasFinishReason) { throw new InvalidStreamError( 'Model stream ended without a finish reason.',