From 63ec1392ea50cad808ef92c7024894cd94260bed Mon Sep 17 00:00:00 2001 From: davidapierce Date: Thu, 23 Jul 2026 20:03:19 +0000 Subject: [PATCH] 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 f9ba444544..83d97e59d9 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 ab9b40d6e8..e573a69060 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,