From 9272f4bc26486339c2461f895116b9c164d3b84e Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 20 Aug 2026 16:47:10 +0800 Subject: [PATCH 1/4] fix(cli): mark estimated compression token counts in banners /compress-fast anchors its banner on the API-reported prompt count (system prompt + tools + history) minus a locally estimated reduction, while a later /compress re-estimates history-only once the stored count is estimate-derived. Consecutive banners therefore show numbers on different scales (e.g. 170k -> 75.8k followed by 26.6k -> 14.5k), which reads as ~50k of context silently lost even though nothing was dropped. Expose per-side token-count provenance on ChatCompressionInfo (originalTokenCountIsEstimated, mirroring newTokenCountIsEstimated) from both tryCompress and compressFast, plumb it through the compression history item, and render estimated figures with a '~' prefix in the CompressionMessage banner, the auto-compaction notice, and the headless/ACP messages. Display-only: no compression behavior, thresholds, or recorded counts change. Fixes #9309 --- .../src/ui/commands/compressCommand.test.ts | 62 ++++++++++ .../cli/src/ui/commands/compressCommand.ts | 12 +- .../ui/commands/compressFastCommand.test.ts | 61 ++++++++++ .../src/ui/commands/compressFastCommand.ts | 12 +- .../messages/CompressionMessage.test.tsx | 43 +++++++ .../messages/CompressionMessage.tsx | 23 +++- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 37 ++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 12 +- packages/cli/src/ui/types.ts | 7 ++ packages/core/src/core/geminiChat.test.ts | 110 ++++++++++++++++++ packages/core/src/core/geminiChat.ts | 7 ++ packages/core/src/core/turn.ts | 9 ++ 12 files changed, 385 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index b80fcc30671..d7bb4fb9083 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -133,6 +133,68 @@ describe('compressCommand', () => { expect(context.ui.setPendingItem).toHaveBeenCalledWith(null); }); + // Issue #9309: after /compress-fast the summarize banner is measured on a + // different scale (local history-only estimate vs the fast banner's + // API-reported baseline), so the compression item must carry per-side + // provenance for the renderer to mark estimated numbers. + it('should pass token-count provenance to the compression item (interactive)', async () => { + mockTryCompressChat.mockResolvedValue({ + originalTokenCount: 200, + newTokenCount: 100, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: true, + compressionStatus: CompressionStatus.COMPRESSED, + } satisfies ChatCompressionInfo); + + await compressCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.COMPRESSION, + compression: { + isPending: false, + compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCount: 200, + newTokenCount: 100, + compressionKind: 'summarize', + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: true, + }, + }, + expect.any(Number), + ); + }); + + it('should mark estimated counts in the non-interactive message', async () => { + mockTryCompressChat.mockResolvedValue({ + originalTokenCount: 200, + newTokenCount: 100, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: true, + compressionStatus: CompressionStatus.COMPRESSED, + } satisfies ChatCompressionInfo); + + const ctx = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getGeminiClient: () => + ({ + tryCompressChat: mockTryCompressChat, + }) as unknown as GeminiClient, + }, + }, + }); + + const result = await compressCommand.action!(ctx, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Context compressed (~200 -> ~100).', + }); + }); + it('should add an error message if tryCompressChat throws', async () => { const error = new Error('Compression failed'); mockTryCompressChat.mockRejectedValue(error); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index b85a3f7ae49..afb63d4099c 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -17,6 +17,11 @@ import { t } from '../../i18n/index.js'; // without exposing that failure mode. const MAX_COMPRESS_INSTRUCTIONS_CHARS = 2000; +// Estimated counts (#9309) get a '~' prefix so structured/headless consumers +// don't treat locally estimated numbers as API-reported token counts. +const formatTokenCount = (count: number, isEstimated?: boolean) => + isEstimated ? `~${count}` : String(count); + export const compressCommand: SlashCommand = { name: 'compress', altNames: ['summarize'], @@ -109,7 +114,7 @@ export const compressCommand: SlashCommand = { yield { messageType: 'info' as const, content: - `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).` + + `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` + (compressed.warning ? `\n⚠️ ${compressed.warning}` : ''), }; } catch (e) { @@ -171,6 +176,9 @@ export const compressCommand: SlashCommand = { newTokenCount: compressed.newTokenCount, compressionStatus: compressed.compressionStatus, compressionKind: 'summarize', + originalTokenCountIsEstimated: + compressed.originalTokenCountIsEstimated, + newTokenCountIsEstimated: compressed.newTokenCountIsEstimated, }, } as HistoryItemCompression, Date.now(), @@ -188,7 +196,7 @@ export const compressCommand: SlashCommand = { type: 'message', messageType: 'info', content: - `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).` + + `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).` + (compressed.warning ? `\n⚠️ ${compressed.warning}` : ''), }; } catch (e) { diff --git a/packages/cli/src/ui/commands/compressFastCommand.test.ts b/packages/cli/src/ui/commands/compressFastCommand.test.ts index 4714fd3bdff..ace8cc9cd85 100644 --- a/packages/cli/src/ui/commands/compressFastCommand.test.ts +++ b/packages/cli/src/ui/commands/compressFastCommand.test.ts @@ -181,6 +181,67 @@ describe('compressFastCommand', () => { ); }); + // Issue #9309: the fast-compression banner mixes an API-reported baseline + // with a locally adjusted count, so the UI item must carry per-side + // provenance for the renderer to mark estimated numbers. + it('should pass token-count provenance to the compression item (interactive)', async () => { + mockTryCompressChatFast.mockResolvedValue({ + originalTokenCount: 200, + newTokenCount: 100, + originalTokenCountIsEstimated: false, + newTokenCountIsEstimated: true, + compressionStatus: CompressionStatus.COMPRESSED, + } satisfies ChatCompressionInfo); + + await compressFastCommand.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.COMPRESSION, + compression: { + isPending: false, + originalTokenCount: 200, + newTokenCount: 100, + compressionStatus: CompressionStatus.COMPRESSED, + compressionKind: 'fast', + originalTokenCountIsEstimated: false, + newTokenCountIsEstimated: true, + }, + }, + expect.any(Number), + ); + }); + + it('should mark estimated counts in the non-interactive message', async () => { + mockTryCompressChatFast.mockResolvedValue({ + originalTokenCount: 200, + newTokenCount: 100, + originalTokenCountIsEstimated: false, + newTokenCountIsEstimated: true, + compressionStatus: CompressionStatus.COMPRESSED, + } satisfies ChatCompressionInfo); + + const ctx = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getGeminiClient: () => + ({ + tryCompressChatFast: mockTryCompressChatFast, + }) as unknown as GeminiClient, + }, + }, + }); + + const result = await compressFastCommand.action!(ctx, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Context compressed (200 -> ~100).', + }); + }); + it('should handle errors gracefully', async () => { const error = new Error('Compression failed'); mockTryCompressChatFast.mockRejectedValue(error); diff --git a/packages/cli/src/ui/commands/compressFastCommand.ts b/packages/cli/src/ui/commands/compressFastCommand.ts index ab6b199f56d..b0687a35828 100644 --- a/packages/cli/src/ui/commands/compressFastCommand.ts +++ b/packages/cli/src/ui/commands/compressFastCommand.ts @@ -11,6 +11,11 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +// Estimated counts (#9309) get a '~' prefix so structured/headless consumers +// don't treat locally estimated numbers as API-reported token counts. +const formatTokenCount = (count: number, isEstimated?: boolean) => + isEstimated ? `~${count}` : String(count); + export const compressFastCommand: SlashCommand = { name: 'compress-fast', get description() { @@ -85,7 +90,7 @@ export const compressFastCommand: SlashCommand = { } yield { messageType: 'info' as const, - content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, + content: `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).`, }; } catch (e) { yield { @@ -139,6 +144,9 @@ export const compressFastCommand: SlashCommand = { newTokenCount: compressed.newTokenCount, compressionStatus: compressed.compressionStatus, compressionKind: 'fast', + originalTokenCountIsEstimated: + compressed.originalTokenCountIsEstimated, + newTokenCountIsEstimated: compressed.newTokenCountIsEstimated, }, } as HistoryItemCompression, Date.now(), @@ -149,7 +157,7 @@ export const compressFastCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`, + content: `Context compressed (${formatTokenCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} -> ${formatTokenCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)}).`, }; } catch (e) { if (executionMode === 'interactive') { diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx index b121fb738d5..1b48636b6eb 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.test.tsx @@ -77,6 +77,49 @@ describe('', () => { }); }); + // Issue #9309: /compress-fast and /compress report on different scales + // (API-reported baseline vs local history-only estimate), so estimated + // numbers must be visibly marked to keep consecutive banners from reading + // as lost context. + describe('estimated token counts', () => { + it('marks an estimated new count with a ~ prefix', () => { + const props = createCompressionProps({ + originalTokenCount: 100, + newTokenCount: 50, + compressionStatus: CompressionStatus.COMPRESSED, + newTokenCountIsEstimated: true, + }); + const { lastFrame } = render(); + + expect(lastFrame()).toContain('compressed from 100 to ~50 tokens'); + }); + + it('marks an estimated original count with a ~ prefix', () => { + const props = createCompressionProps({ + originalTokenCount: 100, + newTokenCount: 50, + compressionStatus: CompressionStatus.COMPRESSED, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: true, + }); + const { lastFrame } = render(); + + expect(lastFrame()).toContain('compressed from ~100 to ~50 tokens'); + }); + + it('does not mark authoritative counts', () => { + const props = createCompressionProps({ + originalTokenCount: 100, + newTokenCount: 50, + compressionStatus: CompressionStatus.COMPRESSED, + }); + const { lastFrame } = render(); + + expect(lastFrame()).toContain('compressed from 100 to 50 tokens'); + expect(lastFrame()).not.toContain('~'); + }); + }); + describe('skipped compression (tokens increased or same)', () => { it('renders skip message when compression would increase token count', () => { const props = createCompressionProps({ diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx index ab1a7c264cb..9e0e536e979 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx @@ -24,12 +24,24 @@ export interface CompressionDisplayProps { export function CompressionMessage({ compression, }: CompressionDisplayProps): React.JSX.Element { - const { isPending, originalTokenCount, newTokenCount, compressionStatus } = - compression; + const { + isPending, + originalTokenCount, + newTokenCount, + compressionStatus, + originalTokenCountIsEstimated, + newTokenCountIsEstimated, + } = compression; const originalTokens = originalTokenCount ?? 0; const newTokens = newTokenCount ?? 0; + // Estimated counts (#9309): the two compression paths measure on different + // scales, so a '~' prefix marks which banner numbers are local estimates + // rather than API-reported counts. + const formatTokens = (count: number, isEstimated?: boolean) => + isEstimated ? `~${count}` : String(count); + const getCompressionText = () => { if (isPending) { return t('Compressing chat history'); @@ -40,8 +52,11 @@ export function CompressionMessage({ return t( 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.', { - originalTokens: String(originalTokens), - newTokens: String(newTokens), + originalTokens: formatTokens( + originalTokens, + originalTokenCountIsEstimated, + ), + newTokens: formatTokens(newTokens, newTokenCountIsEstimated), }, ); case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT: diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 56949848fe0..aef06caff59 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -10987,6 +10987,43 @@ describe('useGeminiStream', () => { }), ]); }); + + // Issue #9309: auto-compaction numbers can be local estimates rather + // than API-reported counts; the notice must mark them so consecutive + // compression banners on different scales don't read as lost context. + it('marks estimated compression counts in the auto-compaction notice', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.ChatCompressed, + value: { + originalTokenCount: 100, + newTokenCount: 50, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: true, + }, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderTestHook(); + await act(async () => { + await result.current.submitQuery('test estimated compression'); + }); + + const infoItems = mockAddItem.mock.calls + .map(([item]) => item as HistoryItem) + .filter((item) => item.type === 'info'); + expect(infoItems).toEqual([ + expect.objectContaining({ + text: expect.stringContaining('compressed from: ~100 to ~50 tokens'), + }), + ]); + }); }); describe('handleFinishedEvent', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index df1b761285e..f05f05f783e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -2192,14 +2192,22 @@ export const useGeminiStream = ( const warningSuffix = eventValue?.warning ? `\n⚠️ ${eventValue.warning}` : ''; + // Estimated counts (#9309) get a '~' prefix so the notice doesn't read + // as an API-reported figure on a different scale than a later banner. + const formatCount = (count?: number, isEstimated?: boolean) => + count === undefined + ? 'unknown' + : isEstimated + ? `~${count}` + : String(count); return addItem( { type: 'info', text: `IMPORTANT: This conversation ${reasonClause}. ` + `A compressed context will be sent for future messages (compressed from: ` + - `${eventValue?.originalTokenCount ?? 'unknown'} to ` + - `${eventValue?.newTokenCount ?? 'unknown'} tokens).` + + `${formatCount(eventValue?.originalTokenCount, eventValue?.originalTokenCountIsEstimated)} to ` + + `${formatCount(eventValue?.newTokenCount, eventValue?.newTokenCountIsEstimated)} tokens).` + warningSuffix, }, Date.now(), diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 613d2e78d62..6a92d0aa6b0 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -110,6 +110,13 @@ export interface CompressionProps { * older sessions, which are treated as 'summarize'. */ compressionKind?: 'summarize' | 'fast'; + /** + * Token-count provenance (#9309). The compression paths measure on + * different scales, so estimated numbers are rendered with a '~' prefix + * to keep consecutive banners from reading as lost context. + */ + originalTokenCountIsEstimated?: boolean; + newTokenCountIsEstimated?: boolean; } export interface SummaryProps { diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 5fdf62abae4..486de38b424 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -15467,6 +15467,116 @@ describe('GeminiChat', async () => { }), ); }); + + // Reproduction for issue #9309: /compress-fast followed by /compress + // shows banners on two different scales — compressFast anchors on the + // API-reported prompt count (system prompt + tools + history) while a + // later tryCompress re-estimates history-only once the stored count is + // estimate-derived. The numbers therefore cannot chain across commands; + // each banner must at least expose which side is an estimate so the UI + // can mark it instead of presenting the jump as lost context. + it('exposes provenance on fast-compression info: API baseline authoritative, adjusted count estimated', () => { + vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ + toolResultsThresholdMinutes: 30, + toolResultsNumToKeep: 1, + }); + const fastChat = new GeminiChat( + mockConfig, + config, + [ + userMsg('question'), + { + role: 'model', + parts: [ + { text: 'reasoning '.repeat(100), thought: true }, + { text: 'answer' }, + ], + }, + ], + undefined, + uiTelemetryService, + ); + fastChat.seedResumeTokenCounts(5000, 0, false); + + const { info } = fastChat.compressFast(); + + expect(info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(info.originalTokenCountIsEstimated).toBe(false); + expect(info.newTokenCountIsEstimated).toBe(true); + }); + + it('marks the fast-compression baseline as estimated when no API count exists', () => { + vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ + toolResultsThresholdMinutes: 30, + toolResultsNumToKeep: 1, + }); + const fastChat = new GeminiChat( + mockConfig, + config, + [ + userMsg('question'), + { + role: 'model', + parts: [ + { text: 'reasoning '.repeat(100), thought: true }, + { text: 'answer' }, + ], + }, + ], + undefined, + uiTelemetryService, + ); + + const { info } = fastChat.compressFast(); + + expect(info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(info.originalTokenCount).toBeGreaterThan(0); + expect(info.originalTokenCountIsEstimated).toBe(true); + }); + + it('reports original-count provenance on the summarize path after a fast compression', async () => { + vi.mocked(mockConfig.getClearContextOnIdle).mockReturnValue({ + toolResultsThresholdMinutes: 30, + toolResultsNumToKeep: 1, + }); + const compressSpy = mockCompressionService('compressed'); + chat.setHistory([ + userMsg('question'), + { + role: 'model', + parts: [ + { text: 'reasoning '.repeat(100), thought: true }, + { text: 'answer' }, + ], + }, + ]); + chat.seedResumeTokenCounts(5000, 0, false); + + // /compress-fast leaves the stored count estimate-derived... + chat.compressFast(); + expect(chat.isLastPromptTokenCountEstimated()).toBe(true); + const adjustedAfterFast = chat.getLastPromptTokenCount(); + + // ...so the subsequent /compress re-estimates the history locally + // (a different scale than the fast banner) and must expose that its + // "before" number is an estimate. + const info = await chat.tryCompress('p-after-fast', true); + + expect(compressSpy.mock.calls[0][1].originalTokenCount).not.toBe( + adjustedAfterFast, + ); + expect(info.originalTokenCountIsEstimated).toBe(true); + }); + + it('reports an authoritative original count when the API count is fresh', async () => { + mockCompressionService('compressed'); + chat.setHistory([userMsg('a'), modelMsg('b')]); + chat.seedResumeTokenCounts(5000, 0, false); + + const info = await chat.tryCompress('p-authoritative-original', true); + + expect(info.originalTokenCountIsEstimated).toBe(false); + }); }); // The circuit breaker is the three-strike replacement for the old diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index c57fdfa01d7..80cc41fbd06 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -2123,6 +2123,11 @@ export class GeminiChat { customInstructions: options?.customInstructions, signal, }); + // The service owns the compression outcome; GeminiChat owns the input + // provenance. Expose it so UIs can mark estimated banner numbers + // instead of presenting cross-path scale changes as lost context + // (#9309). + info.originalTokenCountIsEstimated = originalTokenCountIsEstimated; if (info.compressionStatus === CompressionStatus.COMPRESSED && newHistory) { // ChatCompressionService owns provenance. Keep a conservative fallback @@ -2208,6 +2213,7 @@ export class GeminiChat { info: { originalTokenCount: apiBaseline, newTokenCount: apiBaseline, + originalTokenCountIsEstimated: this.promptCountIsEstimateDerived(), compressionStatus: CompressionStatus.NOOP, }, }; @@ -2226,6 +2232,7 @@ export class GeminiChat { const info: ChatCompressionInfo = { originalTokenCount: apiBaseline, newTokenCount: adjustedTokenCount, + originalTokenCountIsEstimated: baselineIsEstimated, newTokenCountIsEstimated: true, compressionStatus: CompressionStatus.COMPRESSED, triggerReason: 'manual', diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 1c7c593854d..09e826c3ed9 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -393,6 +393,15 @@ export type CompactionTriggerReason = export interface ChatCompressionInfo { originalTokenCount: number; newTokenCount: number; + /** + * Whether originalTokenCount came from a local estimate rather than an + * API-reported prompt count. The two compression paths measure on + * different scales (see #9309): /compress-fast anchors on the last + * API-reported prompt count (system prompt + tools + history) while a + * later /compress re-estimates history-only once the stored count is + * estimate-derived, so UIs must not present the numbers as one chain. + */ + originalTokenCountIsEstimated?: boolean; /** Whether newTokenCount ultimately came from a local estimate. */ newTokenCountIsEstimated?: boolean; compressionStatus: CompressionStatus; From 6039fcf43f274e7244265ad0c4032a266350dcb3 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 21 Aug 2026 01:04:49 +0800 Subject: [PATCH 2/4] fix(cli): mark estimated counts in the ACP auto-compaction notice The ACP session auto-compaction diagnostic interpolated raw token counts and ignored the #9309 provenance flags, so estimated figures reached ACP clients unmarked. Route both counts through the same '~'-prefix formatting as the TUI notice and cover it with an asymmetric-flag regression test. Also harden the marker tests per review: asymmetric estimation flags in compressCommand/useGeminiStream fixtures so a swapped flag-argument mutation is detectable, an executionMode:'acp' case for the /compress stream_messages branch, and a null-event ChatCompressed case pinning the 'unknown' fallback. --- .../acp-integration/session/Session.test.ts | 31 ++++++++++++ .../src/acp-integration/session/Session.ts | 13 ++++- .../src/ui/commands/compressCommand.test.ts | 49 +++++++++++++++++-- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 37 +++++++++++++- 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 7d030f26dac..dc66cbd4639 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -11043,6 +11043,37 @@ describe('Session', () => { }); }); + it('marks estimated compression counts in the ACP notice (#9309)', async () => { + mockGeminiClient.tryCompressChat.mockResolvedValueOnce({ + originalTokenCount: 26600, + newTokenCount: 14500, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: false, + compressionStatus: core.CompressionStatus.COMPRESSED, + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: + 'IMPORTANT: This conversation approached the input token limit for qwen3-code-plus. ' + + 'A compressed context will be sent for future messages (compressed from: ~26600 to 14500 tokens).', + }, + }, + }); + }); + it('continues sending when automatic compression fails', async () => { mockGeminiClient.tryCompressChat.mockRejectedValueOnce( new Error('compression rate limited'), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 73e1fd40cc3..663cac5a7af 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -6470,11 +6470,20 @@ export class Session implements SessionContext { const warningSuffix = compressed.warning ? `\n⚠️ ${compressed.warning}` : ''; + // Estimated counts (#9309) get a '~' prefix so the notice doesn't + // read as an API-reported figure on a different scale than a later + // banner. + const formatCount = (count?: number, isEstimated?: boolean) => + count === undefined + ? 'unknown' + : isEstimated + ? `~${count}` + : String(count); compressionDiagnostic = `IMPORTANT: This conversation ${reasonClause}. ` + `A compressed context will be sent for future messages (compressed from: ` + - `${compressed.originalTokenCount ?? 'unknown'} to ` + - `${compressed.newTokenCount ?? 'unknown'} tokens).` + + `${formatCount(compressed.originalTokenCount, compressed.originalTokenCountIsEstimated)} to ` + + `${formatCount(compressed.newTokenCount, compressed.newTokenCountIsEstimated)} tokens).` + warningSuffix; } } catch (compressionError) { diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index d7bb4fb9083..6231e93fa02 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -138,11 +138,12 @@ describe('compressCommand', () => { // API-reported baseline), so the compression item must carry per-side // provenance for the renderer to mark estimated numbers. it('should pass token-count provenance to the compression item (interactive)', async () => { + // Asymmetric flags so a swapped provenance assignment is detectable. mockTryCompressChat.mockResolvedValue({ originalTokenCount: 200, newTokenCount: 100, originalTokenCountIsEstimated: true, - newTokenCountIsEstimated: true, + newTokenCountIsEstimated: false, compressionStatus: CompressionStatus.COMPRESSED, } satisfies ChatCompressionInfo); @@ -158,7 +159,7 @@ describe('compressCommand', () => { newTokenCount: 100, compressionKind: 'summarize', originalTokenCountIsEstimated: true, - newTokenCountIsEstimated: true, + newTokenCountIsEstimated: false, }, }, expect.any(Number), @@ -166,11 +167,13 @@ describe('compressCommand', () => { }); it('should mark estimated counts in the non-interactive message', async () => { + // Asymmetric flags mirror the real post-/compress-fast scenario and catch + // a swapped flag-argument mutation at the formatTokenCount call sites. mockTryCompressChat.mockResolvedValue({ originalTokenCount: 200, newTokenCount: 100, originalTokenCountIsEstimated: true, - newTokenCountIsEstimated: true, + newTokenCountIsEstimated: false, compressionStatus: CompressionStatus.COMPRESSED, } satisfies ChatCompressionInfo); @@ -191,10 +194,48 @@ describe('compressCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Context compressed (~200 -> ~100).', + content: 'Context compressed (~200 -> 100).', }); }); + it('should mark estimated counts in the ACP stream_messages branch', async () => { + // Asymmetric flags mirror the real post-/compress-fast scenario and catch + // a swapped flag-argument mutation at the ACP formatTokenCount site. + mockTryCompressChat.mockResolvedValue({ + originalTokenCount: 200, + newTokenCount: 100, + originalTokenCountIsEstimated: true, + newTokenCountIsEstimated: false, + compressionStatus: CompressionStatus.COMPRESSED, + } satisfies ChatCompressionInfo); + + const ctx = createMockCommandContext({ + executionMode: 'acp', + services: { + config: { + getGeminiClient: () => + ({ + tryCompressChat: mockTryCompressChat, + }) as unknown as GeminiClient, + }, + }, + }); + + const result = await compressCommand.action!(ctx, ''); + + expect(result?.type).toBe('stream_messages'); + const messages = []; + if (result?.type === 'stream_messages') { + for await (const message of result.messages) { + messages.push(message); + } + } + expect(messages).toEqual([ + { messageType: 'info', content: 'Compressing context...' }, + { messageType: 'info', content: 'Context compressed (~200 -> 100).' }, + ]); + }); + it('should add an error message if tryCompressChat throws', async () => { const error = new Error('Compression failed'); mockTryCompressChat.mockRejectedValue(error); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index aef06caff59..28965827bb0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -10999,8 +10999,10 @@ describe('useGeminiStream', () => { value: { originalTokenCount: 100, newTokenCount: 50, + // Asymmetric flags so a swapped flag-argument mutation in + // formatCount is detectable. originalTokenCountIsEstimated: true, - newTokenCountIsEstimated: true, + newTokenCountIsEstimated: false, }, }; yield { @@ -11020,7 +11022,38 @@ describe('useGeminiStream', () => { .filter((item) => item.type === 'info'); expect(infoItems).toEqual([ expect.objectContaining({ - text: expect.stringContaining('compressed from: ~100 to ~50 tokens'), + text: expect.stringContaining('compressed from: ~100 to 50 tokens'), + }), + ]); + }); + + it('renders unknown counts when the auto-compaction event value is null', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.ChatCompressed, + value: null, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderTestHook(); + await act(async () => { + await result.current.submitQuery('test null compression event'); + }); + + const infoItems = mockAddItem.mock.calls + .map(([item]) => item as HistoryItem) + .filter((item) => item.type === 'info'); + expect(infoItems).toEqual([ + expect.objectContaining({ + text: expect.stringContaining( + 'compressed from: unknown to unknown tokens', + ), }), ]); }); From f460259fad681115ecf5d1af4991cf9ce8864af0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 04:04:59 +0800 Subject: [PATCH 3/4] fix(core): derive compression provenance from the selected count source The estimated flag published on compression banners was computed from the stored count's provenance or the mere presence of an override, not from the count source selected for the attempt: - Auto-compaction / hard-tier rescue: a previously API-reported stored count forced the flag false even though the attempt publishes the precomputed effective count, which always folds in locally estimated parts (pending user message, previous output). - Reactive overflow: a limit/config/default fallback passed as the override forced the flag false whenever the provider did not report actualTokens. Both paths omitted the `~` marker and presented projected or fallback values as API-reported counts (review on #9568). Derive the flag from the selected source: a caller-precomputed effective count is always estimated, and the override now carries explicit provenance ({ count, isEstimated }) so only a provider-reported actualTokens override marks the count authoritative. --- packages/core/src/core/geminiChat.test.ts | 115 +++++++++++++++++++++- packages/core/src/core/geminiChat.ts | 63 +++++++++--- 2 files changed, 159 insertions(+), 19 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 486de38b424..d7a899aec81 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -3252,7 +3252,15 @@ describe('GeminiChat', async () => { } expect(compressSpy).toHaveBeenCalledTimes(1); - expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(123_456); + // The seeded authoritative count is the baseline of this attempt's + // effective count (seed + locally-estimated pending message), so the + // published number is the effective count, not the bare seed. + expect( + compressSpy.mock.calls[0][1].originalTokenCount, + ).toBeGreaterThanOrEqual(123_456); + expect( + compressSpy.mock.calls[0][1].precomputedEffectiveTokens, + ).toBeGreaterThanOrEqual(123_456); }); it('yields a COMPRESSED stream event as the first event after auto-compression succeeds', async () => { @@ -3628,6 +3636,12 @@ describe('GeminiChat', async () => { ).mock.calls[1]![0]; expect(secondRequest.contents).toEqual(expectedRequestContents); expect(events[0]?.type).toBe(StreamEventType.COMPRESSED); + // The overflow message reports the actual token count (135000), so the + // published original count is provider-authoritative — no `~` marker. + expect( + (events[0] as { type: StreamEventType; info: ChatCompressionInfo }).info + .originalTokenCountIsEstimated, + ).toBe(false); expect(events[1]?.type).toBe(StreamEventType.RETRY); expect(events[1]).not.toHaveProperty('retryInfo'); expect( @@ -3674,12 +3688,23 @@ describe('GeminiChat', async () => { { message: 'latest' }, 'prompt-id-reactive-limit-only', ); - for await (const _ of stream) { - /* consume */ + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); } expect(compressSpy).toHaveBeenCalledTimes(2); expect(compressSpy.mock.calls[1][1].originalTokenCount).toBe(128_000); + // The overflow message carries no actual token count, so the published + // original count is the parsed limit — a projection that must keep + // the `~` estimated marker. + const compressedEvent = events.find( + (event) => event.type === StreamEventType.COMPRESSED, + ); + expect( + (compressedEvent as { info: ChatCompressionInfo }).info + .originalTokenCountIsEstimated, + ).toBe(true); }); it('uses the configured context window when reactive overflow has no token counts', async () => { @@ -3719,12 +3744,22 @@ describe('GeminiChat', async () => { { message: 'latest' }, 'prompt-id-reactive-window-fallback', ); - for await (const _ of stream) { - /* consume */ + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); } expect(compressSpy).toHaveBeenCalledTimes(2); expect(compressSpy.mock.calls[1][1].originalTokenCount).toBe(262_144); + // Neither actual nor limit tokens parsed — the configured window is a + // fallback projection and must keep the `~` estimated marker. + const compressedEvent = events.find( + (event) => event.type === StreamEventType.COMPRESSED, + ); + expect( + (compressedEvent as { info: ChatCompressionInfo }).info + .originalTokenCountIsEstimated, + ).toBe(true); }); it('does not attempt reactive compression more than once per send', async () => { @@ -15577,6 +15612,76 @@ describe('GeminiChat', async () => { expect(info.originalTokenCountIsEstimated).toBe(false); }); + + it('marks the precomputed effective count as estimated even when the stored API count is authoritative', async () => { + // Normal/hard-rescue auto-compaction: the send path publishes the + // precomputed effective count (stored baseline + locally estimated + // pending message / previous output). That projection must carry the + // `~` marker even though the stored baseline came from the API + // (review probe on #9568). + const compressSpy = mockCompressionService('compressed'); + chat.setHistory([userMsg('a'), modelMsg('b')]); + chat.seedResumeTokenCounts(5000, 0, false); + + const info = await chat.tryCompress( + 'p-precomputed-effective-estimated', + true, + undefined, + { + precomputedEffectiveTokens: 6200, + pendingUserMessage: userMsg('next'), + trigger: 'auto', + }, + ); + + expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(6200); + expect(info.originalTokenCountIsEstimated).toBe(true); + }); + + it('keeps a fallback override estimated while publishing its count', async () => { + // Reactive overflow without a provider-reported actual count: the + // limit/config/default fallback override is a projection and must + // keep the `~` marker. + const compressSpy = mockCompressionService('compressed'); + chat.setHistory([userMsg('a'), modelMsg('b')]); + chat.seedResumeTokenCounts(5000, 0, false); + + const info = await chat.tryCompress( + 'p-override-fallback-estimated', + true, + undefined, + { + originalTokenCountOverride: { count: 128_000, isEstimated: true }, + precomputedEffectiveTokens: 128_000, + trigger: 'auto', + }, + ); + + expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(128_000); + expect(info.originalTokenCountIsEstimated).toBe(true); + }); + + it('treats a provider-reported actualTokens override as authoritative', async () => { + // Reactive overflow whose error message reports the actual token + // count: only this override variant may drop the `~` marker. + const compressSpy = mockCompressionService('compressed'); + chat.setHistory([userMsg('a'), modelMsg('b')]); + chat.seedResumeTokenCounts(5000, 0, false); + + const info = await chat.tryCompress( + 'p-override-actual-authoritative', + true, + undefined, + { + originalTokenCountOverride: { count: 135_000, isEstimated: false }, + precomputedEffectiveTokens: 135_000, + trigger: 'auto', + }, + ); + + expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(135_000); + expect(info.originalTokenCountIsEstimated).toBe(false); + }); }); // The circuit breaker is the three-strike replacement for the old diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 80cc41fbd06..fefe024272a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -451,7 +451,17 @@ export interface GeminiChatSendOptions { } interface TryCompressOptions { - originalTokenCountOverride?: number; + /** + * Explicit original token count for this attempt, with its provenance. + * Only a provider-reported count (e.g. `actualTokens` parsed from a + * context-overflow error) may claim `isEstimated: false`; limit/config/ + * default fallbacks must carry `isEstimated: true` so UIs mark them + * instead of presenting them as API-reported counts. + */ + originalTokenCountOverride?: { + count: number; + isEstimated: boolean; + }; trigger?: CompactTrigger; /** * Pending user message about to be sent. Threaded through to the @@ -2091,19 +2101,36 @@ export class GeminiChat { signal?: AbortSignal, options?: TryCompressOptions, ): Promise { + const originalTokenCountOverride = options?.originalTokenCountOverride; + // Provenance follows the count source selected for THIS attempt, not + // which inputs merely happen to be present: + // - an override is authoritative only when it carries a provider-reported + // count (reactive overflow `actualTokens`); limit/config/default + // fallbacks stay estimated; + // - a caller-precomputed effective count (auto-compaction / hard-tier + // rescue) always folds in locally estimated parts (pending user + // message, previous output), so it stays estimated even when the + // stored baseline came from the API; + // - otherwise the count is the stored lastPromptTokenCount and inherits + // the provenance tracked for it. const originalTokenCountIsEstimated = - options?.originalTokenCountOverride === undefined && - this.promptCountIsEstimateDerived(); - const originalTokenCount = originalTokenCountIsEstimated - ? (options?.precomputedEffectiveTokens ?? - estimateContentTokens( - options?.pendingUserMessage - ? [...this.getHistoryShallow(true), options.pendingUserMessage] - : this.getHistoryShallow(true), - resolveSlimmingConfig(this.config.getChatCompression()) - .imageTokenEstimate, - )) - : (options?.originalTokenCountOverride ?? this.lastPromptTokenCount); + originalTokenCountOverride !== undefined + ? originalTokenCountOverride.isEstimated + : options?.precomputedEffectiveTokens !== undefined || + this.promptCountIsEstimateDerived(); + const originalTokenCount = + originalTokenCountOverride !== undefined + ? originalTokenCountOverride.count + : originalTokenCountIsEstimated + ? (options?.precomputedEffectiveTokens ?? + estimateContentTokens( + options?.pendingUserMessage + ? [...this.getHistoryShallow(true), options.pendingUserMessage] + : this.getHistoryShallow(true), + resolveSlimmingConfig(this.config.getChatCompression()) + .imageTokenEstimate, + )) + : this.lastPromptTokenCount; debugLogger.debug( `[compaction] token-count provenance: prompt_id=${promptId}, ` + `originalTokenCount=${originalTokenCount}, ` + @@ -3189,6 +3216,11 @@ export class GeminiChat { if (contextOverflow.isExceeded) { if (!exactRoute && !reactiveCompressionAttempted) { reactiveCompressionAttempted = true; + // Only the provider-reported actual count is authoritative. + // Limit/config/default fallbacks are projections and must + // keep the estimated marker in compression banners. + const reactiveOriginalTokenCountIsEstimated = + contextOverflow.actualTokens === undefined; const reactiveOriginalTokenCount = contextOverflow.actualTokens ?? contextOverflow.limitTokens ?? @@ -3203,7 +3235,10 @@ export class GeminiChat { true, params.config?.abortSignal, { - originalTokenCountOverride: reactiveOriginalTokenCount, + originalTokenCountOverride: { + count: reactiveOriginalTokenCount, + isEstimated: reactiveOriginalTokenCountIsEstimated, + }, precomputedEffectiveTokens: reactiveOriginalTokenCount, requestGenerationConfig: params.config, trigger: 'auto', From 97a6ab6be0111b51d2ab3e1b1705b4eb0400caaf Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 23 Aug 2026 05:37:30 +0800 Subject: [PATCH 4/4] test(core): pin send-path effective token counts exactly (#9309) --- packages/core/src/core/geminiChat.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index d7a899aec81..7c7afc4c30f 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -3254,13 +3254,15 @@ describe('GeminiChat', async () => { expect(compressSpy).toHaveBeenCalledTimes(1); // The seeded authoritative count is the baseline of this attempt's // effective count (seed + locally-estimated pending message), so the - // published number is the effective count, not the bare seed. - expect( - compressSpy.mock.calls[0][1].originalTokenCount, - ).toBeGreaterThanOrEqual(123_456); - expect( - compressSpy.mock.calls[0][1].precomputedEffectiveTokens, - ).toBeGreaterThanOrEqual(123_456); + // published number is the effective count, not the bare seed. Pin the + // exact deterministic value — seed 123,456 + char/4 estimate of the + // pending 'go' message (2 chars -> ceil(2/4) = 1 token), with + // lastOutputTokenCount still 0 — so over-counting regressions on the + // send path are caught too, not just under-counting. + expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(123_457); + expect(compressSpy.mock.calls[0][1].precomputedEffectiveTokens).toBe( + 123_457, + ); }); it('yields a COMPRESSED stream event as the first event after auto-compression succeeds', async () => {