From 8e65b845fc6c25c4f5f0ec7c3488551e4c7955e8 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 20 Aug 2026 13:21:39 +0800 Subject: [PATCH 1/9] fix(core): guard compression request admission --- packages/core/src/core/llm-chat.test.ts | 27 +++ packages/core/src/core/turn.ts | 6 +- .../services/chatCompressionService.test.ts | 222 ++++++++++++++++-- .../src/services/chatCompressionService.ts | 108 +++++++-- .../services/compactionInputSlimming.test.ts | 13 + .../src/services/compactionInputSlimming.ts | 7 +- 6 files changed, 347 insertions(+), 36 deletions(-) diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index 390c8680115..dad167b8471 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -16729,6 +16729,33 @@ describe('LlmChat', async () => { expect(compressSpy.mock.calls[0][1].consecutiveFailures).toBe(1); }); + it('counts an input-too-large admission rejection as a compression failure', async () => { + const compressSpy = vi + .spyOn(ChatCompressionService.prototype, 'compress') + .mockResolvedValueOnce({ + newHistory: null, + info: { + originalTokenCount: 1_000, + newTokenCount: 1_000, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + }, + }) + .mockResolvedValueOnce({ + newHistory: null, + info: { + originalTokenCount: 0, + newTokenCount: 0, + compressionStatus: CompressionStatus.NOOP, + }, + }); + + await chat.tryCompress('input-too-large'); + await chat.tryCompress('after-input-too-large'); + + expect(compressSpy.mock.calls[1][1].consecutiveFailures).toBe(1); + }); + it('forwards force=true to the compression service', async () => { const compressSpy = mockCompressionService('compressed'); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index fd992624794..957a2eb8b22 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -394,6 +394,9 @@ export enum CompressionStatus { * apart from model output quality failures. */ COMPRESSION_FAILED_API_ERROR, + + /** The compression input could not leave enough room for a usable summary. */ + COMPRESSION_FAILED_INPUT_TOO_LARGE, } export function isCompressionFailureStatus( @@ -404,7 +407,8 @@ export function isCompressionFailureStatus( status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR || status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY || status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED || - status === CompressionStatus.COMPRESSION_FAILED_API_ERROR + status === CompressionStatus.COMPRESSION_FAILED_API_ERROR || + status === CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE ); } diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index e36bd91539b..0e1c5a32633 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -1361,7 +1361,7 @@ describe('ChatCompressionService', () => { ); vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ model: 'gemini-pro', - contextWindowSize: 6_000, + contextWindowSize: 10_000, } as unknown as ReturnType); const debug = vi.fn(); ( @@ -3161,6 +3161,43 @@ describe('ChatCompressionService.compress cache sharing', () => { expect(coldSpy).not.toHaveBeenCalled(); }); + it('does not let a stale provider anchor approve a larger current-route payload', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'original request' }] }, + { role: 'model', parts: [{ text: 'previous response' }] }, + ]; + const { chat, config, generateText } = makeFixture({ + history, + contextWindowSize: 50_000, + lastPromptTokenCount: 1_000, + }); + vi.mocked(chat.getGenerationConfig).mockReturnValue({ + systemInstruction: 'current-route-system'.repeat(8_000), + tools, + }); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'cold summary', + usage: { + promptTokenCount: 42_000, + candidatesTokenCount: 500, + totalTokenCount: 42_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 1_000, + }); + + expect(generateText).not.toHaveBeenCalled(); + expect(coldSpy).toHaveBeenCalledTimes(1); + }); + it('skips cache sharing when the chat has no provider token-count anchor', async () => { const { chat, config, generateText } = makeFixture({ lastPromptTokenCount: 0, @@ -4927,6 +4964,163 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi }); }); +describe('issue #9455: compression request admission', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeFixture(history: Content[], contextWindowSize: number) { + const getHistory = vi.fn().mockReturnValue(history); + const generateText = vi.fn(); + const chat = { + getHistory, + getHistoryShallow: getHistory, + getLastPromptTokenCount: vi.fn().mockReturnValue(0), + isLastPromptTokenCountEstimated: vi.fn().mockReturnValue(false), + getLastOutputTokenCount: vi.fn().mockReturnValue(0), + } as unknown as GeminiChat; + const config = { + getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), + getBaseLlmClient: vi + .fn() + .mockReturnValue({ generateText } as unknown as BaseLlmClient), + getContentGeneratorConfig: vi.fn().mockReturnValue({ contextWindowSize }), + getHookSystem: vi.fn().mockReturnValue(undefined), + getModel: () => 'test-model', + getCompactionModel: vi.fn().mockReturnValue('test-model'), + getAllConfiguredModels: vi.fn().mockReturnValue([]), + getClearContextOnIdle: vi.fn().mockReturnValue({ + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 1, + toolResultsTotalCharsThreshold: 500_000, + }), + getApprovalMode: () => ApprovalMode.DEFAULT, + getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', + } as unknown as Config; + return { chat, config, generateText }; + } + + it('uses bounded tool-result slimming before sending an oversized cold request', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep the original user intent' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-call', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-call', + name: 'run_shell_command', + response: { output: 'x'.repeat(240_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-call', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-call', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 40_000, + candidatesTokenCount: 500, + totalTokenCount: 40_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 65_000, + }); + + expect(coldSpy).toHaveBeenCalledTimes(1); + const contents = coldSpy.mock.calls[0]![1].contents as Content[]; + const serialized = JSON.stringify(contents); + expect(serialized).toContain('keep the original user intent'); + expect(serialized).toContain('latest context'); + expect(serialized).toContain('old-call'); + expect(serialized).toContain('recent-call'); + expect(serialized).toContain('[Old tool result content cleared]'); + expect(serialized).toContain('recent output'); + expect(serialized).not.toContain('x'.repeat(100)); + }); + + it('fails locally when an irreducible cold request cannot leave usable output room', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'x'.repeat(240_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config, generateText } = makeFixture(history, 50_000); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'should not be sent', + usage: { + promptTokenCount: 61_000, + candidatesTokenCount: 1, + totalTokenCount: 61_001, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 60_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(result.info.warning).toMatch(/input too large/i); + expect(generateText).not.toHaveBeenCalled(); + expect(coldSpy).not.toHaveBeenCalled(); + }); +}); + // Regression tests for https://github.com/QwenLM/qwen-code/issues/7960 // The compression side-query used to always request a fixed // maxOutputTokens=COMPACT_MAX_OUTPUT_TOKENS (20K). On a small-window @@ -5161,12 +5355,10 @@ describe('issue #7960: compression side-query output budget vs small windows', ( expect(capturedMaxOutputTokens).toBe(COMPACT_MAX_OUTPUT_TOKENS); }); - it('rejects any output at a floored budget of 1 instead of persisting a degenerate summary', async () => { - // When the slimmed estimate already fills the window the budget floors - // at 1. A 1-token cap cannot hold a usable summary, so the single token - // the model emits is definitionally truncated and must be dropped — - // persisting it would replace the entire history with a 1-token fragment - // while resetting the failure breaker. + it('rejects locally instead of sending with a floored output budget', async () => { + // When the slimmed estimate already fills the window, the old path + // floored maxOutputTokens at 1 and sent a request that could not produce + // a usable summary. Admission must stop locally before that request. // 257,900 chars (~64.5K tokens) puts the estimate inside the narrow // floor band where estimate >= window - margin (budget floors at 1) yet // estimate + 1 still fits the window, so the backend accepts the request @@ -5184,18 +5376,16 @@ describe('issue #7960: compression side-query output budget vs small windows', ( consecutiveFailures: 0, originalTokenCount: 65_000, }); - expect(capturedMaxOutputTokens).toBe(1); + expect(capturedMaxOutputTokens).toBeUndefined(); expect(result.info.compressionStatus).toBe( - CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, ); expect(result.newHistory).toBeNull(); }); - it('rejects a floored-budget fragment even when usage metadata is missing', async () => { - // Same floor band as above, but the provider omits usage so the output - // count is a local estimate. The floor regime must drop estimates too: - // no complete summary can exist at a 1-token cap, so the estimator - // false-positive rationale for the 20K threshold cannot apply here. + it('rejects locally before provider usage metadata can matter', async () => { + // Same floor band as above. The provider's usage behavior is irrelevant + // because an input that cannot leave usable output room is never sent. vi.mocked(mockChat.getHistory).mockReturnValue([ { role: 'user', parts: [{ text: 'x'.repeat(257_900) }] }, { role: 'model', parts: [{ text: 'ok' }] }, @@ -5209,9 +5399,9 @@ describe('issue #7960: compression side-query output budget vs small windows', ( consecutiveFailures: 0, originalTokenCount: 65_000, }); - expect(capturedMaxOutputTokens).toBe(1); + expect(capturedMaxOutputTokens).toBeUndefined(); expect(result.info.compressionStatus).toBe( - CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, ); expect(result.newHistory).toBeNull(); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index c4d2d1ffebc..3277e7e63a6 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -43,6 +43,7 @@ import { stripAnalysisBlock, type SubagentSnapshot, } from './postCompactAttachments.js'; +import { microcompactHistory } from './microcompaction/microcompact.js'; const debugLogger = createDebugLogger('COMPRESSION'); @@ -67,6 +68,7 @@ export const COMPACT_MAX_OUTPUT_TOKENS = 20_000; * rejects the request with a 400 that propagates to the caller. */ export const COMPACTION_BUDGET_SAFETY_MARGIN = 1_024; +const MIN_COMPACTION_OUTPUT_TOKENS = 1_024; /** * Output budget for the compression side-query: the fixed ceiling clamped to @@ -75,11 +77,11 @@ export const COMPACTION_BUDGET_SAFETY_MARGIN = 1_024; * generating, so on small-window deployments (e.g. vLLM with a reduced * max_model_len) an unclamped ceiling can push the request over the window * and the backend rejects it with a 400 before the model runs - * (https://github.com/QwenLM/qwen-code/issues/7960). Floored at 1 so - * `maxOutputTokens` stays provider-valid even when the estimate already - * fills the window — the request itself may still be rejected when the - * prompt alone leaves no room. The estimate runs on the already-slimmed - * history, so stripped media is no longer counted. + * (https://github.com/QwenLM/qwen-code/issues/7960). The pure calculation is + * floored at 1 so it always returns a provider-valid value; the caller's + * admission check rejects requests that cannot leave a usable output budget. + * The estimate runs on the already-slimmed history, so stripped media is no + * longer counted. * * The main send path enforces the same `prompt + max_tokens <= window` * invariant via clampOutputTokensToWindow in core/tokenLimits.ts, with @@ -702,7 +704,7 @@ export class ChatCompressionService { // output count against it. let coldOutputBudget = COMPACT_MAX_OUTPUT_TOKENS; const runColdCompression = () => { - const slim = getColdInput(); + let slim = getColdInput(); if ( slim.stats.imagesStripped > 0 || slim.stats.documentsStripped > 0 || @@ -716,12 +718,54 @@ export class ChatCompressionService { `${slim.stats.textPartsTruncated} text part(s) in side-query payload`, ); } + const directiveTokenCount = Math.ceil( + COMPRESSION_REQUEST_DIRECTIVE.length / CHARS_PER_TOKEN, + ); + let coldRequestInputTokens = getColdInputEstimate() + directiveTokenCount; + if ( + coldRequestInputTokens + + COMPACTION_BUDGET_SAFETY_MARGIN + + MIN_COMPACTION_OUTPUT_TOKENS > + budgetWindow + ) { + const reduced = microcompactHistory( + slim.slimmedHistory, + null, + config.getClearContextOnIdle?.() ?? {}, + { force: true }, + ); + if (reduced.history !== slim.slimmedHistory) { + coldInput = { ...slim, slimmedHistory: reduced.history }; + cachedColdInputEstimate = undefined; + slim = coldInput; + coldRequestInputTokens = getColdInputEstimate() + directiveTokenCount; + config + .getDebugLogger() + .debug( + `[chat-compression] microcompacted ${reduced.meta?.toolsCleared ?? 0} ` + + `old tool result(s) before cold request admission`, + ); + } + } + if ( + coldRequestInputTokens + + COMPACTION_BUDGET_SAFETY_MARGIN + + MIN_COMPACTION_OUTPUT_TOKENS > + budgetWindow + ) { + compactionWarning = + `Compression input too large: estimated input ` + + `${coldRequestInputTokens.toLocaleString()} tokens cannot leave ` + + `${MIN_COMPACTION_OUTPUT_TOKENS.toLocaleString()} usable output tokens ` + + `within the ${budgetWindow.toLocaleString()}-token context window.`; + config.getDebugLogger().warn(`[chat-compression] ${compactionWarning}`); + return undefined; + } // Clamp the output budget to the receiving model's remaining window so // `prompt + max_tokens <= window` holds even on small-window // deployments (issue #7960). coldOutputBudget = computeCompactionOutputBudget( - getColdInputEstimate() + - Math.ceil(COMPRESSION_REQUEST_DIRECTIVE.length / CHARS_PER_TOKEN), + coldRequestInputTokens, budgetWindow, ); if (coldOutputBudget < COMPACT_MAX_OUTPUT_TOKENS) { @@ -789,6 +833,27 @@ export class ChatCompressionService { const sharedDirectiveTokenCount = Math.ceil( sharedRequestText.length / CHARS_PER_TOKEN, ); + const sharedGenerationConfig = { + ...(chat.getGenerationConfig?.() ?? {}), + ...opts.requestGenerationConfig, + }; + const sharedRouteOverheadTokenEstimate = Math.ceil( + JSON.stringify({ + systemInstruction: sharedGenerationConfig.systemInstruction, + tools: sharedGenerationConfig.tools, + }).length / CHARS_PER_TOKEN, + ); + const sharedCurrentRouteTokenEstimate = + estimateContentTokens( + sideQueryHistory, + slimmingConfig.imageTokenEstimate, + ) + + sharedDirectiveTokenCount + + sharedRouteOverheadTokenEstimate; + const sharedAdmissionTokenCount = Math.max( + sharedPromptTokenCount + sharedDirectiveTokenCount, + sharedCurrentRouteTokenEstimate, + ); const usesMainModel = effectiveCompactionModel === config.getModel(); const providerSupportsCacheSharing = supportsCompressionCacheSharing(config); @@ -801,10 +866,7 @@ export class ChatCompressionService { (chat.getLastPromptTokenCount?.() ?? 0) > 0 && chat.isLastPromptTokenCountEstimated?.() !== true; const sharedRequestFits = - sharedPromptTokenCount + - sharedDirectiveTokenCount + - COMPACT_MAX_OUTPUT_TOKENS <= - contextLimit; + sharedAdmissionTokenCount + COMPACT_MAX_OUTPUT_TOKENS <= contextLimit; const canShareCache = usesMainModel && providerSupportsCacheSharing && @@ -825,18 +887,16 @@ export class ChatCompressionService { : !hasProviderTokenCount ? 'no provider-reported token-count anchor' : !sharedRequestFits - ? `shared request exceeds context window: prompt=${sharedPromptTokenCount}, ` + - `directive=${sharedDirectiveTokenCount}, reserve=${COMPACT_MAX_OUTPUT_TOKENS}, ` + + ? `shared request exceeds context window: admission=${sharedAdmissionTokenCount}, ` + + `provider=${sharedPromptTokenCount}, currentRoute=${sharedCurrentRouteTokenEstimate}, ` + + `reserve=${COMPACT_MAX_OUTPUT_TOKENS}, ` + `window=${contextLimit}` : 'payload-overflow recovery ships the slimmed cold path only'; debugLogger.debug(`[compaction] skipping cache sharing: ${reason}`); } if (canShareCache) { try { - const generationConfig = { - ...chat.getGenerationConfig(), - ...opts.requestGenerationConfig, - }; + const generationConfig = { ...sharedGenerationConfig }; const mainSystemInstruction = generationConfig.systemInstruction; delete generationConfig.systemInstruction; delete generationConfig.abortSignal; @@ -935,6 +995,18 @@ export class ChatCompressionService { }; } } + if (!summaryResult) { + return { + newHistory: null, + info: { + originalTokenCount, + newTokenCount: originalTokenCount, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ...(compactionWarning && { warning: compactionWarning }), + }, + }; + } const summary = summaryResult.text; // Check the PROCESSED summary: postProcessSummary strips // blocks, so a response that is ONLY ... (no diff --git a/packages/core/src/services/compactionInputSlimming.test.ts b/packages/core/src/services/compactionInputSlimming.test.ts index 019c5d89807..953439aad02 100644 --- a/packages/core/src/services/compactionInputSlimming.test.ts +++ b/packages/core/src/services/compactionInputSlimming.test.ts @@ -223,6 +223,19 @@ describe('compactionInputSlimming', () => { expect(estimatePartChars({ text: 'hello' }, 1600)).toBe(5); }); + it('counts text and a co-located thought signature', () => { + expect( + estimatePartChars( + { + text: 'reasoning', + thought: true, + thoughtSignature: 'signed-reasoning', + }, + 1600, + ), + ).toBe('reasoning'.length + 'signed-reasoning'.length); + }); + it('uses fixed budget for inlineData regardless of size', () => { const huge = 'A'.repeat(1_000_000); const expected = 1600 * 4; diff --git a/packages/core/src/services/compactionInputSlimming.ts b/packages/core/src/services/compactionInputSlimming.ts index 0cd3fe4584d..95aa8bb9200 100644 --- a/packages/core/src/services/compactionInputSlimming.ts +++ b/packages/core/src/services/compactionInputSlimming.ts @@ -214,7 +214,12 @@ export function estimatePartChars( return imageTokenEstimate * TOKEN_TO_CHAR_RATIO; } if (typeof part.text === 'string') { - return part.text.length; + return ( + part.text.length + + (typeof part.thoughtSignature === 'string' + ? part.thoughtSignature.length + : 0) + ); } // Tool results in qwen-code carry media on `functionResponse.parts` // (an extension to the @google/genai schema; see From 4c6b5a021d65895d0642699ef5e21b37fccb9d22 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 20 Aug 2026 16:27:00 +0800 Subject: [PATCH 2/9] fix(core): preserve context during compression admission Keep managed-memory reads and shared failure semantics intact while bounding cold-request admission work. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../acp-integration/session/Session.test.ts | 3 +- packages/core/src/core/turn.test.ts | 53 +-- .../services/chatCompressionService.test.ts | 334 +++++++++++++++++ .../src/services/chatCompressionService.ts | 350 +++++++++++------- 4 files changed, 569 insertions(+), 171 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 58d73b29086..40de9a43515 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -35527,8 +35527,9 @@ describe('Session', () => { it.each([ core.CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, core.CompressionStatus.COMPRESSION_FAILED_API_ERROR, + core.CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, ])( - 'does not count a failed Guard compression status %s or block later automatic work', + 'does not count failed Guard compression status %s or block later automatic work', async (compressionStatus) => { rebuildSessionWithGuard(); installPendingTodoTool(); diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 98849de2012..79620d82721 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -56,46 +56,23 @@ vi.mock('../utils/errorReporting', () => ({ })); describe('isCompressionFailureStatus', () => { - it('treats each compression failure status as failed', () => { - expect( - isCompressionFailureStatus( - CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, - ), - ).toBe(true); - expect( - isCompressionFailureStatus( - CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, - ), - ).toBe(true); - expect( - isCompressionFailureStatus( - CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, - ), - ).toBe(true); - expect( - isCompressionFailureStatus( - CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, - ), - ).toBe(true); - expect( - isCompressionFailureStatus( - CompressionStatus.COMPRESSION_FAILED_API_ERROR, - ), - ).toBe(true); + it.each([ + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, + CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, + CompressionStatus.COMPRESSION_FAILED_API_ERROR, + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ])('classifies %s as a compression failure', (status) => { + expect(isCompressionFailureStatus(status)).toBe(true); }); - it('keeps API errors distinct from other compression failure statuses', () => { - expect(CompressionStatus.COMPRESSION_FAILED_API_ERROR).not.toBe( - CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, - ); - expect(CompressionStatus.COMPRESSION_FAILED_API_ERROR).not.toBe( - CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, - ); - expect(isCompressionFailureStatus(CompressionStatus.COMPRESSED)).toBe( - false, - ); - expect(isCompressionFailureStatus(CompressionStatus.NOOP)).toBe(false); - }); + it.each([CompressionStatus.COMPRESSED, CompressionStatus.NOOP])( + 'does not classify %s as a compression failure', + (status) => { + expect(isCompressionFailureStatus(status)).toBe(false); + }, + ); }); describe('findRepeatedDuplicateProviderToolCall', () => { diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 0e1c5a32633..d16adf1962d 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -4975,6 +4975,7 @@ describe('issue #9455: compression request admission', () => { const chat = { getHistory, getHistoryShallow: getHistory, + getGenerationConfig: vi.fn().mockReturnValue({}), getLastPromptTokenCount: vi.fn().mockReturnValue(0), isLastPromptTokenCountEstimated: vi.fn().mockReturnValue(false), getLastOutputTokenCount: vi.fn().mockReturnValue(0), @@ -4995,6 +4996,7 @@ describe('issue #9455: compression request admission', () => { toolResultsNumToKeep: 1, toolResultsTotalCharsThreshold: 500_000, }), + getProjectRoot: () => '/tmp/test-workspace', getApprovalMode: () => ApprovalMode.DEFAULT, getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), getTargetDir: () => '/tmp/test-workspace', @@ -5087,6 +5089,312 @@ describe('issue #9455: compression request admission', () => { expect(serialized).not.toContain('x'.repeat(100)); }); + it('preserves managed-memory reads while reducing cold request input', async () => { + const managedMemoryMarker = 'managed-memory-marker'; + const history: Content[] = [ + { role: 'user', parts: [{ text: 'load memory' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'memory-call', + name: 'read_file', + args: { + file_path: '/tmp/test-workspace/.qwen/team-memory/MEMORY.md', + }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'memory-call', + name: 'read_file', + response: { output: managedMemoryMarker }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: 'x'.repeat(240_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 40_000, + candidatesTokenCount: 500, + totalTokenCount: 40_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 65_000, + }); + + expect(coldSpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(coldSpy.mock.calls[0]![1].contents)).toContain( + managedMemoryMarker, + ); + }); + + it('uses fixed minimal retention for admission microcompaction', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep the original intent' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: 'x'.repeat(240_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + vi.mocked(config.getClearContextOnIdle).mockReturnValue({ + toolResultsNumToKeep: 50, + }); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 40_000, + candidatesTokenCount: 500, + totalTokenCount: 40_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 65_000, + }); + + expect(coldSpy).toHaveBeenCalledTimes(1); + const serialized = JSON.stringify(coldSpy.mock.calls[0]![1].contents); + expect(serialized).toContain('[Old tool result content cleared]'); + expect(serialized).not.toContain('x'.repeat(100)); + }); + + it('keeps a distinct compaction model when reduced input fits its window', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep the original intent' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: 'x'.repeat(240_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 200_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 50_000 }, + ] as never[]); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 40_000, + candidatesTokenCount: 500, + totalTokenCount: 40_500, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 65_000, + }); + + expect(coldSpy).toHaveBeenCalledWith( + config, + expect.objectContaining({ model: 'compact-model' }), + ); + expect(result.info.warning).toBeUndefined(); + }); + + it('does not serialize shared-route config when cache sharing is impossible', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'context' }] }, + { role: 'model', parts: [{ text: 'response' }] }, + ]; + const { chat, config } = makeFixture(history, 200_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 200_000 }, + ] as never[]); + const toJSON = vi.fn().mockReturnValue([]); + vi.mocked(chat.getGenerationConfig).mockReturnValue({ + tools: { toJSON }, + } as never); + vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 1_000, + candidatesTokenCount: 500, + totalTokenCount: 1_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 1_000, + }); + + expect(toJSON).not.toHaveBeenCalled(); + }); + it('fails locally when an irreducible cold request cannot leave usable output room', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(240_000) }] }, @@ -5119,6 +5427,32 @@ describe('issue #9455: compression request admission', () => { expect(generateText).not.toHaveBeenCalled(); expect(coldSpy).not.toHaveBeenCalled(); }); + + it('does not fire PreCompact when an irreducible request cannot be sent', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'x'.repeat(240_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + const firePreCompactEvent = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getHookSystem).mockReturnValue({ + firePreCompactEvent, + } as never); + vi.spyOn(sideQueryModule, 'runSideQuery'); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 60_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(firePreCompactEvent).not.toHaveBeenCalled(); + }); }); // Regression tests for https://github.com/QwenLM/qwen-code/issues/7960 diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 3277e7e63a6..f0d8e8173e6 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -24,6 +24,7 @@ import { logChatCompression } from '../telemetry/loggers.js'; import { makeChatCompressionEvent } from '../telemetry/types.js'; import { PreCompactTrigger, PostCompactTrigger } from '../hooks/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { isManagedMemoryPath } from '../memory/paths.js'; import { estimateContentChars, resolveCompactionTuning, @@ -555,41 +556,6 @@ export class ChatCompressionService { }; } - // Fire PreCompact hook before compression begins. Pass any user-supplied - // `/compress` instructions so hook scripts can read / log / amend them - // via `hookSpecificOutput.additionalContext`. The aggregator concatenates - // additionalContext across all hooks with '\n' separators. - let hookExtraInstructions = ''; - const hookSystem = config.getHookSystem(); - if (hookSystem) { - const preCompactTrigger = - compactTrigger === 'manual' - ? PreCompactTrigger.Manual - : PreCompactTrigger.Auto; - try { - const result = await hookSystem.firePreCompactEvent( - preCompactTrigger, - opts.customInstructions ?? '', - signal, - ); - // `getAdditionalContext()` sanitises (`<`/`>` → `<`/`>`) so a - // hook can't inject XML structure into the summary prompt. Mirrors - // every other call-site in this repo (toolHookTriggers, agent.ts, - // client.ts) — keep it consistent. - const merged = result?.getAdditionalContext(); - if (merged && merged.trim().length > 0) { - // Cap like the user-text path: an unbounded hook payload would - // otherwise bypass MAX_COMPRESS_INSTRUCTIONS_CHARS and inflate the - // side-query prompt past a recoverable size. - hookExtraInstructions = merged - .trim() - .slice(0, MAX_HOOK_INSTRUCTIONS_CHARS); - } - } catch (err) { - config.getDebugLogger().warn(`PreCompact hook failed: ${err}`); - } - } - // A tool result is still pending when automatic compaction runs before // sendMessageStream commits the current user turn to chat history. Include // it in the side-query so Anthropic-compatible providers see it immediately @@ -634,6 +600,146 @@ export class ChatCompressionService { ); return coldInput; }; + let cachedColdHistoryEstimate: number | undefined; + const getColdHistoryEstimate = () => + (cachedColdHistoryEstimate ??= estimateContentTokens( + getColdInput().slimmedHistory, + slimmingConfig.imageTokenEstimate, + )); + const compressionDirectiveTokenCount = Math.ceil( + COMPRESSION_REQUEST_DIRECTIVE.length / CHARS_PER_TOKEN, + ); + const projectRoot = + config.getProjectRoot?.() ?? config.getTargetDir?.() ?? process.cwd(); + const targetDir = config.getTargetDir?.() ?? projectRoot; + const reduceColdInputForAdmission = () => { + const slim = getColdInput(); + const reduced = microcompactHistory( + slim.slimmedHistory, + null, + { toolResultsNumToKeep: 1 }, + { + force: true, + preserveReadFileResult: (filePath) => + isManagedMemoryPath(filePath, projectRoot, targetDir), + }, + ); + if (reduced.history === slim.slimmedHistory) { + return false; + } + coldInput = { ...slim, slimmedHistory: reduced.history }; + cachedColdHistoryEstimate = undefined; + config + .getDebugLogger() + .debug( + `[chat-compression] microcompacted ${reduced.meta?.toolsCleared ?? 0} ` + + `old tool result(s) before cold request admission`, + ); + return true; + }; + const estimateColdRequestInput = (systemPrompt: string) => + getColdHistoryEstimate() + + Math.ceil(systemPrompt.length / CHARS_PER_TOKEN) + + compressionDirectiveTokenCount; + const coldRequestCannotFit = ( + inputTokens: number, + receivingWindow: number, + ) => + inputTokens + + COMPACTION_BUDGET_SAFETY_MARGIN + + MIN_COMPACTION_OUTPUT_TOKENS > + receivingWindow; + const buildInputTooLargeWarning = ( + inputTokens: number, + receivingWindow: number, + ) => + `Compression input too large: estimated input ` + + `${inputTokens.toLocaleString()} tokens cannot leave ` + + `${MIN_COMPACTION_OUTPUT_TOKENS.toLocaleString()} usable output tokens ` + + `within the ${receivingWindow.toLocaleString()}-token context window.`; + let effectiveCompactionModel = + config.getCompactionModel?.() ?? config.getModel(); + let compactionWarning: string | undefined; + const providerSupportsCacheSharing = + supportsCompressionCacheSharing(config); + const hasProviderTokenCount = + (chat.getLastPromptTokenCount?.() ?? 0) > 0 && + chat.isLastPromptTokenCountEstimated?.() !== true; + const canAttemptSharedRequestBeforeHook = + effectiveCompactionModel === config.getModel() && + providerSupportsCacheSharing && + hasProviderTokenCount; + + // Do not fire side-effecting hooks for an input that cannot fit even with + // zero hook output. Hook output can only add prompt text, never make this + // minimum payload smaller. Skip this cold-path work while a cache-sharing + // request is still possible; that path deliberately preserves the full + // history and may succeed without any slimming. + if (!canAttemptSharedRequestBeforeHook) { + const preHookSystemInstruction = buildCompressionSystemPrompt( + opts.customInstructions, + '', + ); + let preHookInputTokens = estimateColdRequestInput( + preHookSystemInstruction, + ); + if (coldRequestCannotFit(preHookInputTokens, contextLimit)) { + reduceColdInputForAdmission(); + preHookInputTokens = estimateColdRequestInput(preHookSystemInstruction); + if (coldRequestCannotFit(preHookInputTokens, contextLimit)) { + const warning = buildInputTooLargeWarning( + preHookInputTokens, + contextLimit, + ); + config.getDebugLogger().warn(`[chat-compression] ${warning}`); + return { + newHistory: null, + info: { + originalTokenCount, + newTokenCount: originalTokenCount, + compressionStatus: + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + warning, + }, + }; + } + } + } + + // Fire PreCompact hook before compression begins. Pass any user-supplied + // `/compress` instructions so hook scripts can read / log / amend them + // via `hookSpecificOutput.additionalContext`. The aggregator concatenates + // additionalContext across all hooks with '\n' separators. + let hookExtraInstructions = ''; + const hookSystem = config.getHookSystem(); + if (hookSystem) { + const preCompactTrigger = + compactTrigger === 'manual' + ? PreCompactTrigger.Manual + : PreCompactTrigger.Auto; + try { + const result = await hookSystem.firePreCompactEvent( + preCompactTrigger, + opts.customInstructions ?? '', + signal, + ); + // `getAdditionalContext()` sanitises (`<`/`>` → `<`/`>`) so a + // hook can't inject XML structure into the summary prompt. Mirrors + // every other call-site in this repo (toolHookTriggers, agent.ts, + // client.ts) — keep it consistent. + const merged = result?.getAdditionalContext(); + if (merged && merged.trim().length > 0) { + // Cap like the user-text path: an unbounded hook payload would + // otherwise bypass MAX_COMPRESS_INSTRUCTIONS_CHARS and inflate the + // side-query prompt past a recoverable size. + hookExtraInstructions = merged + .trim() + .slice(0, MAX_HOOK_INSTRUCTIONS_CHARS); + } + } catch (err) { + config.getDebugLogger().warn(`PreCompact hook failed: ${err}`); + } + } // Hoist the system prompt so the guard can include it in the estimate. const systemInstruction = buildCompressionSystemPrompt( @@ -645,21 +751,15 @@ export class ChatCompressionService { // slimmed payload, fall back to the main model for this compression only. // Coalesce to the main model so an undefined getCompactionModel() (e.g. // validation failure) never leaks to the fast model via resolveDefaultModel. - let effectiveCompactionModel = - config.getCompactionModel?.() ?? config.getModel(); - let compactionWarning: string | undefined; // Shared estimate of the slimmed side-query payload (history + system - // instruction), memoized and lazy: the cache-sharing path must not pay - // for slimming. The compaction-model guard adds the output reserve as - // its third term; the budget clamp adds the directive — keeping the - // leading terms in one place so the two checks cannot drift. - let cachedColdInputEstimate: number | undefined; + // instruction), memoized and lazy: the cache-sharing path must not pay for + // slimming. The compaction-model guard adds the output reserve, while the + // pre-hook and final admission checks add the directive, safety margin, + // and minimum output reserve. Keeping the shared terms here prevents the + // three checks from drifting. const getColdInputEstimate = () => - (cachedColdInputEstimate ??= - estimateContentTokens( - getColdInput().slimmedHistory, - slimmingConfig.imageTokenEstimate, - ) + Math.ceil(systemInstruction.length / CHARS_PER_TOKEN)); + getColdHistoryEstimate() + + Math.ceil(systemInstruction.length / CHARS_PER_TOKEN); // Window the output budget clamps against: the window of the model that // actually receives the side-query. Defaults to the main model's window; // switched below to a distinct compaction model's window when the guard @@ -678,18 +778,25 @@ export class ChatCompressionService { const window = entry?.contextWindowSize; // Include the system prompt and the output reserve: providers check // prompt + max_tokens <= window, so all three terms count. - const slimmedTokenEstimate = + let slimmedTokenEstimate = getColdInputEstimate() + COMPACT_MAX_OUTPUT_TOKENS; if (window && window > 0 && slimmedTokenEstimate > window) { - compactionWarning = - `Compaction model "${resolved.modelId}" context window ` + - `(${window.toLocaleString()} tokens) is too small for the current ` + - `payload (~${slimmedTokenEstimate.toLocaleString()} tokens); ` + - `using the main model for this compression.`; - config - .getDebugLogger() - .warn(`[chat-compression] ${compactionWarning}`); - effectiveCompactionModel = config.getModel(); + reduceColdInputForAdmission(); + slimmedTokenEstimate = + getColdInputEstimate() + COMPACT_MAX_OUTPUT_TOKENS; + if (slimmedTokenEstimate > window) { + compactionWarning = + `Compaction model "${resolved.modelId}" context window ` + + `(${window.toLocaleString()} tokens) is too small for the current ` + + `payload (~${slimmedTokenEstimate.toLocaleString()} tokens); ` + + `using the main model for this compression.`; + config + .getDebugLogger() + .warn(`[chat-compression] ${compactionWarning}`); + effectiveCompactionModel = config.getModel(); + } else { + budgetWindow = window; + } } else if (window && window > 0) { budgetWindow = window; } @@ -718,46 +825,19 @@ export class ChatCompressionService { `${slim.stats.textPartsTruncated} text part(s) in side-query payload`, ); } - const directiveTokenCount = Math.ceil( - COMPRESSION_REQUEST_DIRECTIVE.length / CHARS_PER_TOKEN, - ); - let coldRequestInputTokens = getColdInputEstimate() + directiveTokenCount; - if ( - coldRequestInputTokens + - COMPACTION_BUDGET_SAFETY_MARGIN + - MIN_COMPACTION_OUTPUT_TOKENS > - budgetWindow - ) { - const reduced = microcompactHistory( - slim.slimmedHistory, - null, - config.getClearContextOnIdle?.() ?? {}, - { force: true }, - ); - if (reduced.history !== slim.slimmedHistory) { - coldInput = { ...slim, slimmedHistory: reduced.history }; - cachedColdInputEstimate = undefined; - slim = coldInput; - coldRequestInputTokens = getColdInputEstimate() + directiveTokenCount; - config - .getDebugLogger() - .debug( - `[chat-compression] microcompacted ${reduced.meta?.toolsCleared ?? 0} ` + - `old tool result(s) before cold request admission`, - ); - } + let coldRequestInputTokens = + getColdInputEstimate() + compressionDirectiveTokenCount; + if (coldRequestCannotFit(coldRequestInputTokens, budgetWindow)) { + reduceColdInputForAdmission(); + slim = getColdInput(); + coldRequestInputTokens = + getColdInputEstimate() + compressionDirectiveTokenCount; } - if ( - coldRequestInputTokens + - COMPACTION_BUDGET_SAFETY_MARGIN + - MIN_COMPACTION_OUTPUT_TOKENS > - budgetWindow - ) { - compactionWarning = - `Compression input too large: estimated input ` + - `${coldRequestInputTokens.toLocaleString()} tokens cannot leave ` + - `${MIN_COMPACTION_OUTPUT_TOKENS.toLocaleString()} usable output tokens ` + - `within the ${budgetWindow.toLocaleString()}-token context window.`; + if (coldRequestCannotFit(coldRequestInputTokens, budgetWindow)) { + compactionWarning = buildInputTooLargeWarning( + coldRequestInputTokens, + budgetWindow, + ); config.getDebugLogger().warn(`[chat-compression] ${compactionWarning}`); return undefined; } @@ -823,50 +903,56 @@ export class ChatCompressionService { let summaryResult: GenerateTextResult | undefined; let usedCacheSharing = false; - const sharedRequestText = - `${systemInstruction}\n\n` + - 'Do not call tools; tool execution is disabled for this request. ' + - COMPRESSION_REQUEST_DIRECTIVE; + let sharedRequestText = ''; const sharedPromptTokenCount = opts.precomputedEffectiveTokens ?? originalTokenCount + (chat.getLastOutputTokenCount?.() ?? 0); - const sharedDirectiveTokenCount = Math.ceil( - sharedRequestText.length / CHARS_PER_TOKEN, - ); - const sharedGenerationConfig = { - ...(chat.getGenerationConfig?.() ?? {}), - ...opts.requestGenerationConfig, - }; - const sharedRouteOverheadTokenEstimate = Math.ceil( - JSON.stringify({ - systemInstruction: sharedGenerationConfig.systemInstruction, - tools: sharedGenerationConfig.tools, - }).length / CHARS_PER_TOKEN, - ); - const sharedCurrentRouteTokenEstimate = - estimateContentTokens( - sideQueryHistory, - slimmingConfig.imageTokenEstimate, - ) + - sharedDirectiveTokenCount + - sharedRouteOverheadTokenEstimate; - const sharedAdmissionTokenCount = Math.max( - sharedPromptTokenCount + sharedDirectiveTokenCount, - sharedCurrentRouteTokenEstimate, - ); const usesMainModel = effectiveCompactionModel === config.getModel(); - const providerSupportsCacheSharing = - supportsCompressionCacheSharing(config); // The anchor must be provider-reported, not merely non-zero: an // estimate-derived count misses the ~15-20K system/tools overhead the // shared request actually carries, so `sharedRequestFits` could approve // a request that overflows the window. Estimate-only sessions stay on // the cold path until provider usage arrives. - const hasProviderTokenCount = - (chat.getLastPromptTokenCount?.() ?? 0) > 0 && - chat.isLastPromptTokenCountEstimated?.() !== true; - const sharedRequestFits = - sharedAdmissionTokenCount + COMPACT_MAX_OUTPUT_TOKENS <= contextLimit; + let sharedGenerationConfig: GenerateContentConfig = {}; + let sharedCurrentRouteTokenEstimate = 0; + let sharedAdmissionTokenCount = 0; + let sharedRequestFits = false; + if ( + usesMainModel && + providerSupportsCacheSharing && + hasProviderTokenCount + ) { + sharedRequestText = + `${systemInstruction}\n\n` + + 'Do not call tools; tool execution is disabled for this request. ' + + COMPRESSION_REQUEST_DIRECTIVE; + const sharedDirectiveTokenCount = Math.ceil( + sharedRequestText.length / CHARS_PER_TOKEN, + ); + sharedGenerationConfig = { + ...(chat.getGenerationConfig?.() ?? {}), + ...opts.requestGenerationConfig, + }; + const sharedRouteOverheadTokenEstimate = Math.ceil( + JSON.stringify({ + systemInstruction: sharedGenerationConfig.systemInstruction, + tools: sharedGenerationConfig.tools, + }).length / CHARS_PER_TOKEN, + ); + sharedCurrentRouteTokenEstimate = + estimateContentTokens( + sideQueryHistory, + slimmingConfig.imageTokenEstimate, + ) + + sharedDirectiveTokenCount + + sharedRouteOverheadTokenEstimate; + sharedAdmissionTokenCount = Math.max( + sharedPromptTokenCount + sharedDirectiveTokenCount, + sharedCurrentRouteTokenEstimate, + ); + sharedRequestFits = + sharedAdmissionTokenCount + COMPACT_MAX_OUTPUT_TOKENS <= contextLimit; + } const canShareCache = usesMainModel && providerSupportsCacheSharing && From 715e96497ea8b710adbdf240106a3567669fd691 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 20 Aug 2026 19:03:38 +0800 Subject: [PATCH 3/9] fix(core): harden compression admission accounting Use the actual receiving window, preserve unreduced fallback input, and account for admission-cleared tokens so valid summaries are not rejected or silently degraded. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../services/chatCompressionService.test.ts | 257 +++++++++++++++++- .../src/services/chatCompressionService.ts | 119 ++++++-- .../microcompaction/microcompact.test.ts | 16 ++ .../services/microcompaction/microcompact.ts | 26 +- 4 files changed, 377 insertions(+), 41 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index d16adf1962d..890d23f3ab7 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -4966,6 +4966,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi describe('issue #9455: compression request admission', () => { afterEach(() => { + delete process.env['QWEN_MC_KEEP_RECENT']; vi.restoreAllMocks(); }); @@ -5069,7 +5070,7 @@ describe('issue #9455: compression request admission', () => { }, } as never); - await new ChatCompressionService().compress(chat, { + const result = await new ChatCompressionService().compress(chat, { promptId: 'p', force: true, config, @@ -5087,6 +5088,8 @@ describe('issue #9455: compression request admission', () => { expect(serialized).toContain('[Old tool result content cleared]'); expect(serialized).toContain('recent output'); expect(serialized).not.toContain('x'.repeat(100)); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); }); it('preserves managed-memory reads while reducing cold request input', async () => { @@ -5181,7 +5184,7 @@ describe('issue #9455: compression request admission', () => { }, } as never); - await new ChatCompressionService().compress(chat, { + const result = await new ChatCompressionService().compress(chat, { promptId: 'p', force: true, config, @@ -5193,9 +5196,91 @@ describe('issue #9455: compression request admission', () => { expect(JSON.stringify(coldSpy.mock.calls[0]![1].contents)).toContain( managedMemoryMarker, ); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); + }); + + it('clears old non-managed read_file results during admission', async () => { + const sourceMarker = 'ordinary-source-marker'; + const history: Content[] = [ + { role: 'user', parts: [{ text: 'inspect source' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'source-call', + name: 'read_file', + args: { file_path: '/tmp/test-workspace/src/main.ts' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'source-call', + name: 'read_file', + response: { output: sourceMarker.repeat(20_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 40_000, + candidatesTokenCount: 500, + totalTokenCount: 40_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 65_000, + }); + + const serialized = JSON.stringify(coldSpy.mock.calls[0]![1].contents); + expect(serialized).toContain('[Old tool result content cleared]'); + expect(serialized).not.toContain(sourceMarker); }); it('uses fixed minimal retention for admission microcompaction', async () => { + process.env['QWEN_MC_KEEP_RECENT'] = '50'; const history: Content[] = [ { role: 'user', parts: [{ text: 'keep the original intent' }] }, { @@ -5263,7 +5348,7 @@ describe('issue #9455: compression request admission', () => { }, } as never); - await new ChatCompressionService().compress(chat, { + const result = await new ChatCompressionService().compress(chat, { promptId: 'p', force: true, config, @@ -5275,6 +5360,8 @@ describe('issue #9455: compression request admission', () => { const serialized = JSON.stringify(coldSpy.mock.calls[0]![1].contents); expect(serialized).toContain('[Old tool result content cleared]'); expect(serialized).not.toContain('x'.repeat(100)); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); }); it('keeps a distinct compaction model when reduced input fits its window', async () => { @@ -5359,6 +5446,71 @@ describe('issue #9455: compression request admission', () => { expect.objectContaining({ model: 'compact-model' }), ); expect(result.info.warning).toBeUndefined(); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); + }); + + it('restores full cold input when a small compaction model falls back', async () => { + const oldToolMarker = 'full-old-tool-output'; + const history: Content[] = [ + { role: 'user', parts: [{ text: 'x'.repeat(160_000) }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: oldToolMarker.repeat(12_000) }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 262_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 32_000 }, + ] as never[]); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 100_000, + candidatesTokenCount: 500, + totalTokenCount: 100_500, + }, + } as never); + + await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 120_000, + }); + + expect(coldSpy).toHaveBeenCalledWith( + config, + expect.objectContaining({ model: 'test-model' }), + ); + const serialized = JSON.stringify(coldSpy.mock.calls[0]![1].contents); + expect(serialized).toContain(oldToolMarker); + expect(serialized).not.toContain('[Old tool result content cleared]'); }); it('does not serialize shared-route config when cache sharing is impossible', async () => { @@ -5378,13 +5530,13 @@ describe('issue #9455: compression request admission', () => { vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ text: 'summary', usage: { - promptTokenCount: 1_000, - candidatesTokenCount: 500, - totalTokenCount: 1_500, + promptTokenCount: 1_900, + candidatesTokenCount: 100, + totalTokenCount: 2_000, }, } as never); - await new ChatCompressionService().compress(chat, { + const result = await new ChatCompressionService().compress(chat, { promptId: 'p', force: true, config, @@ -5393,6 +5545,8 @@ describe('issue #9455: compression request admission', () => { }); expect(toJSON).not.toHaveBeenCalled(); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); }); it('fails locally when an irreducible cold request cannot leave usable output room', async () => { @@ -5426,6 +5580,13 @@ describe('issue #9455: compression request admission', () => { expect(result.info.warning).toMatch(/input too large/i); expect(generateText).not.toHaveBeenCalled(); expect(coldSpy).not.toHaveBeenCalled(); + expect(logChatCompression).toHaveBeenCalledWith( + config, + expect.objectContaining({ + tokens_before: 60_000, + tokens_after: 60_000, + }), + ); }); it('does not fire PreCompact when an irreducible request cannot be sent', async () => { @@ -5434,6 +5595,11 @@ describe('issue #9455: compression request admission', () => { { role: 'model', parts: [{ text: 'latest response' }] }, ]; const { chat, config } = makeFixture(history, 50_000); + vi.mocked(chat.getLastPromptTokenCount).mockReturnValue(60_000); + vi.mocked(config.getContentGeneratorConfig).mockReturnValue({ + contextWindowSize: 50_000, + authType: AuthType.USE_ANTHROPIC, + } as never); const firePreCompactEvent = vi.fn().mockResolvedValue(undefined); vi.mocked(config.getHookSystem).mockReturnValue({ firePreCompactEvent, @@ -5453,6 +5619,81 @@ describe('issue #9455: compression request admission', () => { ); expect(firePreCompactEvent).not.toHaveBeenCalled(); }); + + it('accounts for admission-cleared tokens in provider usage math', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep intent' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: 'x'.repeat(240_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 1_200, + candidatesTokenCount: 3_000, + totalTokenCount: 4_200, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 45_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); + }); }); // Regression tests for https://github.com/QwenLM/qwen-code/issues/7960 @@ -5672,7 +5913,7 @@ describe('issue #7960: compression side-query output budget vs small windows', ( { id: 'compact-model', contextWindowSize: 200_000 }, ] as never[]); vi.mocked(mockChat.getHistory).mockReturnValue([ - { role: 'user', parts: [{ text: 'x'.repeat(240_000) }] }, + { role: 'user', parts: [{ text: 'x'.repeat(255_000) }] }, { role: 'model', parts: [{ text: 'ok' }] }, ]); mockVllmBackend(200_000); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index f0d8e8173e6..c9a52b06191 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -601,6 +601,7 @@ export class ChatCompressionService { return coldInput; }; let cachedColdHistoryEstimate: number | undefined; + let admissionTokensSaved = 0; const getColdHistoryEstimate = () => (cachedColdHistoryEstimate ??= estimateContentTokens( getColdInput().slimmedHistory, @@ -620,6 +621,7 @@ export class ChatCompressionService { { toolResultsNumToKeep: 1 }, { force: true, + keepRecentOverride: 1, preserveReadFileResult: (filePath) => isManagedMemoryPath(filePath, projectRoot, targetDir), }, @@ -629,6 +631,7 @@ export class ChatCompressionService { } coldInput = { ...slim, slimmedHistory: reduced.history }; cachedColdHistoryEstimate = undefined; + admissionTokensSaved += reduced.meta?.tokensSaved ?? 0; config .getDebugLogger() .debug( @@ -660,6 +663,19 @@ export class ChatCompressionService { let effectiveCompactionModel = config.getCompactionModel?.() ?? config.getModel(); let compactionWarning: string | undefined; + const getConfiguredModelWindow = (model: string): number | undefined => { + if (model === config.getModel()) return contextLimit; + const resolved = resolveModelId(model); + if (!resolved) return undefined; + const models = resolved.authType + ? config.getAllConfiguredModels([resolved.authType]) + : config.getAllConfiguredModels(); + return models.find((entry) => entry.id === resolved.modelId) + ?.contextWindowSize; + }; + const configuredCompactionWindow = getConfiguredModelWindow( + effectiveCompactionModel, + ); const providerSupportsCacheSharing = supportsCompressionCacheSharing(config); const hasProviderTokenCount = @@ -669,29 +685,81 @@ export class ChatCompressionService { effectiveCompactionModel === config.getModel() && providerSupportsCacheSharing && hasProviderTokenCount; + const sharedPromptTokenCountBeforeHook = + opts.precomputedEffectiveTokens ?? + originalTokenCount + (chat.getLastOutputTokenCount?.() ?? 0); + const sharedRequestCouldFitBeforeHook = (() => { + if (!canAttemptSharedRequestBeforeHook) return false; + const preHookSystemInstruction = buildCompressionSystemPrompt( + opts.customInstructions, + '', + ); + const sharedRequestText = + `${preHookSystemInstruction}\n\n` + + 'Do not call tools; tool execution is disabled for this request. ' + + COMPRESSION_REQUEST_DIRECTIVE; + const sharedDirectiveTokenCount = Math.ceil( + sharedRequestText.length / CHARS_PER_TOKEN, + ); + const generationConfig = { + ...(chat.getGenerationConfig?.() ?? {}), + ...opts.requestGenerationConfig, + }; + const routeOverheadTokenEstimate = Math.ceil( + JSON.stringify({ + systemInstruction: generationConfig.systemInstruction, + tools: generationConfig.tools, + }).length / CHARS_PER_TOKEN, + ); + const currentRouteTokenEstimate = + estimateContentTokens( + sideQueryHistory, + slimmingConfig.imageTokenEstimate, + ) + + sharedDirectiveTokenCount + + routeOverheadTokenEstimate; + const admissionTokenCount = Math.max( + sharedPromptTokenCountBeforeHook + sharedDirectiveTokenCount, + currentRouteTokenEstimate, + ); + return admissionTokenCount + COMPACT_MAX_OUTPUT_TOKENS <= contextLimit; + })(); // Do not fire side-effecting hooks for an input that cannot fit even with // zero hook output. Hook output can only add prompt text, never make this // minimum payload smaller. Skip this cold-path work while a cache-sharing // request is still possible; that path deliberately preserves the full // history and may succeed without any slimming. - if (!canAttemptSharedRequestBeforeHook) { + if (!sharedRequestCouldFitBeforeHook) { const preHookSystemInstruction = buildCompressionSystemPrompt( opts.customInstructions, '', ); + const preHookReceivingWindow = Math.max( + contextLimit, + configuredCompactionWindow ?? 0, + ); let preHookInputTokens = estimateColdRequestInput( preHookSystemInstruction, ); - if (coldRequestCannotFit(preHookInputTokens, contextLimit)) { + if (coldRequestCannotFit(preHookInputTokens, preHookReceivingWindow)) { reduceColdInputForAdmission(); preHookInputTokens = estimateColdRequestInput(preHookSystemInstruction); - if (coldRequestCannotFit(preHookInputTokens, contextLimit)) { + if (coldRequestCannotFit(preHookInputTokens, preHookReceivingWindow)) { const warning = buildInputTooLargeWarning( preHookInputTokens, - contextLimit, + preHookReceivingWindow, ); config.getDebugLogger().warn(`[chat-compression] ${warning}`); + logChatCompression( + config, + makeChatCompressionEvent({ + tokens_before: originalTokenCount, + tokens_after: originalTokenCount, + cache_sharing_attempted: false, + cache_sharing_used: false, + }), + ); return { newHistory: null, info: { @@ -771,16 +839,15 @@ export class ChatCompressionService { if (effectiveCompactionModel !== config.getModel()) { const resolved = resolveModelId(effectiveCompactionModel); if (resolved) { - const models = resolved.authType - ? config.getAllConfiguredModels([resolved.authType]) - : config.getAllConfiguredModels(); - const entry = models.find((m) => m.id === resolved.modelId); - const window = entry?.contextWindowSize; + const window = configuredCompactionWindow; // Include the system prompt and the output reserve: providers check // prompt + max_tokens <= window, so all three terms count. let slimmedTokenEstimate = getColdInputEstimate() + COMPACT_MAX_OUTPUT_TOKENS; if (window && window > 0 && slimmedTokenEstimate > window) { + const unreducedColdInput = coldInput; + const unreducedColdHistoryEstimate = cachedColdHistoryEstimate; + const unreducedAdmissionTokensSaved = admissionTokensSaved; reduceColdInputForAdmission(); slimmedTokenEstimate = getColdInputEstimate() + COMPACT_MAX_OUTPUT_TOKENS; @@ -794,6 +861,9 @@ export class ChatCompressionService { .getDebugLogger() .warn(`[chat-compression] ${compactionWarning}`); effectiveCompactionModel = config.getModel(); + coldInput = unreducedColdInput; + cachedColdHistoryEstimate = unreducedColdHistoryEstimate; + admissionTokensSaved = unreducedAdmissionTokensSaved; } else { budgetWindow = window; } @@ -1082,6 +1152,15 @@ export class ChatCompressionService { } } if (!summaryResult) { + logChatCompression( + config, + makeChatCompressionEvent({ + tokens_before: originalTokenCount, + tokens_after: originalTokenCount, + cache_sharing_attempted: canShareCache, + cache_sharing_used: false, + }), + ); return { newHistory: null, info: { @@ -1150,30 +1229,23 @@ export class ChatCompressionService { // fixed ceiling: since issue #7960's clamp the requested budget can sit // below COMPACT_MAX_OUTPUT_TOKENS, and output can never exceed what was // requested — comparing against the fixed ceiling would make this guard - // unreachable on every clamped request. That includes the floor regime - // (budget 1): a 1-token cap cannot hold a usable summary, so any output - // at the cap is definitionally truncated and must be dropped. + // unreachable on every clamped request. // // Local estimates instead keep the pre-clamp fixed-ceiling threshold: // unlike provider counts they can overshoot the budget purely from // estimator error (the ±30% variance the margin documents), so comparing // them against a clamped budget would convert that error into false // truncation verdicts for complete summaries. The fixed ceiling - // preserves the pre-#7960 semantics for the usage-missing path. The one - // exception is the floor regime (budget 1): no complete summary can - // exist at a 1-token cap, so the false-positive rationale cannot apply - // and estimates must be dropped there too — otherwise a provider that - // omits usage would persist a 1-token fragment as COMPRESSED. + // preserves the pre-#7960 semantics for the usage-missing path. // // TODO(finish_reason): the current `>= budget` check is a heuristic that // false-positives on legitimate summaries that happen to land exactly at // the budget. The proper signal is `finish_reason === 'length'` (OpenAI) / // `MAX_TOKENS` (Gemini), but `runSideQuery` doesn't surface it today. // Plumb it through and tighten this guard when that's available. - const truncationThreshold = - outputCountIsEstimated && coldOutputBudget > 1 - ? COMPACT_MAX_OUTPUT_TOKENS - : coldOutputBudget; + const truncationThreshold = outputCountIsEstimated + ? COMPACT_MAX_OUTPUT_TOKENS + : coldOutputBudget; if ( !usedCacheSharing && !isSummaryEmpty && @@ -1363,7 +1435,10 @@ export class ChatCompressionService { canCalculateNewTokenCount = true; const compressedHistoryTokenCount = Math.max( 0, - compressionInputTokenCount - 1000 - pendingToolResultTokenCount, + compressionInputTokenCount - + 1000 - + pendingToolResultTokenCount + + admissionTokensSaved, ); newTokenCount = Math.max( 0, diff --git a/packages/core/src/services/microcompaction/microcompact.test.ts b/packages/core/src/services/microcompaction/microcompact.test.ts index cd6c5a3a7d0..c7d2db5aece 100644 --- a/packages/core/src/services/microcompaction/microcompact.test.ts +++ b/packages/core/src/services/microcompaction/microcompact.test.ts @@ -524,6 +524,22 @@ describe('microcompactHistory', () => { ).toBe(MICROCOMPACT_CLEARED_MESSAGE); }); + it('uses an explicit keep-recent override ahead of the environment', () => { + process.env['QWEN_MC_KEEP_RECENT'] = '3'; + const history: Content[] = Array.from({ length: 4 }).flatMap((_, i) => [ + makeToolCall('read_file'), + makeToolResult('read_file', `content ${i}`), + ]); + + const result = microcompactHistory(history, twoHoursAgo, DEFAULT_SETTINGS, { + force: true, + keepRecentOverride: 1, + }); + + expect(result.meta?.keepRecent).toBe(1); + expect(result.meta?.toolsCleared).toBe(3); + }); + it.each(['0', '-2'])( 'floors integer QWEN_MC_KEEP_RECENT=%s to 1', (envValue) => { diff --git a/packages/core/src/services/microcompaction/microcompact.ts b/packages/core/src/services/microcompaction/microcompact.ts index 38feb3e0559..4456b6656ba 100644 --- a/packages/core/src/services/microcompaction/microcompact.ts +++ b/packages/core/src/services/microcompaction/microcompact.ts @@ -519,6 +519,8 @@ export interface MicrocompactOptions { sizeOnly?: boolean; pendingContent?: Content | Content[]; preserveReadFileResult?: PreserveReadFileResult; + /** Explicit caller policy that takes precedence over session/env tuning. */ + keepRecentOverride?: number; } export interface MicrocompactMeta { @@ -569,10 +571,12 @@ export function microcompactHistory( settings: ClearContextOnIdleSettings, opts?: MicrocompactOptions, ): { history: Content[]; meta?: MicrocompactMeta } { - const keepRecent = resolveKeepRecent( - process.env['QWEN_MC_KEEP_RECENT'], - settings.toolResultsNumToKeep, - ); + const keepRecent = + normalizeKeepRecent(opts?.keepRecentOverride) ?? + resolveKeepRecent( + process.env['QWEN_MC_KEEP_RECENT'], + settings.toolResultsNumToKeep, + ); let triggerReason: MicrocompactTriggerReason | undefined; let gapMs = 0; @@ -814,18 +818,18 @@ function resolveKeepRecent( envValue: string | undefined, settingsValue: number | undefined, ): number { - const normalize = (value: number | undefined): number | undefined => { - if (value === undefined || !Number.isSafeInteger(value)) return undefined; - return Math.max(1, value); - }; - if (envValue !== undefined) { const trimmed = envValue.trim(); if (/^-?\d+$/.test(trimmed)) { - const envKeep = normalize(Number(trimmed)); + const envKeep = normalizeKeepRecent(Number(trimmed)); if (envKeep !== undefined) return envKeep; } } - return normalize(settingsValue) ?? 5; + return normalizeKeepRecent(settingsValue) ?? 5; +} + +function normalizeKeepRecent(value: number | undefined): number | undefined { + if (value === undefined || !Number.isSafeInteger(value)) return undefined; + return Math.max(1, value); } From 8cd28b7eb93305ef70d7f0692dbfc92a77414901 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Thu, 20 Aug 2026 23:14:05 +0800 Subject: [PATCH 4/9] fix(core): align pre-hook compression admission Model each receiving window with its actual output reserve and pin the post-hook cold-request safety gate. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../services/chatCompressionService.test.ts | 70 +++++++++++++++++++ .../src/services/chatCompressionService.ts | 22 ++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 890d23f3ab7..df396c5a0b1 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -5620,6 +5620,76 @@ describe('issue #9455: compression request admission', () => { expect(firePreCompactEvent).not.toHaveBeenCalled(); }); + it('does not fire PreCompact when neither the main nor compaction model can admit the request', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'x'.repeat(720_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 200_000 }, + ] as never[]); + const firePreCompactEvent = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getHookSystem).mockReturnValue({ + firePreCompactEvent, + } as never); + const coldSpy = vi.spyOn(sideQueryModule, 'runSideQuery'); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 180_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(result.newHistory).toBeNull(); + expect(firePreCompactEvent).not.toHaveBeenCalled(); + expect(coldSpy).not.toHaveBeenCalled(); + }); + + it('rejects hook-expanded input at the final cold-request admission gate', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'x'.repeat(184_400) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 50_000); + const firePreCompactEvent = vi.fn().mockResolvedValue({ + getAdditionalContext: () => 'h'.repeat(MAX_HOOK_INSTRUCTIONS_CHARS), + }); + vi.mocked(config.getHookSystem).mockReturnValue({ + firePreCompactEvent, + } as never); + const coldSpy = vi.spyOn(sideQueryModule, 'runSideQuery'); + vi.mocked(logChatCompression).mockClear(); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 45_500, + }); + + expect(firePreCompactEvent).toHaveBeenCalledOnce(); + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(result.newHistory).toBeNull(); + expect(coldSpy).not.toHaveBeenCalled(); + expect(logChatCompression).toHaveBeenCalledWith( + config, + expect.objectContaining({ + tokens_before: 45_500, + tokens_after: 45_500, + }), + ); + }); + it('accounts for admission-cleared tokens in provider usage math', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'keep intent' }] }, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index c9a52b06191..270745acb89 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -735,20 +735,28 @@ export class ChatCompressionService { opts.customInstructions, '', ); - const preHookReceivingWindow = Math.max( - contextLimit, - configuredCompactionWindow ?? 0, - ); + const preHookRequestCannotFit = (inputTokens: number) => { + const compactionModelCouldFit = + effectiveCompactionModel !== config.getModel() && + (configuredCompactionWindow === undefined || + configuredCompactionWindow <= 0 || + inputTokens + COMPACT_MAX_OUTPUT_TOKENS <= + configuredCompactionWindow); + return ( + !compactionModelCouldFit && + coldRequestCannotFit(inputTokens, contextLimit) + ); + }; let preHookInputTokens = estimateColdRequestInput( preHookSystemInstruction, ); - if (coldRequestCannotFit(preHookInputTokens, preHookReceivingWindow)) { + if (preHookRequestCannotFit(preHookInputTokens)) { reduceColdInputForAdmission(); preHookInputTokens = estimateColdRequestInput(preHookSystemInstruction); - if (coldRequestCannotFit(preHookInputTokens, preHookReceivingWindow)) { + if (preHookRequestCannotFit(preHookInputTokens)) { const warning = buildInputTooLargeWarning( preHookInputTokens, - preHookReceivingWindow, + contextLimit, ); config.getDebugLogger().warn(`[chat-compression] ${warning}`); logChatCompression( From 0319845cd2d3aaab2462e504989d0f3cd26af56e Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 21 Aug 2026 03:02:54 +0800 Subject: [PATCH 5/9] fix(core): harden compression fallback handling Use conservative multilingual admission, consistent estimated accounting, and explicit side-query failures so compression degrades without corrupting context or UI state. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../acp-integration/session/Session.test.ts | 1 + packages/core/src/core/llm-chat.test.ts | 15 +- .../services/chatCompressionService.test.ts | 448 ++++++++++++++++-- .../src/services/chatCompressionService.ts | 208 ++++---- 4 files changed, 541 insertions(+), 131 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 40de9a43515..8ffff22a84e 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -35528,6 +35528,7 @@ describe('Session', () => { core.CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, core.CompressionStatus.COMPRESSION_FAILED_API_ERROR, core.CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + core.CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR, ])( 'does not count failed Guard compression status %s or block later automatic work', async (compressionStatus) => { diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index dad167b8471..84f9ac66dd4 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -4471,7 +4471,7 @@ describe('LlmChat', async () => { ); }); - it('triggers cache-sharing compaction end-to-end when a provider token count is available (R3.4)', async () => { + it('persists estimated cache-sharing compaction end-to-end (R3.4)', async () => { // Reviewer R3.4: the "forwards the pending user message" test above // mocks the service entirely, so the real cheap-gate never runs there. // Exercise the full chain here with the provider token-count anchor @@ -4479,7 +4479,7 @@ describe('LlmChat', async () => { // sendMessageStream → tryCompress → service.compress (REAL) → // cheap-gate (count-based estimate from the 172K anchor) → // splitter (real) → cache-sharing request (mocked at baseLlmClient) → - // persistence. + // estimated visible-history accounting → persistence. const largeChars = 'x'.repeat(688_000); // ~172K estimated tokens const inheritedHistory: Content[] = [ { role: 'user', parts: [{ text: largeChars }] }, @@ -4526,10 +4526,15 @@ describe('LlmChat', async () => { expect(compressed).toBeDefined(); expect( (compressed as { type: StreamEventType; info: ChatCompressionInfo }) - .info.compressionStatus, - ).toBe(CompressionStatus.COMPRESSED); + .info, + ).toEqual( + expect.objectContaining({ + compressionStatus: CompressionStatus.COMPRESSED, + newTokenCountIsEstimated: true, + }), + ); // Google GenAI uses the cache-sharing request rather than the cold side - // query, while still exercising the real splitter and accounting path. + // query, while still exercising the real splitter and local-delta path. expect(generateText).toHaveBeenCalled(); expect(coldSpy).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index df396c5a0b1..67bf86aec2e 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -19,7 +19,7 @@ import { PAYLOAD_OVERFLOW_SIDE_QUERY_TEXT_CAP, } from './chatCompressionService.js'; import type { Content } from '@google/genai'; -import { CompressionStatus } from '../core/turn.js'; +import { CompressionStatus, isCompressionFailureStatus } from '../core/turn.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { tokenLimit } from '../core/tokenLimits.js'; import type { LlmChat } from '../core/llm-chat.js'; @@ -41,6 +41,21 @@ vi.mock('../telemetry/uiTelemetry.js'); vi.mock('../core/tokenLimits.js'); vi.mock('../telemetry/loggers.js'); +function estimateUtf8AdjustedVisibleTokens(contents: Content[]): number { + let nonAsciiUtf8Bytes = 0; + let nonAsciiUtf16CodeUnits = 0; + for (const character of JSON.stringify(contents)) { + const codePoint = character.codePointAt(0)!; + if (codePoint < 0x80) continue; + nonAsciiUtf16CodeUnits += character.length; + nonAsciiUtf8Bytes += codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + } + return ( + estimateContentTokens(contents) + + Math.ceil(nonAsciiUtf8Bytes - nonAsciiUtf16CodeUnits / 4) + ); +} + describe('ChatCompressionService', () => { let service: ChatCompressionService; let mockChat: LlmChat; @@ -605,6 +620,7 @@ describe('ChatCompressionService', () => { expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.info.newTokenCount).toBe(27_450); // 28000 - (1600 - 1000) + 50 + expect(result.info.newTokenCountIsEstimated).toBe(false); expect(result.newHistory).not.toBeNull(); // postProcessSummary appends the resume trailer to the summary body, // so it's "Summary\n\n" rather than a strict equality. @@ -1347,7 +1363,7 @@ describe('ChatCompressionService', () => { expect(result.newHistory).toBeNull(); }); - it('should use estimated token count if usage metadata is missing', async () => { + it('uses the local visible-history delta if usage metadata is missing', async () => { const largeMessage = 'x'.repeat(4_000); const history: Content[] = [ { role: 'user', parts: [{ text: largeMessage }] }, @@ -1363,19 +1379,6 @@ describe('ChatCompressionService', () => { model: 'gemini-pro', contextWindowSize: 10_000, } as unknown as ReturnType); - const debug = vi.fn(); - ( - mockConfig as unknown as { - getDebugLogger: () => { - warn: ReturnType; - debug: typeof debug; - }; - } - ).getDebugLogger = () => ({ - warn: vi.fn(), - debug, - }); - const mockGenerateContent = vi.fn().mockResolvedValue({ // Well-formed snapshot: the clamped budget + local-estimate path gates // acceptance on snapshot well-formedness, so the summary must carry @@ -1399,19 +1402,16 @@ describe('ChatCompressionService', () => { expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.info.originalTokenCount).toBe(5_000); - expect(result.info.newTokenCount).toBeGreaterThan(1_000); - expect(result.info.newTokenCount).toBeLessThan(1_100); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); - expect(result.newHistory![0].parts![0].text).toContain('Summary'); - expect(debug).toHaveBeenCalledWith( - expect.stringContaining('usage metadata missing'), - ); - expect(debug).toHaveBeenCalledWith( - expect.stringContaining('API-reported non-visible remainder (1000)'), + expect(result.info.newTokenCount).toBe( + 5_000 - + estimateUtf8AdjustedVisibleTokens(history) + + estimateUtf8AdjustedVisibleTokens(result.newHistory!), ); }); - it('should reject inflated local delta if usage metadata is missing', async () => { + it('rejects a heuristically inflated summary if usage metadata is missing', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'short user message' }] }, { role: 'model', parts: [{ text: 'short model response' }] }, @@ -1420,9 +1420,8 @@ describe('ChatCompressionService', () => { ]; vi.mocked(mockChat.getHistory).mockReturnValue(history); vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800); - // Window large enough that the output budget is not clamped: on the - // clamped + usage-missing path the well-formedness guard would preempt - // the inflation check this test targets (this summary is not XML). + // Keep the output budget unclamped so the malformed-summary truncation + // guard does not preempt the token-accounting result. vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ model: 'gemini-pro', contextWindowSize: 128_000, @@ -2458,6 +2457,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { getDebugLogger: () => ({ warn, debug: vi.fn() }), getTargetDir: () => '/tmp/test-workspace', } as unknown as Config; + vi.mocked(logChatCompression).mockClear(); const result = await new ChatCompressionService().compress(mockChat, { promptId: 'p', @@ -2474,6 +2474,16 @@ describe('ChatCompressionService.compress sideQuery config', () => { expect(warn).toHaveBeenCalledWith( expect.stringContaining('truncation threshold'), ); + expect(logChatCompression).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + tokens_before: 180_000, + tokens_after: 180_000, + compression_input_token_count: 50_000, + compression_output_token_count: 20_000, + cache_sharing_used: false, + }), + ); }); }); @@ -2815,7 +2825,7 @@ describe('ChatCompressionService.compress cache sharing', () => { ]); }); - it('preserves non-visible system and tool tokens in the post-compression count', async () => { + it('preserves non-visible tokens with a shared local visible-history delta', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(40_000) }] }, { role: 'model', parts: [{ text: 'y'.repeat(40_000) }] }, @@ -2832,10 +2842,15 @@ describe('ChatCompressionService.compress cache sharing', () => { expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.info.newTokenCountIsEstimated).toBe(true); - expect(result.info.newTokenCount).toBeGreaterThan(100_000); + expect(result.newHistory).not.toBeNull(); + expect(result.info.newTokenCount).toBe( + 180_000 - + estimateUtf8AdjustedVisibleTokens(history) + + estimateUtf8AdjustedVisibleTokens(result.newHistory!), + ); }); - it('accepts a complete shared summary when thinking reaches the output cap', async () => { + it('does not mistake shared thinking at the output cap for truncation', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(40_000) }] }, { role: 'model', parts: [{ text: 'y'.repeat(40_000) }] }, @@ -2862,6 +2877,7 @@ describe('ChatCompressionService.compress cache sharing', () => { }); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); expect(coldSpy).not.toHaveBeenCalled(); }); @@ -5089,6 +5105,7 @@ describe('issue #9455: compression request admission', () => { expect(serialized).toContain('recent output'); expect(serialized).not.toContain('x'.repeat(100)); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); }); @@ -5197,6 +5214,7 @@ describe('issue #9455: compression request admission', () => { managedMemoryMarker, ); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); }); @@ -5361,6 +5379,7 @@ describe('issue #9455: compression request admission', () => { expect(serialized).toContain('[Old tool result content cleared]'); expect(serialized).not.toContain('x'.repeat(100)); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); }); @@ -5447,6 +5466,7 @@ describe('issue #9455: compression request admission', () => { ); expect(result.info.warning).toBeUndefined(); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); }); @@ -5589,6 +5609,116 @@ describe('issue #9455: compression request admission', () => { ); }); + it('rejects CJK-dense cold input that only fits under the char/4 lower bound', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: '保'.repeat(48_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 65_536); + const coldSpy = vi.spyOn(sideQueryModule, 'runSideQuery'); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 60_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(result.newHistory).toBeNull(); + expect(coldSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['Arabic', 'ش'], + ['Hebrew', 'ש'], + ['Devanagari', 'क'], + ['Thai', 'ก'], + ['supplementary CJK', '𠮷'], + ['emoji', '😀'], + ])( + 'rejects %s-dense cold input that only fits under the char/4 lower bound', + async (_script, character) => { + const history: Content[] = [ + { role: 'user', parts: [{ text: character.repeat(48_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 65_536); + const coldSpy = vi.spyOn(sideQueryModule, 'runSideQuery'); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 60_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(result.newHistory).toBeNull(); + expect(coldSpy).not.toHaveBeenCalled(); + }, + ); + + it('returns a breaker-compatible failure when the cold side-query rejects', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep intent' }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 65_536); + vi.spyOn(sideQueryModule, 'runSideQuery').mockRejectedValue( + new Error('provider unavailable'), + ); + vi.mocked(logChatCompression).mockClear(); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 60_000, + }); + + expect(result.newHistory).toBeNull(); + expect(isCompressionFailureStatus(result.info.compressionStatus)).toBe( + true, + ); + expect(result.info.newTokenCount).toBe(60_000); + expect(logChatCompression).toHaveBeenCalledWith( + config, + expect.objectContaining({ + tokens_before: 60_000, + tokens_after: 60_000, + cache_sharing_used: false, + }), + ); + }); + + it('rethrows an AbortError from the cold side-query', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep intent' }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 65_536); + const abortError = new DOMException('cancelled', 'AbortError'); + vi.spyOn(sideQueryModule, 'runSideQuery').mockRejectedValue(abortError); + + await expect( + new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 60_000, + }), + ).rejects.toBe(abortError); + }); + it('does not fire PreCompact when an irreducible request cannot be sent', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(240_000) }] }, @@ -5652,6 +5782,37 @@ describe('issue #9455: compression request admission', () => { expect(coldSpy).not.toHaveBeenCalled(); }); + it('does not fire PreCompact when a distinct model only fits without the safety margin', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'small history' }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 2_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 21_500 }, + ] as never[]); + const firePreCompactEvent = vi.fn().mockResolvedValue(undefined); + vi.mocked(config.getHookSystem).mockReturnValue({ + firePreCompactEvent, + } as never); + const coldSpy = vi.spyOn(sideQueryModule, 'runSideQuery'); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 20_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(firePreCompactEvent).not.toHaveBeenCalled(); + expect(coldSpy).not.toHaveBeenCalled(); + }); + it('rejects hook-expanded input at the final cold-request admission gate', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(184_400) }] }, @@ -5690,7 +5851,7 @@ describe('issue #9455: compression request admission', () => { ); }); - it('accounts for admission-cleared tokens in provider usage math', async () => { + it('uses one local visible-history delta after admission reduction', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'keep intent' }] }, { @@ -5758,11 +5919,183 @@ describe('issue #9455: compression request admission', () => { force: true, config, consecutiveFailures: 0, - originalTokenCount: 45_000, + originalTokenCount: 80_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); + expect(result.newHistory).not.toBeNull(); + expect(result.info.newTokenCount).toBe( + Math.max(0, 80_000 - estimateUtf8AdjustedVisibleTokens(history)) + + estimateUtf8AdjustedVisibleTokens(result.newHistory!), + ); + }); + + it('uses UTF-8-adjusted histories instead of provider admission usage', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep intent' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: '保'.repeat(60_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 200_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 32_000 }, + ] as never[]); + vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ + text: '' + 's'.repeat(39_900) + '', + usage: { + promptTokenCount: 1_200, + candidatesTokenCount: 10_000, + totalTokenCount: 11_200, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 80_000, }); expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); + expect(result.info.newTokenCount).toBe( + Math.max(0, 80_000 - estimateUtf8AdjustedVisibleTokens(history)) + + estimateUtf8AdjustedVisibleTokens(result.newHistory!), + ); + }); + + it('heuristically rejects inflation after admission reduction', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'keep intent' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'old-shell', + name: 'run_shell_command', + args: { command: 'old' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'old-shell', + name: 'run_shell_command', + response: { output: '保'.repeat(60_000) }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: 'recent output' }, + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'latest context' }] }, + ]; + const { chat, config } = makeFixture(history, 200_000); + vi.mocked(config.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(config.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 32_000 }, + ] as never[]); + vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ + text: '' + 's'.repeat(71_900) + '', + usage: { + promptTokenCount: 1_200, + candidatesTokenCount: 18_000, + totalTokenCount: 19_200, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 10_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, + ); + expect(result.info.newTokenCountIsEstimated).toBe(true); + expect(result.info.newTokenCount).toBeGreaterThan(10_000); + expect(result.newHistory).toBeNull(); }); }); @@ -5911,14 +6244,14 @@ describe('issue #7960: compression side-query output budget vs small windows', ( // The budget was actually clamped below the fixed ceiling... expect(capturedMaxOutputTokens).toBeLessThan(COMPACT_MAX_OUTPUT_TOKENS); - // ...to window - prompt - safety margin (with a 2-token tolerance for - // per-part vs combined ceil rounding between the service's estimate and - // this mock's)... + // ...to window - prompt - safety margin. The service also reserves the + // UTF-8 byte cost of non-ASCII directive characters that this ASCII-only + // mock prompt count omits. expect(capturedMaxOutputTokens).toBeLessThanOrEqual( WINDOW - capturedPromptTokens! - COMPACTION_BUDGET_SAFETY_MARGIN, ); expect(capturedMaxOutputTokens).toBeGreaterThanOrEqual( - WINDOW - capturedPromptTokens! - COMPACTION_BUDGET_SAFETY_MARGIN - 2, + WINDOW - capturedPromptTokens! - COMPACTION_BUDGET_SAFETY_MARGIN - 16, ); // ...and the request now satisfies the backend invariant. expect( @@ -6000,6 +6333,32 @@ describe('issue #7960: compression side-query output budget vs small windows', ( expect(capturedMaxOutputTokens).toBe(COMPACT_MAX_OUTPUT_TOKENS); }); + it('falls back when a distinct model cannot fit the full request plus safety and 20K output', async () => { + vi.mocked(mockConfig.getCompactionModel).mockReturnValue('compact-model'); + vi.mocked(mockConfig.getAllConfiguredModels).mockReturnValue([ + { id: 'compact-model', contextWindowSize: 21_500 }, + ] as never[]); + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'small history' }] }, + { role: 'model', parts: [{ text: 'ok' }] }, + ]); + mockVllmBackend(WINDOW); + + const result = await service.compress(mockChat, { + promptId: 'test-prompt-id', + force: true, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 100_000, + }); + + expect(result.info.compressionStatus).not.toBe( + CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, + ); + expect(capturedModel).toBe('test-model'); + expect(capturedMaxOutputTokens).toBe(COMPACT_MAX_OUTPUT_TOKENS); + }); + it('rejects locally instead of sending with a floored output budget', async () => { // When the slimmed estimate already fills the window, the old path // floored maxOutputTokens at 1 and sent a request that could not produce @@ -6036,6 +6395,7 @@ describe('issue #7960: compression side-query output budget vs small windows', ( { role: 'model', parts: [{ text: 'ok' }] }, ]); mockVllmBackend(WINDOW, true, { omitUsage: true }); + vi.mocked(logChatCompression).mockClear(); const result = await service.compress(mockChat, { promptId: 'test-prompt-id', @@ -6051,13 +6411,13 @@ describe('issue #7960: compression side-query output budget vs small windows', ( expect(result.newHistory).toBeNull(); }); - it('accepts a complete summary whose local estimate exceeds a clamped budget when usage is missing', async () => { + it('accepts a complete clamped summary when usage is missing', async () => { // The estimated branch of the truncation guard keeps the fixed 20K // ceiling precisely so estimator error on the usage-missing path cannot // drop complete summaries. ~50K history clamps the budget to ~13.5K; // a complete summary locally estimated at ~17K (between the clamped - // budget and 20K) must still be persisted. Pins the provenance split: - // comparing estimates against the clamped budget would drop it. + // budget and 20K) reaches heuristic accounting rather than being + // mislabeled as truncated. vi.mocked(mockChat.getHistory).mockReturnValue([ { role: 'user', parts: [{ text: 'x'.repeat(200_000) }] }, { role: 'model', parts: [{ text: 'ok' }] }, @@ -6078,6 +6438,7 @@ describe('issue #7960: compression side-query output budget vs small windows', ( expect(capturedMaxOutputTokens).toBeLessThan(COMPACT_MAX_OUTPUT_TOKENS); // ...yet the complete summary survives the guard. expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); expect(result.newHistory).not.toBeNull(); }); @@ -6107,6 +6468,15 @@ describe('issue #7960: compression side-query output budget vs small windows', ( CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED, ); expect(result.newHistory).toBeNull(); + expect(logChatCompression).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + tokens_before: 55_000, + tokens_after: 55_000, + compression_output_token_count: expect.any(Number), + cache_sharing_used: false, + }), + ); }); describe('computeCompactionOutputBudget', () => { diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 270745acb89..921b40756b3 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -24,6 +24,7 @@ import { logChatCompression } from '../telemetry/loggers.js'; import { makeChatCompressionEvent } from '../telemetry/types.js'; import { PreCompactTrigger, PostCompactTrigger } from '../hooks/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { isAbortError } from '../utils/errors.js'; import { isManagedMemoryPath } from '../memory/paths.js'; import { estimateContentChars, @@ -61,12 +62,10 @@ export const COMPACT_MAX_OUTPUT_TOKENS = 20_000; /** * Safety margin subtracted from the remaining window when computing the - * compression side-query's output budget. The side-query input size is a - * char/4 estimate, so this pad absorbs rounding and small per-part drift. - * It does NOT scale with the estimate: proportional tokenizer error - * (real tokenizers vary ±30% and under-count CJK-dense content) can still - * push `prompt + max_tokens` over the window, in which case the backend - * rejects the request with a 400 that propagates to the caller. + * compression side-query's output budget. The side-query input uses a + * UTF-8-adjusted estimate; this fixed pad absorbs rounding and small per-part + * drift. Provider tokenizers can still exceed a local estimate, so side-query + * failures remain a breaker-compatible compression failure. */ export const COMPACTION_BUDGET_SAFETY_MARGIN = 1_024; const MIN_COMPACTION_OUTPUT_TOKENS = 1_024; @@ -144,28 +143,46 @@ export const HARD_BUFFER = 3_000; */ export const MAX_CONSECUTIVE_FAILURES = 3; -const CJK_CHAR_TOKEN_MULTIPLIER = 1.5; -const CJK_CHAR_PATTERN = - /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/g; +function estimateNonAsciiUtf8Adjustment(text: string): number { + let utf8Bytes = 0; + let utf16CodeUnits = 0; + for (const character of text) { + const codePoint = character.codePointAt(0)!; + if (codePoint < 0x80) continue; + utf16CodeUnits += character.length; + utf8Bytes += codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + } + return Math.ceil(utf8Bytes - utf16CodeUnits / CHARS_PER_TOKEN); +} + +function estimateUtf8AdjustedTextTokens(text: string): number { + return ( + Math.ceil(text.length / CHARS_PER_TOKEN) + + estimateNonAsciiUtf8Adjustment(text) + ); +} + +function estimateUtf8AdjustedContentTokens( + contents: Content[], + imageTokenEstimate: number, +): number { + const genericEstimate = estimateContentTokens(contents, imageTokenEstimate); + return ( + genericEstimate + estimateNonAsciiUtf8Adjustment(JSON.stringify(contents)) + ); +} function estimateSummaryOutputTokens( summary: string, imageTokenEstimate: number, ): number { - const genericEstimate = estimateContentTokens( - [{ role: 'model', parts: [{ text: summary }] }], - imageTokenEstimate, + return Math.max( + estimateContentTokens( + [{ role: 'model', parts: [{ text: summary }] }], + imageTokenEstimate, + ), + estimateUtf8AdjustedTextTokens(summary), ); - const cjkCharCount = summary.match(CJK_CHAR_PATTERN)?.length ?? 0; - if (cjkCharCount === 0) { - return genericEstimate; - } - - const nonCjkCharCount = Math.max(0, summary.length - cjkCharCount); - const cjkAwareEstimate = - Math.ceil(nonCjkCharCount / CHARS_PER_TOKEN) + - Math.ceil(cjkCharCount * CJK_CHAR_TOKEN_MULTIPLIER); - return Math.max(genericEstimate, cjkAwareEstimate); } /** @@ -601,14 +618,14 @@ export class ChatCompressionService { return coldInput; }; let cachedColdHistoryEstimate: number | undefined; - let admissionTokensSaved = 0; + let coldInputReducedForAdmission = false; const getColdHistoryEstimate = () => - (cachedColdHistoryEstimate ??= estimateContentTokens( + (cachedColdHistoryEstimate ??= estimateUtf8AdjustedContentTokens( getColdInput().slimmedHistory, slimmingConfig.imageTokenEstimate, )); - const compressionDirectiveTokenCount = Math.ceil( - COMPRESSION_REQUEST_DIRECTIVE.length / CHARS_PER_TOKEN, + const compressionDirectiveTokenCount = estimateUtf8AdjustedTextTokens( + COMPRESSION_REQUEST_DIRECTIVE, ); const projectRoot = config.getProjectRoot?.() ?? config.getTargetDir?.() ?? process.cwd(); @@ -631,7 +648,7 @@ export class ChatCompressionService { } coldInput = { ...slim, slimmedHistory: reduced.history }; cachedColdHistoryEstimate = undefined; - admissionTokensSaved += reduced.meta?.tokensSaved ?? 0; + coldInputReducedForAdmission = true; config .getDebugLogger() .debug( @@ -642,7 +659,7 @@ export class ChatCompressionService { }; const estimateColdRequestInput = (systemPrompt: string) => getColdHistoryEstimate() + - Math.ceil(systemPrompt.length / CHARS_PER_TOKEN) + + estimateUtf8AdjustedTextTokens(systemPrompt) + compressionDirectiveTokenCount; const coldRequestCannotFit = ( inputTokens: number, @@ -652,6 +669,8 @@ export class ChatCompressionService { COMPACTION_BUDGET_SAFETY_MARGIN + MIN_COMPACTION_OUTPUT_TOKENS > receivingWindow; + const coldRequestWithFullOutputEstimate = (inputTokens: number) => + inputTokens + COMPACTION_BUDGET_SAFETY_MARGIN + COMPACT_MAX_OUTPUT_TOKENS; const buildInputTooLargeWarning = ( inputTokens: number, receivingWindow: number, @@ -740,7 +759,7 @@ export class ChatCompressionService { effectiveCompactionModel !== config.getModel() && (configuredCompactionWindow === undefined || configuredCompactionWindow <= 0 || - inputTokens + COMPACT_MAX_OUTPUT_TOKENS <= + coldRequestWithFullOutputEstimate(inputTokens) <= configuredCompactionWindow); return ( !compactionModelCouldFit && @@ -829,13 +848,13 @@ export class ChatCompressionService { // validation failure) never leaks to the fast model via resolveDefaultModel. // Shared estimate of the slimmed side-query payload (history + system // instruction), memoized and lazy: the cache-sharing path must not pay for - // slimming. The compaction-model guard adds the output reserve, while the - // pre-hook and final admission checks add the directive, safety margin, - // and minimum output reserve. Keeping the shared terms here prevents the - // three checks from drifting. + // slimming. The model-selection checks add the directive, safety margin, + // and full output reserve, while final admission requires the minimum + // usable output reserve. Keeping the shared terms here prevents the checks + // from drifting. const getColdInputEstimate = () => getColdHistoryEstimate() + - Math.ceil(systemInstruction.length / CHARS_PER_TOKEN); + estimateUtf8AdjustedTextTokens(systemInstruction); // Window the output budget clamps against: the window of the model that // actually receives the side-query. Defaults to the main model's window; // switched below to a distinct compaction model's window when the guard @@ -848,17 +867,19 @@ export class ChatCompressionService { const resolved = resolveModelId(effectiveCompactionModel); if (resolved) { const window = configuredCompactionWindow; - // Include the system prompt and the output reserve: providers check - // prompt + max_tokens <= window, so all three terms count. - let slimmedTokenEstimate = - getColdInputEstimate() + COMPACT_MAX_OUTPUT_TOKENS; + // Providers check prompt + max_tokens <= window, so include the + // directive, safety margin, and full output reserve. + let slimmedTokenEstimate = coldRequestWithFullOutputEstimate( + getColdInputEstimate() + compressionDirectiveTokenCount, + ); if (window && window > 0 && slimmedTokenEstimate > window) { const unreducedColdInput = coldInput; const unreducedColdHistoryEstimate = cachedColdHistoryEstimate; - const unreducedAdmissionTokensSaved = admissionTokensSaved; + const wasUnreducedForAdmission = coldInputReducedForAdmission; reduceColdInputForAdmission(); - slimmedTokenEstimate = - getColdInputEstimate() + COMPACT_MAX_OUTPUT_TOKENS; + slimmedTokenEstimate = coldRequestWithFullOutputEstimate( + getColdInputEstimate() + compressionDirectiveTokenCount, + ); if (slimmedTokenEstimate > window) { compactionWarning = `Compaction model "${resolved.modelId}" context window ` + @@ -871,7 +892,7 @@ export class ChatCompressionService { effectiveCompactionModel = config.getModel(); coldInput = unreducedColdInput; cachedColdHistoryEstimate = unreducedColdHistoryEstimate; - admissionTokensSaved = unreducedAdmissionTokensSaved; + coldInputReducedForAdmission = wasUnreducedForAdmission; } else { budgetWindow = window; } @@ -1143,12 +1164,21 @@ export class ChatCompressionService { try { summaryResult = await runColdCompression(); } catch (error) { - if (abortSignal.aborted) throw error; + if (abortSignal.aborted || isAbortError(error)) throw error; config .getDebugLogger() .warn( - `[chat-compression] compression side-query failed: ${String(error)}`, + `[chat-compression] dedicated summarizer failed: ${String(error)}`, ); + logChatCompression( + config, + makeChatCompressionEvent({ + tokens_before: originalTokenCount, + tokens_after: originalTokenCount, + cache_sharing_attempted: canShareCache, + cache_sharing_used: false, + }), + ); return { newHistory: null, info: { @@ -1223,6 +1253,18 @@ export class ChatCompressionService { `(${compressionOutputTokenCount}).`, ); } + const logCompressionResult = (tokensAfter: number) => + logChatCompression( + config, + makeChatCompressionEvent({ + tokens_before: originalTokenCount, + tokens_after: tokensAfter, + compression_input_token_count: compressionInputTokenCount, + compression_output_token_count: compressionOutputTokenCount, + cache_sharing_attempted: canShareCache, + cache_sharing_used: usedCacheSharing, + }), + ); // Defensive guard: if the dedicated side-query hit the output budget it // actually requested, the summary is likely truncated mid-content and @@ -1268,6 +1310,7 @@ export class ChatCompressionService { `dropping potentially-truncated result. This counts as a ` + `compression failure for the per-chat circuit breaker.`, ); + logCompressionResult(originalTokenCount); return { newHistory: null, info: { @@ -1307,6 +1350,7 @@ export class ChatCompressionService { `potentially-truncated result. This counts as a compression ` + `failure for the per-chat circuit breaker.`, ); + logCompressionResult(originalTokenCount); return { newHistory: null, info: { @@ -1321,6 +1365,7 @@ export class ChatCompressionService { let newTokenCount = originalTokenCount; let extraHistory: Content[] = []; let canCalculateNewTokenCount = false; + let usedEstimatedVisibleDelta = false; if (!isSummaryEmpty) { // Manual /compress has no pending functionResponse, so a trailing @@ -1410,18 +1455,16 @@ export class ChatCompressionService { ]; } - // Best-effort token math using model-reported token counts when - // available. Some OpenAI-compatible providers omit usage for the - // compression side-query; in that case, fall back to the same local - // content estimator used by the auto-compaction gate so a valid summary - // can still shrink the history instead of failing with a token-count - // error. + // Prefer comparable model-reported counts. Cache-sharing includes the + // main system/tools, admission reduction changes the sent history, and + // some providers omit usage; those paths instead use one consistent + // local estimate for both visible histories. // // The cache-sharing request also includes the main system and tools, so - // its input count cannot isolate visible history with a fixed subtraction; - // that path uses the local visible-history delta below. On the cold path, - // compressionInputTokenCount includes the entire compression - // system prompt (the instructions, ~900 tokens) PLUS + // its input count cannot isolate visible history with a fixed subtraction. + // On the unreduced cold path, compressionInputTokenCount includes the + // entire compression system prompt (the instructions, + // ~900 tokens) PLUS // the short kick-off user turn ("First, reason in your // block. Then, produce the XML.", ~20 tokens) — the // "approx. 1000 tokens" subtracted below is for that combined fixed @@ -1435,6 +1478,7 @@ export class ChatCompressionService { if ( !usedCacheSharing && !opts.requestPayloadTooLarge && + !coldInputReducedForAdmission && typeof compressionInputTokenCount === 'number' && compressionInputTokenCount > 0 && typeof compressionOutputTokenCount === 'number' && @@ -1443,10 +1487,7 @@ export class ChatCompressionService { canCalculateNewTokenCount = true; const compressedHistoryTokenCount = Math.max( 0, - compressionInputTokenCount - - 1000 - - pendingToolResultTokenCount + - admissionTokensSaved, + compressionInputTokenCount - 1000 - pendingToolResultTokenCount, ); newTokenCount = Math.max( 0, @@ -1469,11 +1510,12 @@ export class ChatCompressionService { ); newTokenCount += Math.ceil(restorationChars / CHARS_PER_TOKEN); } else { - const estimatedOriginalVisibleTokenCount = estimateContentTokens( - curatedHistory, - slimmingConfig.imageTokenEstimate, - ); - const estimatedNewVisibleTokenCount = estimateContentTokens( + const estimatedOriginalVisibleTokenCount = + estimateUtf8AdjustedContentTokens( + curatedHistory, + slimmingConfig.imageTokenEstimate, + ); + const estimatedNewVisibleTokenCount = estimateUtf8AdjustedContentTokens( extraHistory, slimmingConfig.imageTokenEstimate, ); @@ -1485,42 +1527,29 @@ export class ChatCompressionService { 0, originalTokenCount - estimatedOriginalVisibleTokenCount, ); - // Keep the API-reported system/tool/prompt remainder intact. The - // local estimator is only used for the visible conversation delta, so - // missing usage metadata cannot replace the authoritative total with - // a much smaller visible-history-only estimate. + // Preserve the existing baseline's non-visible remainder and replace + // only the visible conversation. Both sides use the same + // UTF-8-adjusted estimator; provider prompt counts and admission + // savings are intentionally excluded from this local delta. newTokenCount = estimatedNonVisibleTokenCount + estimatedNewVisibleTokenCount; canCalculateNewTokenCount = true; + usedEstimatedVisibleDelta = true; config .getDebugLogger() .debug( - `[chat-compression] ${ - usedCacheSharing - ? 'cache-sharing token accounting' - : 'usage metadata missing' - }; estimated ` + - `post-compression token count by preserving the ` + - `API-reported non-visible remainder ` + + `[chat-compression] estimated post-compression token count ` + + `by preserving the non-visible remainder ` + `(${estimatedNonVisibleTokenCount}) and replacing the ` + - `visible-history estimate (${estimatedOriginalVisibleTokenCount} -> ` + + `UTF-8-adjusted visible-history estimate ` + + `(${estimatedOriginalVisibleTokenCount} -> ` + `${estimatedNewVisibleTokenCount}).`, ); } } } - logChatCompression( - config, - makeChatCompressionEvent({ - tokens_before: originalTokenCount, - tokens_after: newTokenCount, - compression_input_token_count: compressionInputTokenCount, - compression_output_token_count: compressionOutputTokenCount, - cache_sharing_attempted: canShareCache, - cache_sharing_used: usedCacheSharing, - }), - ); + logCompressionResult(newTokenCount); if (isSummaryEmpty) { return { @@ -1542,11 +1571,16 @@ export class ChatCompressionService { }, }; } else if (newTokenCount > originalTokenCount) { + // Local visible-history deltas are heuristic rather than tokenizer + // bounds, but still prevent an estimated expansion from being persisted. return { newHistory: null, info: { originalTokenCount, newTokenCount, + ...(usedEstimatedVisibleDelta && { + newTokenCountIsEstimated: true, + }), compressionStatus: CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT, }, @@ -1579,7 +1613,7 @@ export class ChatCompressionService { info: { originalTokenCount, newTokenCount, - newTokenCountIsEstimated: true, + newTokenCountIsEstimated: usedEstimatedVisibleDelta, compressionStatus: CompressionStatus.COMPRESSED, triggerReason, ...(compactionWarning && { warning: compactionWarning }), From 875c1f8b0bb4aef7eca79d2130a5590a6b2ac5f2 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Mon, 31 Aug 2026 15:20:03 +0800 Subject: [PATCH 6/9] fix(core): align compression fallback after rebase Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/core/src/services/chatCompressionService.test.ts | 2 +- packages/core/src/services/chatCompressionService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 67bf86aec2e..021ed49b84e 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -4996,7 +4996,7 @@ describe('issue #9455: compression request admission', () => { getLastPromptTokenCount: vi.fn().mockReturnValue(0), isLastPromptTokenCountEstimated: vi.fn().mockReturnValue(false), getLastOutputTokenCount: vi.fn().mockReturnValue(0), - } as unknown as GeminiChat; + } as unknown as LlmChat; const config = { getChatCompression: vi.fn(), getAutoCompactThreshold: vi.fn(), diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 921b40756b3..876b293f16d 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -1168,7 +1168,7 @@ export class ChatCompressionService { config .getDebugLogger() .warn( - `[chat-compression] dedicated summarizer failed: ${String(error)}`, + `[chat-compression] compression side-query failed: ${String(error)}`, ); logChatCompression( config, From 758c4062aebfcbd1abf550da4af0628e27b6a961 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Tue, 1 Sep 2026 12:19:44 +0800 Subject: [PATCH 7/9] fix(core): refine compression token estimates Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- packages/core/src/core/llm-chat.test.ts | 10 +- packages/core/src/core/llm-chat.ts | 1 + .../services/chatCompressionService.test.ts | 262 +++++++++++++++++- .../src/services/chatCompressionService.ts | 63 ++++- 4 files changed, 311 insertions(+), 25 deletions(-) diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts index 84f9ac66dd4..ed41544e963 100644 --- a/packages/core/src/core/llm-chat.test.ts +++ b/packages/core/src/core/llm-chat.test.ts @@ -16982,16 +16982,24 @@ describe('LlmChat', async () => { expect(compressSpy.mock.calls[0][1].originalTokenCount).not.toBe( adjustedAfterFast, ); + expect(compressSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ originalTokenCountIsEstimated: true }), + ); expect(info.originalTokenCountIsEstimated).toBe(true); }); it('reports an authoritative original count when the API count is fresh', async () => { - mockCompressionService('compressed'); + const compressSpy = mockCompressionService('compressed'); chat.setHistory([userMsg('a'), modelMsg('b')]); chat.seedResumeTokenCounts(5000, 0, false); const info = await chat.tryCompress('p-authoritative-original', true); + expect(compressSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ originalTokenCountIsEstimated: false }), + ); expect(info.originalTokenCountIsEstimated).toBe(false); }); diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts index 29753038313..4ad13463c19 100644 --- a/packages/core/src/core/llm-chat.ts +++ b/packages/core/src/core/llm-chat.ts @@ -2392,6 +2392,7 @@ export class LlmChat { config: this.config, consecutiveFailures: this.consecutiveFailures, originalTokenCount, + originalTokenCountIsEstimated, pendingUserMessage: options?.pendingUserMessage, precomputedEffectiveTokens: options?.precomputedEffectiveTokens, requestGenerationConfig: options?.requestGenerationConfig, diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 021ed49b84e..bc4ace387d3 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -18,7 +18,7 @@ import { MAX_HOOK_INSTRUCTIONS_CHARS, PAYLOAD_OVERFLOW_SIDE_QUERY_TEXT_CAP, } from './chatCompressionService.js'; -import type { Content } from '@google/genai'; +import type { Content, Part } from '@google/genai'; import { CompressionStatus, isCompressionFailureStatus } from '../core/turn.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { tokenLimit } from '../core/tokenLimits.js'; @@ -41,18 +41,56 @@ vi.mock('../telemetry/uiTelemetry.js'); vi.mock('../core/tokenLimits.js'); vi.mock('../telemetry/loggers.js'); +function collectTextPayloads(part: Part, payloads: string[]): void { + if (part.inlineData || part.fileData) { + return; + } + if (typeof part.text === 'string') { + payloads.push(part.text); + if (typeof part.thoughtSignature === 'string') { + payloads.push(part.thoughtSignature); + } + return; + } + if (part.functionResponse) { + const output = part.functionResponse.response?.['output']; + const error = part.functionResponse.response?.['error']; + if (typeof output === 'string') { + payloads.push(output); + } else if (typeof error === 'string') { + payloads.push(error); + } + const nested = ( + part.functionResponse as typeof part.functionResponse & { + parts?: Part[]; + } + ).parts; + nested?.forEach((inner) => collectTextPayloads(inner, payloads)); + return; + } + payloads.push(JSON.stringify(part ?? {})); +} + function estimateUtf8AdjustedVisibleTokens(contents: Content[]): number { let nonAsciiUtf8Bytes = 0; let nonAsciiUtf16CodeUnits = 0; - for (const character of JSON.stringify(contents)) { - const codePoint = character.codePointAt(0)!; - if (codePoint < 0x80) continue; - nonAsciiUtf16CodeUnits += character.length; - nonAsciiUtf8Bytes += codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + const payloads: string[] = []; + for (const content of contents) { + for (const part of content.parts ?? []) { + collectTextPayloads(part, payloads); + } + } + for (const payload of payloads) { + for (const character of payload) { + const codePoint = character.codePointAt(0)!; + if (codePoint < 0x80) continue; + nonAsciiUtf16CodeUnits += character.length; + nonAsciiUtf8Bytes += codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + } } return ( estimateContentTokens(contents) + - Math.ceil(nonAsciiUtf8Bytes - nonAsciiUtf16CodeUnits / 4) + Math.ceil((nonAsciiUtf8Bytes - nonAsciiUtf16CodeUnits) / 2) ); } @@ -629,6 +667,76 @@ describe('ChatCompressionService', () => { expect(mockGetHookSystem).toHaveBeenCalled(); }); + it('preserves estimated provenance from the original token count', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + ]); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + model: 'gemini-pro', + contextWindowSize: 128_000, + } as unknown as ReturnType); + vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ + generateText: vi.fn().mockResolvedValue({ + text: 'Summary', + usage: { + promptTokenCount: 49_000, + candidatesTokenCount: 1_500, + totalTokenCount: 50_500, + }, + }), + } as unknown as BaseLlmClient); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: true, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 100_000, + originalTokenCountIsEstimated: true, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); + }); + + it('marks provider accounting with restored content as estimated', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + ]); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + model: 'gemini-pro', + contextWindowSize: 128_000, + } as unknown as ReturnType); + vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ + generateText: vi.fn().mockResolvedValue({ + text: 'Summary', + usage: { + promptTokenCount: 49_000, + candidatesTokenCount: 1_500, + totalTokenCount: 50_500, + }, + }), + } as unknown as BaseLlmClient); + vi.spyOn(postCompactModule, 'composePostCompactHistory').mockResolvedValue([ + { role: 'user', parts: [{ text: 'Summary' }] }, + { role: 'model', parts: [{ text: 'ack' }] }, + { role: 'user', parts: [{ text: '保'.repeat(19_000) }] }, + ]); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: true, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 100_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); + }); + it('does not deep-clone full history while compressing', async () => { const largeToolOutput = 'x'.repeat(1024 * 1024); const history: Content[] = [ @@ -1536,7 +1644,7 @@ describe('ChatCompressionService', () => { debug: vi.fn(), }); const mockGenerateContent = vi.fn().mockResolvedValue({ - text: '\u4e00'.repeat(Math.ceil(COMPACT_MAX_OUTPUT_TOKENS / 1.5)), + text: '\u4e00'.repeat(Math.ceil(COMPACT_MAX_OUTPUT_TOKENS / 1.25)), usage: undefined, }); vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ @@ -2850,6 +2958,39 @@ describe('ChatCompressionService.compress cache sharing', () => { ); }); + it('does not stringify inline media while estimating a visible-history delta', async () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { text: 'review this screenshot '.repeat(5_000) }, + { + inlineData: { + mimeType: 'image/png', + data: 'A'.repeat(2_000_000), + }, + }, + ], + }, + { role: 'model', parts: [{ text: 'done' }] }, + ]; + const stringifySpy = vi.spyOn(JSON, 'stringify'); + const { chat, config } = makeFixture({ history }); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 180_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(stringifySpy.mock.calls.some(([value]) => value === history)).toBe( + false, + ); + }); + it('does not mistake shared thinking at the output cap for truncation', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(40_000) }] }, @@ -5472,6 +5613,7 @@ describe('issue #9455: compression request admission', () => { it('restores full cold input when a small compaction model falls back', async () => { const oldToolMarker = 'full-old-tool-output'; + const recentToolMarker = 'full-recent-tool-output'; const history: Content[] = [ { role: 'user', parts: [{ text: 'x'.repeat(160_000) }] }, { @@ -5498,6 +5640,30 @@ describe('issue #9455: compression request admission', () => { }, ], }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'recent-shell', + name: 'run_shell_command', + args: { command: 'recent' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'recent-shell', + name: 'run_shell_command', + response: { output: recentToolMarker.repeat(6_000) }, + }, + }, + ], + }, { role: 'model', parts: [{ text: 'latest context' }] }, ]; const { chat, config } = makeFixture(history, 262_000); @@ -5516,7 +5682,7 @@ describe('issue #9455: compression request admission', () => { }, } as never); - await new ChatCompressionService().compress(chat, { + const result = await new ChatCompressionService().compress(chat, { promptId: 'p', force: true, config, @@ -5530,7 +5696,11 @@ describe('issue #9455: compression request admission', () => { ); const serialized = JSON.stringify(coldSpy.mock.calls[0]![1].contents); expect(serialized).toContain(oldToolMarker); + expect(serialized).toContain(recentToolMarker); expect(serialized).not.toContain('[Old tool result content cleared]'); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(false); + expect(result.info.newTokenCount).toBe(21_500); }); it('does not serialize shared-route config when cache sharing is impossible', async () => { @@ -5611,7 +5781,7 @@ describe('issue #9455: compression request admission', () => { it('rejects CJK-dense cold input that only fits under the char/4 lower bound', async () => { const history: Content[] = [ - { role: 'user', parts: [{ text: '保'.repeat(48_000) }] }, + { role: 'user', parts: [{ text: '保'.repeat(52_000) }] }, { role: 'model', parts: [{ text: 'latest response' }] }, ]; const { chat, config } = makeFixture(history, 65_536); @@ -5632,6 +5802,36 @@ describe('issue #9455: compression request admission', () => { expect(coldSpy).not.toHaveBeenCalled(); }); + it('accepts CJK-dense cold input that fits the calibrated estimate', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: '保'.repeat(26_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 65_536); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 33_000, + candidatesTokenCount: 500, + totalTokenCount: 33_500, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 33_000, + }); + + expect(coldSpy).toHaveBeenCalledOnce(); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); + }); + it.each([ ['Arabic', 'ش'], ['Hebrew', 'ש'], @@ -5643,7 +5843,7 @@ describe('issue #9455: compression request admission', () => { 'rejects %s-dense cold input that only fits under the char/4 lower bound', async (_script, character) => { const history: Content[] = [ - { role: 'user', parts: [{ text: character.repeat(48_000) }] }, + { role: 'user', parts: [{ text: character.repeat(100_000) }] }, { role: 'model', parts: [{ text: 'latest response' }] }, ]; const { chat, config } = makeFixture(history, 65_536); @@ -6339,7 +6539,7 @@ describe('issue #7960: compression side-query output budget vs small windows', ( { id: 'compact-model', contextWindowSize: 21_500 }, ] as never[]); vi.mocked(mockChat.getHistory).mockReturnValue([ - { role: 'user', parts: [{ text: 'small history' }] }, + { role: 'user', parts: [{ text: 'x'.repeat(12_000) }] }, { role: 'model', parts: [{ text: 'ok' }] }, ]); mockVllmBackend(WINDOW); @@ -6352,9 +6552,8 @@ describe('issue #7960: compression side-query output budget vs small windows', ( originalTokenCount: 100_000, }); - expect(result.info.compressionStatus).not.toBe( - CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, - ); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); expect(capturedModel).toBe('test-model'); expect(capturedMaxOutputTokens).toBe(COMPACT_MAX_OUTPUT_TOKENS); }); @@ -6409,6 +6608,15 @@ describe('issue #7960: compression side-query output budget vs small windows', ( CompressionStatus.COMPRESSION_FAILED_INPUT_TOO_LARGE, ); expect(result.newHistory).toBeNull(); + expect(logChatCompression).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ + tokens_before: 65_000, + tokens_after: 65_000, + cache_sharing_attempted: false, + cache_sharing_used: false, + }), + ); }); it('accepts a complete clamped summary when usage is missing', async () => { @@ -6442,6 +6650,30 @@ describe('issue #7960: compression side-query output budget vs small windows', ( expect(result.newHistory).not.toBeNull(); }); + it('accepts a complete CJK summary below the estimated truncation threshold', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'x'.repeat(200_000) }] }, + { role: 'model', parts: [{ text: 'ok' }] }, + ]); + mockVllmBackend(WINDOW, false, { + omitUsage: true, + text: '' + '保'.repeat(15_000) + '', + }); + + const result = await service.compress(mockChat, { + promptId: 'test-prompt-id', + force: true, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 50_000, + }); + + expect(capturedMaxOutputTokens).toBeLessThan(COMPACT_MAX_OUTPUT_TOKENS); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.info.newTokenCountIsEstimated).toBe(true); + expect(result.newHistory).not.toBeNull(); + }); + it('drops a clamped-cap fragment lacking a closed snapshot when usage is missing', async () => { // With a clamped budget and a local estimate the threshold comparison // cannot detect cap-hits: output never exceeds the requested budget and diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 876b293f16d..dfd36bd1604 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content, GenerateContentConfig } from '@google/genai'; +import type { Content, GenerateContentConfig, Part } from '@google/genai'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import type { GenerateTextResult } from '../core/baseLlmClient.js'; @@ -28,6 +28,7 @@ import { isAbortError } from '../utils/errors.js'; import { isManagedMemoryPath } from '../memory/paths.js'; import { estimateContentChars, + getFunctionResponseParts, resolveCompactionTuning, resolveSlimmingConfig, slimCompactionInput, @@ -143,7 +144,7 @@ export const HARD_BUFFER = 3_000; */ export const MAX_CONSECUTIVE_FAILURES = 3; -function estimateNonAsciiUtf8Adjustment(text: string): number { +function measureNonAsciiUtf8Expansion(text: string): number { let utf8Bytes = 0; let utf16CodeUnits = 0; for (const character of text) { @@ -152,7 +153,40 @@ function estimateNonAsciiUtf8Adjustment(text: string): number { utf16CodeUnits += character.length; utf8Bytes += codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; } - return Math.ceil(utf8Bytes - utf16CodeUnits / CHARS_PER_TOKEN); + return utf8Bytes - utf16CodeUnits; +} + +function estimateNonAsciiUtf8Adjustment(text: string): number { + return Math.ceil(measureNonAsciiUtf8Expansion(text) / 2); +} + +function measurePartUtf8Expansion(part: Part): number { + if (part.inlineData || part.fileData) { + return 0; + } + if (typeof part.text === 'string') { + return ( + measureNonAsciiUtf8Expansion(part.text) + + (typeof part.thoughtSignature === 'string' + ? measureNonAsciiUtf8Expansion(part.thoughtSignature) + : 0) + ); + } + if (part.functionResponse) { + const output = part.functionResponse.response?.['output']; + const error = part.functionResponse.response?.['error']; + let expansion = + typeof output === 'string' + ? measureNonAsciiUtf8Expansion(output) + : typeof error === 'string' + ? measureNonAsciiUtf8Expansion(error) + : 0; + for (const nestedPart of getFunctionResponseParts(part) ?? []) { + expansion += measurePartUtf8Expansion(nestedPart); + } + return expansion; + } + return measureNonAsciiUtf8Expansion(JSON.stringify(part ?? {})); } function estimateUtf8AdjustedTextTokens(text: string): number { @@ -167,9 +201,13 @@ function estimateUtf8AdjustedContentTokens( imageTokenEstimate: number, ): number { const genericEstimate = estimateContentTokens(contents, imageTokenEstimate); - return ( - genericEstimate + estimateNonAsciiUtf8Adjustment(JSON.stringify(contents)) - ); + let utf8Expansion = 0; + for (const content of contents) { + for (const part of content.parts ?? []) { + utf8Expansion += measurePartUtf8Expansion(part); + } + } + return genericEstimate + Math.ceil(utf8Expansion / 2); } function estimateSummaryOutputTokens( @@ -295,6 +333,8 @@ export interface CompressOptions { * the service does not read or write any global telemetry. */ originalTokenCount: number; + /** Whether originalTokenCount contains locally estimated components. */ + originalTokenCountIsEstimated?: boolean; /** * Hook trigger to report for this compression. `force=true` bypasses the * threshold gate but does not always mean the user manually requested @@ -1366,6 +1406,7 @@ export class ChatCompressionService { let extraHistory: Content[] = []; let canCalculateNewTokenCount = false; let usedEstimatedVisibleDelta = false; + let restorationChars = 0; if (!isSummaryEmpty) { // Manual /compress has no pending functionResponse, so a trailing @@ -1501,7 +1542,7 @@ export class ChatCompressionService { // compressionOutputTokenCount. Estimate their cost locally so the // inflation guard below fires when attachments dominate the // post-compact size. - const restorationChars = extraHistory + restorationChars = extraHistory .slice(2) // skip [summary, model ack] .reduce( (acc, c) => @@ -1549,6 +1590,10 @@ export class ChatCompressionService { } } + const newTokenCountIsEstimated = + usedEstimatedVisibleDelta || + Boolean(opts.originalTokenCountIsEstimated) || + restorationChars > 0; logCompressionResult(newTokenCount); if (isSummaryEmpty) { @@ -1578,7 +1623,7 @@ export class ChatCompressionService { info: { originalTokenCount, newTokenCount, - ...(usedEstimatedVisibleDelta && { + ...(newTokenCountIsEstimated && { newTokenCountIsEstimated: true, }), compressionStatus: @@ -1613,7 +1658,7 @@ export class ChatCompressionService { info: { originalTokenCount, newTokenCount, - newTokenCountIsEstimated: usedEstimatedVisibleDelta, + newTokenCountIsEstimated, compressionStatus: CompressionStatus.COMPRESSED, triggerReason, ...(compactionWarning && { warning: compactionWarning }), From ed158c71eff9761e25df1e98184b6949cc609b88 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Fri, 4 Sep 2026 16:56:45 +0800 Subject: [PATCH 8/9] test(core): strengthen compression review coverage Co-Authored-By: Claude Sonnet 4.6 --- .../services/chatCompressionService.test.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index bc4ace387d3..74b7a78b765 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -2959,21 +2959,24 @@ describe('ChatCompressionService.compress cache sharing', () => { }); it('does not stringify inline media while estimating a visible-history delta', async () => { + const textPayload = 'review this screenshot '.repeat(5_000); + const inlineMedia = 'A'.repeat(2_000_000); const history: Content[] = [ { role: 'user', parts: [ - { text: 'review this screenshot '.repeat(5_000) }, + { text: textPayload }, { inlineData: { mimeType: 'image/png', - data: 'A'.repeat(2_000_000), + data: inlineMedia, }, }, ], }, { role: 'model', parts: [{ text: 'done' }] }, ]; + const stringify = JSON.stringify.bind(JSON); const stringifySpy = vi.spyOn(JSON, 'stringify'); const { chat, config } = makeFixture({ history }); @@ -2989,6 +2992,18 @@ describe('ChatCompressionService.compress cache sharing', () => { expect(stringifySpy.mock.calls.some(([value]) => value === history)).toBe( false, ); + expect( + Math.max( + ...stringifySpy.mock.calls.map( + ([value]) => stringify(value)?.length ?? 0, + ), + ), + ).toBeLessThan(textPayload.length + 1_000); + expect(result.info.newTokenCount).toBe( + 180_000 - + estimateUtf8AdjustedVisibleTokens(history) + + estimateUtf8AdjustedVisibleTokens(result.newHistory!), + ); }); it('does not mistake shared thinking at the output cap for truncation', async () => { @@ -6047,6 +6062,8 @@ describe('issue #9455: compression request admission', () => { expect.objectContaining({ tokens_before: 45_500, tokens_after: 45_500, + cache_sharing_attempted: false, + cache_sharing_used: false, }), ); }); From 15448792922a68840cb318ff5faa49bc4ea542d5 Mon Sep 17 00:00:00 2001 From: "zhangyu.34" Date: Tue, 15 Sep 2026 16:08:54 +0800 Subject: [PATCH 9/9] fix(core): trust measured counts for compression admission Use authoritative provider counts to avoid rejecting dense CJK history while retaining conservative estimates when token provenance is uncertain. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../services/chatCompressionService.test.ts | 32 +++++++++++++++++++ .../src/services/chatCompressionService.ts | 13 ++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 74b7a78b765..9190df6a3c3 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -5808,6 +5808,7 @@ describe('issue #9455: compression request admission', () => { config, consecutiveFailures: 0, originalTokenCount: 60_000, + originalTokenCountIsEstimated: true, }); expect(result.info.compressionStatus).toBe( @@ -5817,6 +5818,37 @@ describe('issue #9455: compression request admission', () => { expect(coldSpy).not.toHaveBeenCalled(); }); + it('uses an authoritative provider count to admit dense-CJK cold input', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: '保'.repeat(120_000) }] }, + { role: 'model', parts: [{ text: 'latest response' }] }, + ]; + const { chat, config } = makeFixture(history, 128_000); + const coldSpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'summary', + usage: { + promptTokenCount: 91_101, + candidatesTokenCount: 2_000, + totalTokenCount: 93_101, + }, + } as never); + + const result = await new ChatCompressionService().compress(chat, { + promptId: 'p', + force: true, + config, + consecutiveFailures: 0, + originalTokenCount: 66_688, + originalTokenCountIsEstimated: false, + }); + + expect(coldSpy).toHaveBeenCalledOnce(); + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(result.newHistory).not.toBeNull(); + }); + it('accepts CJK-dense cold input that fits the calibrated estimate', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: '保'.repeat(26_000) }] }, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index dfd36bd1604..c1d7f00889c 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -664,6 +664,15 @@ export class ChatCompressionService { getColdInput().slimmedHistory, slimmingConfig.imageTokenEstimate, )); + // A provider count bounds the history heuristic; compression-only prompt + // text is still added below. Estimated baselines keep the conservative walk. + const getColdHistoryAdmissionEstimate = () => { + const localEstimate = getColdHistoryEstimate(); + return originalTokenCount > 0 && + opts.originalTokenCountIsEstimated === false + ? Math.min(localEstimate, originalTokenCount) + : localEstimate; + }; const compressionDirectiveTokenCount = estimateUtf8AdjustedTextTokens( COMPRESSION_REQUEST_DIRECTIVE, ); @@ -698,7 +707,7 @@ export class ChatCompressionService { return true; }; const estimateColdRequestInput = (systemPrompt: string) => - getColdHistoryEstimate() + + getColdHistoryAdmissionEstimate() + estimateUtf8AdjustedTextTokens(systemPrompt) + compressionDirectiveTokenCount; const coldRequestCannotFit = ( @@ -893,7 +902,7 @@ export class ChatCompressionService { // usable output reserve. Keeping the shared terms here prevents the checks // from drifting. const getColdInputEstimate = () => - getColdHistoryEstimate() + + getColdHistoryAdmissionEstimate() + estimateUtf8AdjustedTextTokens(systemInstruction); // Window the output budget clamps against: the window of the model that // actually receives the side-query. Defaults to the main model's window;