From 0c5eae4d35f8c5688e7faa2135a16d7f2a7828e7 Mon Sep 17 00:00:00 2001 From: davidapierce Date: Wed, 22 Jul 2026 21:48:27 +0000 Subject: [PATCH 1/4] Update filtering out thought parts from getHistoryTurns --- packages/core/src/core/geminiChat.test.ts | 56 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 50 ++++++++++++++++---- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 44835852026..6780c9c0ea3 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2282,6 +2282,62 @@ describe('GeminiChat', () => { text: 'actual conversational response', }); }); + + it('should completely filter out thought parts from getHistoryTurns when context management is disabled but model is gemini-2/modern', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false); + vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro'); + + chat.setHistory([ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true } as unknown as Part, + { text: 'actual conversational response' }, + ], + }, + ]); + + const turns = chat.getHistoryTurns(true); + + expect(turns).toHaveLength(2); + const modelTurn = turns[1]; + expect(modelTurn.content.parts).toHaveLength(1); + expect(modelTurn.content.parts![0]).toEqual({ + text: 'actual conversational response', + }); + }); + + it('should completely filter out thought parts from getHistoryTurns when model supports modern features', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false); + vi.mocked(mockConfig.getModel).mockReturnValue('gemini-3.1-pro-preview'); + + chat.setHistory([ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true } as unknown as Part, + { text: 'actual conversational response' }, + ], + }, + ]); + + const turns = chat.getHistoryTurns(true); + + expect(turns).toHaveLength(2); + const modelTurn = turns[1]; + expect(modelTurn.content.parts).toHaveLength(1); + expect(modelTurn.content.parts![0]).toEqual({ + text: 'actual conversational response', + }); + }); }); describe('ensureActiveLoopHasThoughtSignatures', () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5104f42ff5d..a43c400f7cc 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -30,7 +30,11 @@ import { getRetryErrorType, } from '../utils/retry.js'; import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js'; -import { resolveModel, supportsModernFeatures } from '../config/models.js'; +import { + resolveModel, + supportsModernFeatures, + isGemini2Model, +} from '../config/models.js'; import { hasCycleInSchema } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; import type { CompletedToolCall } from '../scheduler/types.js'; @@ -766,9 +770,10 @@ export class GeminiChat { abortSignal, }; - let contentsToUse: Content[] = supportsModernFeatures(modelToUse) - ? [...contentsForPreviewModel] - : [...requestContents]; + let contentsToUse: Content[] = + supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse) + ? [...contentsForPreviewModel] + : [...requestContents]; const hookSystem = this.context.config.getHookSystem(); if (hookSystem) { @@ -810,9 +815,10 @@ export class GeminiChat { ); lastModelToUse = modelToUse; // Re-evaluate contentsToUse based on the new model's feature support - contentsToUse = supportsModernFeatures(modelToUse) - ? [...contentsForPreviewModel] - : [...requestContents]; + contentsToUse = + supportsModernFeatures(modelToUse) || isGemini2Model(modelToUse) + ? [...contentsForPreviewModel] + : [...requestContents]; } if (beforeModelResult.modifiedConfig) { Object.assign(config, beforeModelResult.modifiedConfig); @@ -956,9 +962,16 @@ export class GeminiChat { ? extractCuratedHistory(this.agentHistory.get()) : [...this.agentHistory.get()]; - return this.context.config.isContextManagementEnabled() - ? scrubHistory(history) - : history; + if (this.context.config.isContextManagementEnabled()) { + return scrubHistory(history); + } + + const model = this.context.config.getModel(); + if (isGemini2Model(model) || supportsModernFeatures(model)) { + return stripThoughts(history); + } + + return history; } /** @@ -1503,3 +1516,20 @@ export function coalesceConsecutiveRoles( } return result; } + +export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { + return history.map((turn) => { + if (!turn.content.parts) return turn; + const hasThought = turn.content.parts.some((p) => p && p.thought); + if (!hasThought) return turn; + + const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought); + return { + id: turn.id, + content: { + ...turn.content, + parts: nonThoughtParts, + }, + }; + }); +} From 83d4deafbb9e3046925f6be4e7b16395b812b554 Mon Sep 17 00:00:00 2001 From: davidapierce Date: Thu, 23 Jul 2026 19:12:57 +0000 Subject: [PATCH 2/4] Filter out empty part arrays after thoughts are stripped. --- packages/core/src/core/geminiChat.test.ts | 90 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 30 ++++---- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 6780c9c0ea3..f9ba444544e 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -22,6 +22,7 @@ import { stripToolCallIdPrefixes, type HistoryTurn, coalesceConsecutiveRoles, + stripThoughts, } from './geminiChat.js'; import { type CompletedToolCall, @@ -2338,6 +2339,30 @@ describe('GeminiChat', () => { text: 'actual conversational response', }); }); + + it('should completely filter out model turns that end up with empty parts after stripping thoughts', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false); + vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro'); + + chat.setHistory([ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true } as unknown as Part, + ], + }, + ]); + + const turns = chat.getHistoryTurns(true); + + // Since the model turn contains only a thought part, it should be filtered out entirely. + expect(turns).toHaveLength(1); + expect(turns[0].content.role).toBe('user'); + }); }); describe('ensureActiveLoopHasThoughtSignatures', () => { @@ -3252,4 +3277,69 @@ describe('GeminiChat', () => { expect(coalesceConsecutiveRoles(history)).toEqual(history); }); }); + + describe('stripThoughts', () => { + it('should return empty history if empty array is passed', () => { + expect(stripThoughts([])).toEqual([]); + }); + + it('should strip thought parts and keep the turn if other parts remain', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true } as unknown as Part, + { text: 'visible response' }, + ], + }, + }, + ]; + expect(stripThoughts(history)).toEqual([ + { + id: '1', + content: { + role: 'model', + parts: [{ text: 'visible response' }], + }, + }, + ]); + }); + + it('should completely remove a turn if all its parts are thought parts', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'hello' }], + }, + }, + { + id: '2', + content: { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true } as unknown as Part, + ], + }, + }, + ]; + expect(stripThoughts(history)).toEqual([ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'hello' }], + }, + }, + ]); + }); + + it('should preserve turns that do not have parts arrays', () => { + const history: HistoryTurn[] = [{ id: '1', content: { role: 'user' } }]; + expect(stripThoughts(history)).toEqual(history); + }); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index a43c400f7cc..ab9b40d6e8b 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1518,18 +1518,20 @@ export function coalesceConsecutiveRoles( } export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { - return history.map((turn) => { - if (!turn.content.parts) return turn; - const hasThought = turn.content.parts.some((p) => p && p.thought); - if (!hasThought) return turn; - - const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought); - return { - id: turn.id, - content: { - ...turn.content, - parts: nonThoughtParts, - }, - }; - }); + return history + .map((turn) => { + if (!turn.content.parts) return turn; + const hasThought = turn.content.parts.some((p) => p && p.thought); + if (!hasThought) return turn; + + const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought); + return { + id: turn.id, + content: { + ...turn.content, + parts: nonThoughtParts, + }, + }; + }) + .filter((turn) => !turn.content.parts || turn.content.parts.length > 0); } From 63ec1392ea50cad808ef92c7024894cd94260bed Mon Sep 17 00:00:00 2001 From: davidapierce Date: Thu, 23 Jul 2026 20:03:19 +0000 Subject: [PATCH 3/4] maintain turn metadata and coalesce same roles. --- packages/core/src/core/geminiChat.test.ts | 49 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 4 +- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index f9ba444544e..83d97e59d91 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2363,6 +2363,30 @@ describe('GeminiChat', () => { expect(turns).toHaveLength(1); expect(turns[0].content.role).toBe('user'); }); + + it('should coalesce consecutive user turns when an intermediate model turn is stripped', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(false); + vi.mocked(mockConfig.getModel).mockReturnValue('gemini-2.5-pro'); + + chat.setHistory([ + { role: 'user', parts: [{ text: 'Question 1' }] }, + { + role: 'model', + parts: [{ text: 'thinking...', thought: true } as unknown as Part], + }, + { role: 'user', parts: [{ text: 'Question 2' }] }, + ]); + + const turns = chat.getHistoryTurns(true); + + // The model turn contains only a thought part, so it is stripped. + // The two adjacent user turns must be coalesced into one user turn. + expect(turns).toHaveLength(1); + expect(turns[0].content.role).toBe('user'); + expect(turns[0].content.parts).toHaveLength(2); + expect(turns[0].content.parts![0].text).toBe('Question 1'); + expect(turns[0].content.parts![1].text).toBe('Question 2'); + }); }); describe('ensureActiveLoopHasThoughtSignatures', () => { @@ -3341,5 +3365,30 @@ describe('GeminiChat', () => { const history: HistoryTurn[] = [{ id: '1', content: { role: 'user' } }]; expect(stripThoughts(history)).toEqual(history); }); + + it('should preserve top-level metadata when stripping thoughts', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true } as unknown as Part, + { text: 'visible response' }, + ], + }, + // top-level turn metadata + timestamp: '2026-07-23T00:00:00.000Z', + metadata: { some: 'value' }, + } as unknown as HistoryTurn, + ]; + const stripped = stripThoughts(history); + expect(stripped).toHaveLength(1); + expect(stripped[0]).toHaveProperty( + 'timestamp', + '2026-07-23T00:00:00.000Z', + ); + expect(stripped[0]).toHaveProperty('metadata', { some: 'value' }); + }); }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ab9b40d6e8b..e573a69060b 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -968,7 +968,7 @@ export class GeminiChat { const model = this.context.config.getModel(); if (isGemini2Model(model) || supportsModernFeatures(model)) { - return stripThoughts(history); + return coalesceConsecutiveRoles(stripThoughts(history)); } return history; @@ -1526,7 +1526,7 @@ export function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { const nonThoughtParts = turn.content.parts.filter((p) => p && !p.thought); return { - id: turn.id, + ...turn, content: { ...turn.content, parts: nonThoughtParts, From ab2776ba7ba976f2862892d78bc3d70aa1902054 Mon Sep 17 00:00:00 2001 From: davidapierce Date: Fri, 24 Jul 2026 19:13:26 +0000 Subject: [PATCH 4/4] update default mocked model for client test. --- packages/core/src/core/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 0606f18a00c..86272c02d17 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -212,7 +212,7 @@ describe('Gemini Client (client.ts)', () => { .fn() .mockReturnValue(contentGeneratorConfig), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), - getModel: vi.fn().mockReturnValue('test-model'), + getModel: vi.fn().mockReturnValue('gemini-1.5-pro'), getUserTier: vi.fn().mockReturnValue(undefined), getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'), getApiKey: vi.fn().mockReturnValue('test-key'),