From f2a899a4c2402829cfecfd9a5c02bd422a875e4d Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Tue, 26 May 2026 12:46:52 +1000 Subject: [PATCH 1/7] fix(core): guard oversized resumed history sends --- packages/core/src/core/geminiChat.test.ts | 73 +++++++++++++++++++++++ packages/core/src/core/geminiChat.ts | 62 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 2b55cacc11f..4c97ac8dc9e 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2428,6 +2428,79 @@ describe('GeminiChat', async () => { ).toBe(true); }); + it('rejects before request serialization when oversized resumed history cannot be compressed', async () => { + const oversizedResumedHistory: Content[] = [ + { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + ]; + chat.setHistory(oversizedResumedHistory); + expect(chat.getLastPromptTokenCount()).toBe(0); + + const compressSpy = vi + .spyOn(ChatCompressionService.prototype, 'compress') + .mockResolvedValueOnce({ + newHistory: null, + info: { + originalTokenCount: 180_000, + newTokenCount: 180_000, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + }, + }); + vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue( + new Error('Invalid string length'), + ); + + await expect( + chat.sendMessageStream( + 'test-model', + { message: 'continue' }, + 'prompt-id-oversized-resume-guard', + ), + ).rejects.toThrow(/context is too large/i); + + expect(compressSpy).toHaveBeenCalledTimes(1); + expect(compressSpy.mock.calls[0][1].force).toBe(true); + expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); + expect(chat.getHistory()).toHaveLength(2); + }); + + it('rejects before request serialization when hard-rescue compression is still oversized', async () => { + chat.setHistory([ + { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + ]); + + vi.spyOn( + ChatCompressionService.prototype, + 'compress', + ).mockResolvedValueOnce({ + newHistory: [ + { role: 'user', parts: [{ text: 'still large summary' }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + ], + info: { + originalTokenCount: 180_000, + newTokenCount: 177_000, + compressionStatus: CompressionStatus.COMPRESSED, + }, + }); + vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue( + new Error('Invalid string length'), + ); + + await expect( + chat.sendMessageStream( + 'test-model', + { message: 'continue' }, + 'prompt-id-oversized-after-compression', + ), + ).rejects.toThrow(/context is too large/i); + + expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); + expect(chat.getHistory()[0].parts?.[0].text).toBe('still large summary'); + }); + it('forwards latched consecutiveFailures into hard-rescue (no pre-call reset); success recovers via the post-call branch', async () => { // Hard-rescue uses force=true, which already bypasses the // chatCompressionService breaker (the `!force` check in compress's diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 759a74b433a..5da26f14a23 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -106,6 +106,42 @@ function isCompressionFailureStatus(status: CompressionStatus): boolean { ); } +function shouldStopAfterHardRescue( + shouldForceFromHard: boolean, + compressionInfo: ChatCompressionInfo, + hardLimit: number, + localPromptTokensAfterCompression: number, +): boolean { + if (!shouldForceFromHard) { + return false; + } + if (compressionInfo.compressionStatus !== CompressionStatus.COMPRESSED) { + return localPromptTokensAfterCompression >= hardLimit; + } + return ( + compressionInfo.newTokenCount >= hardLimit || + localPromptTokensAfterCompression >= hardLimit + ); +} + +function getHardRescueFailureMessage( + effectiveTokens: number, + hardLimit: number, + compressionInfo: ChatCompressionInfo, + localPromptTokensAfterCompression: number, +): string { + const tokenCount = + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED + ? compressionInfo.newTokenCount + : Math.max(effectiveTokens, localPromptTokensAfterCompression); + return ( + `Context is too large to send safely after automatic compression. ` + + `Estimated prompt tokens: ${tokenCount}; hard limit: ${hardLimit}; ` + + `compression status: ${compressionInfo.compressionStatus}. ` + + `Start a new session or reduce the resumed history before continuing.` + ); +} + export enum StreamEventType { /** A regular content chunk from the API. */ CHUNK = 'chunk', @@ -1550,6 +1586,32 @@ export class GeminiChat { }, ); + const localPromptTokensAfterCompression = shouldForceFromHard + ? estimatePromptTokens( + this.getHistoryShallow(true), + userContent, + 0, + imageTokenEstimate, + ) + : 0; + if ( + shouldStopAfterHardRescue( + shouldForceFromHard, + compressionInfo, + hard, + localPromptTokensAfterCompression, + ) + ) { + const message = getHardRescueFailureMessage( + effectiveTokens, + hard, + compressionInfo, + localPromptTokensAfterCompression, + ); + debugLogger.warn(message); + throw new Error(message); + } + // Add user content to history ONCE before any attempts. this.history.push(userContent); userContentAdded = true; From b25e964e5b3d2c49dc1c2afb11fccbbdcd97a096 Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Wed, 27 May 2026 14:47:13 +1000 Subject: [PATCH 2/7] fix(core): preserve history on hard rescue stop --- packages/core/src/core/geminiChat.test.ts | 51 +++++++++++++++++++++-- packages/core/src/core/geminiChat.ts | 35 +++++++++++++--- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4c97ac8dc9e..c22622e0eb6 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1632,6 +1632,11 @@ describe('GeminiChat', async () => { }); it('seeds inherited token count via setLastPromptTokenCount', async () => { + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + model: 'test-model', + contextWindowSize: 200_000, + }); const subagentChat = new GeminiChat(mockConfig, config, [ { role: 'user', parts: [{ text: 'inherited' }] }, { role: 'model', parts: [{ text: 'inherited reply' }] }, @@ -2465,11 +2470,12 @@ describe('GeminiChat', async () => { expect(chat.getHistory()).toHaveLength(2); }); - it('rejects before request serialization when hard-rescue compression is still oversized', async () => { - chat.setHistory([ + it('rejects before request serialization and restores history when hard-rescue compression is still oversized', async () => { + const originalHistory: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, { role: 'model', parts: [{ text: 'ack' }] }, - ]); + ]; + chat.setHistory(originalHistory); vi.spyOn( ChatCompressionService.prototype, @@ -2498,7 +2504,44 @@ describe('GeminiChat', async () => { ).rejects.toThrow(/context is too large/i); expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); - expect(chat.getHistory()[0].parts?.[0].text).toBe('still large summary'); + expect(chat.getHistory()[0].parts?.[0].text).toBe( + originalHistory[0].parts?.[0].text, + ); + }); + + it('rejects when compressed history is below hard but the pending user message pushes it over', async () => { + chat.setHistory([ + { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + ]); + + vi.spyOn( + ChatCompressionService.prototype, + 'compress', + ).mockResolvedValueOnce({ + newHistory: [ + { role: 'user', parts: [{ text: 'summary' }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + ], + info: { + originalTokenCount: 180_000, + newTokenCount: 176_000, + compressionStatus: CompressionStatus.COMPRESSED, + }, + }); + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + makeStreamResponse('should not send'), + ); + + await expect( + chat.sendMessageStream( + 'test-model', + { message: 'x'.repeat(8_000) }, + 'prompt-id-oversized-after-compression-and-user', + ), + ).rejects.toThrow(/Estimated prompt tokens: 178000; hard limit: 177000/i); + + expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); }); it('forwards latched consecutiveFailures into hard-rescue (no pre-call reset); success recovers via the post-call branch', async () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5da26f14a23..cec2f03d74a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -132,7 +132,10 @@ function getHardRescueFailureMessage( ): string { const tokenCount = compressionInfo.compressionStatus === CompressionStatus.COMPRESSED - ? compressionInfo.newTokenCount + ? Math.max( + compressionInfo.newTokenCount, + localPromptTokensAfterCompression, + ) : Math.max(effectiveTokens, localPromptTokensAfterCompression); return ( `Context is too large to send safely after automatic compression. ` + @@ -1561,9 +1564,13 @@ export class GeminiChat { imageTokenEstimate, ); const shouldForceFromHard = effectiveTokens >= hard; + const historyBeforeHardRescue = shouldForceFromHard + ? this.getHistoryShallow() + : undefined; + const lastPromptTokenCountBeforeHardRescue = this.lastPromptTokenCount; if (shouldForceFromHard) { debugLogger.warn( - `[compaction] hard-tier rescue triggered: effectiveTokens=${effectiveTokens}, hard=${hard}, consecutiveFailures=${this.consecutiveFailures}.`, + `[compaction] hard-tier rescue triggered: prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, hard=${hard}, consecutiveFailures=${this.consecutiveFailures}.`, ); } @@ -1588,9 +1595,9 @@ export class GeminiChat { const localPromptTokensAfterCompression = shouldForceFromHard ? estimatePromptTokens( - this.getHistoryShallow(true), + this.lastPromptTokenCount > 0 ? [] : this.getHistoryShallow(true), userContent, - 0, + this.lastPromptTokenCount, imageTokenEstimate, ) : 0; @@ -1608,7 +1615,25 @@ export class GeminiChat { compressionInfo, localPromptTokensAfterCompression, ); - debugLogger.warn(message); + if ( + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED && + historyBeforeHardRescue + ) { + this.setHistory(historyBeforeHardRescue); + this.lastPromptTokenCount = lastPromptTokenCountBeforeHardRescue; + this.telemetryService?.setLastPromptTokenCount( + lastPromptTokenCountBeforeHardRescue, + ); + } + debugLogger.warn( + `[compaction] hard-tier rescue stopped oversized prompt: ` + + `prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, ` + + `hard=${hard}, localPromptTokensAfterCompression=` + + `${localPromptTokensAfterCompression}, compressionStatus=` + + `${compressionInfo.compressionStatus}, newTokenCount=` + + `${compressionInfo.newTokenCount}, consecutiveFailures=` + + `${this.consecutiveFailures}. ${message}`, + ); throw new Error(message); } From a171a5df2f7c055e0afd1235787f3eb97e7c90f9 Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Wed, 27 May 2026 22:12:19 +1000 Subject: [PATCH 3/7] fix(core): defer hard-rescue compression recording until guard passes --- packages/core/src/core/geminiChat.test.ts | 48 ++++++++++++++++++++--- packages/core/src/core/geminiChat.ts | 38 ++++++++++-------- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index c22622e0eb6..f63ebcaf689 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2387,6 +2387,17 @@ describe('GeminiChat', async () => { { role: 'user', parts: [{ text: 'summary' }] }, { role: 'model', parts: [{ text: 'ack' }] }, ]; + const recordChatCompression = vi.fn(); + const chatWithRecording = new GeminiChat( + mockConfig, + config, + [], + { + recordAssistantTurn: vi.fn(), + recordChatCompression, + } as unknown as ConstructorParameters[3], + uiTelemetryService, + ); const compressSpy = vi .spyOn(ChatCompressionService.prototype, 'compress') .mockResolvedValueOnce({ @@ -2404,10 +2415,10 @@ describe('GeminiChat', async () => { // Seed lastPromptTokenCount JUST under the 177K hard threshold; the // pending user message adds a handful of estimate-tokens that pushes // effective >= 177K, so the rescue must trigger. - chat.setLastPromptTokenCount(176_999); + chatWithRecording.setLastPromptTokenCount(176_999); const userMessage = 'this is the next user message'; - const stream = await chat.sendMessageStream( + const stream = await chatWithRecording.sendMessageStream( 'test-model', { message: userMessage }, 'prompt-id-hard-rescue-forces', @@ -2431,6 +2442,18 @@ describe('GeminiChat', async () => { (part) => part.text === userMessage, ), ).toBe(true); + expect(recordChatCompression).toHaveBeenCalledTimes(1); + const recordPayload = recordChatCompression.mock.calls[0][0]; + expect(recordPayload.info).toEqual( + expect.objectContaining({ + compressionStatus: CompressionStatus.COMPRESSED, + newTokenCount: 40_000, + }), + ); + expect(recordPayload.compressedHistory).toEqual([ + { role: 'user', parts: [{ text: 'summary' }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + ]); }); it('rejects before request serialization when oversized resumed history cannot be compressed', async () => { @@ -2475,7 +2498,19 @@ describe('GeminiChat', async () => { { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, { role: 'model', parts: [{ text: 'ack' }] }, ]; - chat.setHistory(originalHistory); + const recordChatCompression = vi.fn(); + const chatWithRecording = new GeminiChat( + mockConfig, + config, + [], + { + recordAssistantTurn: vi.fn(), + recordChatCompression, + } as unknown as ConstructorParameters[3], + uiTelemetryService, + ); + chatWithRecording.setHistory(originalHistory); + chatWithRecording.setLastPromptTokenCount(176_999); vi.spyOn( ChatCompressionService.prototype, @@ -2496,7 +2531,7 @@ describe('GeminiChat', async () => { ); await expect( - chat.sendMessageStream( + chatWithRecording.sendMessageStream( 'test-model', { message: 'continue' }, 'prompt-id-oversized-after-compression', @@ -2504,7 +2539,9 @@ describe('GeminiChat', async () => { ).rejects.toThrow(/context is too large/i); expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); - expect(chat.getHistory()[0].parts?.[0].text).toBe( + expect(recordChatCompression).not.toHaveBeenCalled(); + expect(chatWithRecording.getLastPromptTokenCount()).toBe(176_999); + expect(chatWithRecording.getHistory()[0].parts?.[0].text).toBe( originalHistory[0].parts?.[0].text, ); }); @@ -2542,6 +2579,7 @@ describe('GeminiChat', async () => { ).rejects.toThrow(/Estimated prompt tokens: 178000; hard limit: 177000/i); expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); + expect(chat.getLastPromptTokenCount()).toBe(0); }); it('forwards latched consecutiveFailures into hard-rescue (no pre-call reset); success recovers via the post-call branch', async () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index cec2f03d74a..54c225b540f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -108,20 +108,10 @@ function isCompressionFailureStatus(status: CompressionStatus): boolean { function shouldStopAfterHardRescue( shouldForceFromHard: boolean, - compressionInfo: ChatCompressionInfo, hardLimit: number, localPromptTokensAfterCompression: number, ): boolean { - if (!shouldForceFromHard) { - return false; - } - if (compressionInfo.compressionStatus !== CompressionStatus.COMPRESSED) { - return localPromptTokensAfterCompression >= hardLimit; - } - return ( - compressionInfo.newTokenCount >= hardLimit || - localPromptTokensAfterCompression >= hardLimit - ); + return shouldForceFromHard && localPromptTokensAfterCompression >= hardLimit; } function getHardRescueFailureMessage( @@ -196,6 +186,11 @@ interface TryCompressOptions { * `getHistory(true)` clone per send. (review #4168 R1.3 / R1.4) */ precomputedEffectiveTokens?: number; + /** + * Delay writing the compression checkpoint until the caller has run any + * post-compression guards that may roll the in-memory chat state back. + */ + deferChatCompressionRecord?: boolean; } const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = { @@ -1391,10 +1386,12 @@ export class GeminiChat { }); if (info.compressionStatus === CompressionStatus.COMPRESSED && newHistory) { - this.chatRecordingService?.recordChatCompression({ - info, - compressedHistory: newHistory, - }); + if (!options?.deferChatCompressionRecord) { + this.chatRecordingService?.recordChatCompression({ + info, + compressedHistory: newHistory, + }); + } this.setHistory(newHistory); debugLogger.debug('[FILE_READ_CACHE] clear after auto tryCompress'); this.config.getFileReadCache().clear(); @@ -1582,6 +1579,7 @@ export class GeminiChat { { pendingUserMessage: userContent, precomputedEffectiveTokens: effectiveTokens, + deferChatCompressionRecord: shouldForceFromHard, // Hard-rescue is force=true to bypass the cheap-gate breaker // but it's an AUTOMATIC trigger. Explicit trigger='auto' tells // the service to skip the manual-only orphan-strip that would @@ -1604,7 +1602,6 @@ export class GeminiChat { if ( shouldStopAfterHardRescue( shouldForceFromHard, - compressionInfo, hard, localPromptTokensAfterCompression, ) @@ -1636,6 +1633,15 @@ export class GeminiChat { ); throw new Error(message); } + if ( + shouldForceFromHard && + compressionInfo.compressionStatus === CompressionStatus.COMPRESSED + ) { + this.chatRecordingService?.recordChatCompression({ + info: compressionInfo, + compressedHistory: this.getHistoryShallow(), + }); + } // Add user content to history ONCE before any attempts. this.history.push(userContent); From 7dbe38f00ef2e5d4491ac33da15a4d8ee2350fee Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Wed, 27 May 2026 23:34:37 +1000 Subject: [PATCH 4/7] test(core): clarify hard-rescue compression status --- packages/core/src/core/geminiChat.test.ts | 7 +++++-- packages/core/src/core/geminiChat.ts | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index f63ebcaf689..4694759bcce 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2485,11 +2485,14 @@ describe('GeminiChat', async () => { { message: 'continue' }, 'prompt-id-oversized-resume-guard', ), - ).rejects.toThrow(/context is too large/i); + ).rejects.toThrow( + /compression status: COMPRESSION_FAILED_EMPTY_SUMMARY/i, + ); expect(compressSpy).toHaveBeenCalledTimes(1); expect(compressSpy.mock.calls[0][1].force).toBe(true); expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); + expect(chat.getLastPromptTokenCount()).toBe(0); expect(chat.getHistory()).toHaveLength(2); }); @@ -2536,7 +2539,7 @@ describe('GeminiChat', async () => { { message: 'continue' }, 'prompt-id-oversized-after-compression', ), - ).rejects.toThrow(/context is too large/i); + ).rejects.toThrow(/compression status: COMPRESSED/i); expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); expect(recordChatCompression).not.toHaveBeenCalled(); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 54c225b540f..418c3df498c 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -120,6 +120,9 @@ function getHardRescueFailureMessage( compressionInfo: ChatCompressionInfo, localPromptTokensAfterCompression: number, ): string { + const compressionStatus = + CompressionStatus[compressionInfo.compressionStatus] ?? + String(compressionInfo.compressionStatus); const tokenCount = compressionInfo.compressionStatus === CompressionStatus.COMPRESSED ? Math.max( @@ -130,7 +133,7 @@ function getHardRescueFailureMessage( return ( `Context is too large to send safely after automatic compression. ` + `Estimated prompt tokens: ${tokenCount}; hard limit: ${hardLimit}; ` + - `compression status: ${compressionInfo.compressionStatus}. ` + + `compression status: ${compressionStatus}. ` + `Start a new session or reduce the resumed history before continuing.` ); } From 99e7b77903bf93e227ccf697c3666080845b6e20 Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Thu, 28 May 2026 01:55:00 +1000 Subject: [PATCH 5/7] test(core): cover hard rescue rollback invariants --- packages/core/src/core/geminiChat.test.ts | 25 +++++++++++++++++++---- packages/core/src/core/geminiChat.ts | 10 ++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4694759bcce..2c12bdc0ce3 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2550,10 +2550,23 @@ describe('GeminiChat', async () => { }); it('rejects when compressed history is below hard but the pending user message pushes it over', async () => { - chat.setHistory([ + const originalHistory: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, { role: 'model', parts: [{ text: 'ack' }] }, - ]); + ]; + const recordChatCompression = vi.fn(); + const chatWithRecording = new GeminiChat( + mockConfig, + config, + [], + { + recordAssistantTurn: vi.fn(), + recordChatCompression, + } as unknown as ConstructorParameters[3], + uiTelemetryService, + ); + chatWithRecording.setHistory(originalHistory); + chatWithRecording.setLastPromptTokenCount(175_500); vi.spyOn( ChatCompressionService.prototype, @@ -2574,7 +2587,7 @@ describe('GeminiChat', async () => { ); await expect( - chat.sendMessageStream( + chatWithRecording.sendMessageStream( 'test-model', { message: 'x'.repeat(8_000) }, 'prompt-id-oversized-after-compression-and-user', @@ -2582,7 +2595,11 @@ describe('GeminiChat', async () => { ).rejects.toThrow(/Estimated prompt tokens: 178000; hard limit: 177000/i); expect(mockContentGenerator.generateContentStream).not.toHaveBeenCalled(); - expect(chat.getLastPromptTokenCount()).toBe(0); + expect(recordChatCompression).not.toHaveBeenCalled(); + expect(chatWithRecording.getLastPromptTokenCount()).toBe(175_500); + expect(chatWithRecording.getHistory()[0].parts?.[0].text).toBe( + originalHistory[0].parts?.[0].text, + ); }); it('forwards latched consecutiveFailures into hard-rescue (no pre-call reset); success recovers via the post-call branch', async () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 418c3df498c..a1d44970b98 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1619,18 +1619,26 @@ export class GeminiChat { compressionInfo.compressionStatus === CompressionStatus.COMPRESSED && historyBeforeHardRescue ) { + // Hard-rescue compression mutates in-memory history before this + // guard can compare the compressed prompt size. If the compressed + // prompt is still too large to send, restore the pre-compression + // state and defer the JSONL compression checkpoint until a guarded + // send is actually allowed below. this.setHistory(historyBeforeHardRescue); this.lastPromptTokenCount = lastPromptTokenCountBeforeHardRescue; this.telemetryService?.setLastPromptTokenCount( lastPromptTokenCountBeforeHardRescue, ); } + const compressionStatus = + CompressionStatus[compressionInfo.compressionStatus] ?? + String(compressionInfo.compressionStatus); debugLogger.warn( `[compaction] hard-tier rescue stopped oversized prompt: ` + `prompt_id=${prompt_id}, effectiveTokens=${effectiveTokens}, ` + `hard=${hard}, localPromptTokensAfterCompression=` + `${localPromptTokensAfterCompression}, compressionStatus=` + - `${compressionInfo.compressionStatus}, newTokenCount=` + + `${compressionStatus}, newTokenCount=` + `${compressionInfo.newTokenCount}, consecutiveFailures=` + `${this.consecutiveFailures}. ${message}`, ); From 7541e556089beb72c4b8447c7c393f8eeb1dfec5 Mon Sep 17 00:00:00 2001 From: JerryLee <223425819+Jerry2003826@users.noreply.github.com> Date: Thu, 28 May 2026 02:41:35 +1000 Subject: [PATCH 6/7] docs(core): clarify hard rescue rollback comment --- packages/core/src/core/geminiChat.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index a1d44970b98..cb9940398ff 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1622,8 +1622,8 @@ export class GeminiChat { // Hard-rescue compression mutates in-memory history before this // guard can compare the compressed prompt size. If the compressed // prompt is still too large to send, restore the pre-compression - // state and defer the JSONL compression checkpoint until a guarded - // send is actually allowed below. + // state. The JSONL compression checkpoint is intentionally not + // written because the send is about to be rejected. this.setHistory(historyBeforeHardRescue); this.lastPromptTokenCount = lastPromptTokenCountBeforeHardRescue; this.telemetryService?.setLastPromptTokenCount( From 89f35d1e9312e21a77eb7e43ba5d26aaecd2d84d Mon Sep 17 00:00:00 2001 From: Jerry Lee <223425819+Jerry2003826@users.noreply.github.com> Date: Thu, 28 May 2026 19:05:25 +1000 Subject: [PATCH 7/7] docs(core): document deferred compression recording --- packages/core/src/core/geminiChat.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index cb9940398ff..dca02417fe0 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1362,9 +1362,11 @@ export class GeminiChat { * * Returns the compression info regardless of outcome. On a successful * compaction (`COMPRESSED`), this method has already mutated the chat's - * history, recorded the event to `chatRecordingService` (if wired), and - * updated both the per-chat token count and (when wired) the global - * telemetry singleton. + * history, recorded the event to `chatRecordingService` (if wired and + * unless `options.deferChatCompressionRecord` is set), and updated both + * the per-chat token count and (when wired) the global telemetry singleton. + * Deferred callers are responsible for recording after their own + * post-compression guards pass. */ async tryCompress( promptId: string,