From b5ff13da9031c28534eeac40d52bfd2d56e0379e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 25 Jun 2026 19:51:45 +0800 Subject: [PATCH 01/12] feat(core): add configurable auto-compact threshold and Stop hook context usage (#4025) Add two features requested in issue #4025: 1. Configurable auto-compact threshold via settings.json - Add context.autoCompactThreshold setting (0-1, default 0.7) - Extend computeThresholds(window, pct?) to accept optional pct parameter - Wire all 4 call sites (chatCompressionService, geminiChat, contextCommand, useContextualTips) - Large windows (>110K) dominated by absolute branch, custom threshold mainly affects small windows 2. Stop hook stdin payload includes context usage data - Add ContextUsageData interface and buildContextUsage helper - Extend StopInput with context_usage, context_limit, input_tokens fields - Wire 3 callers (Session.ts, client.ts, config.ts) - Enables hook scripts to observe context usage and suggest compact strategies --- .../src/acp-integration/session/Session.ts | 7 ++ packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 10 ++ .../src/ui/commands/contextCommand.test.ts | 3 + .../cli/src/ui/commands/contextCommand.ts | 5 +- .../cli/src/ui/hooks/useContextualTips.ts | 5 +- packages/core/src/config/config.ts | 24 ++++ packages/core/src/core/client.ts | 7 ++ packages/core/src/core/geminiChat.ts | 5 +- packages/core/src/hooks/context-usage.test.ts | 38 ++++++ packages/core/src/hooks/context-usage.ts | 15 +++ .../core/src/hooks/hookEventHandler.test.ts | 64 ++++++++++ packages/core/src/hooks/hookEventHandler.ts | 3 + packages/core/src/hooks/hookSystem.test.ts | 34 +++++ packages/core/src/hooks/hookSystem.ts | 8 +- packages/core/src/hooks/types.ts | 12 ++ packages/core/src/index.ts | 1 + .../services/chatCompressionService.test.ts | 118 ++++++++++++++++++ .../src/services/chatCompressionService.ts | 31 +++-- .../schemas/settings.schema.json | 4 + 20 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/hooks/context-usage.test.ts create mode 100644 packages/core/src/hooks/context-usage.ts diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 74bfc0a3666..ce4e1999a66 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -57,6 +57,7 @@ import { firePreToolUseHook, firePostToolUseHook, firePostToolUseFailureHook, + buildContextUsage, injectPermissionRulesIfMissing, NotificationType, persistPermissionOutcome, @@ -1674,6 +1675,11 @@ export class Session implements SessionContext { this.#getCurrentChat().getLastModelMessageText?.() || '[no response text]'; + const contextUsage = buildContextUsage( + this.config.getContentGeneratorConfig()?.contextWindowSize, + this.lastPromptTokenCount, + ); + const response = await messageBus.request< HookExecutionRequest, HookExecutionResponse @@ -1684,6 +1690,7 @@ export class Session implements SessionContext { input: { stop_hook_active: true, last_assistant_message: responseText, + ...contextUsage, }, signal: pendingSend.signal, }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3228e3d7ff4..21cd1eeba95 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1968,6 +1968,7 @@ export async function loadCliConfig( cliVersion: await getCliVersion(), ideMode, chatCompression: settings.model?.chatCompression, + autoCompactThreshold: settings.context?.autoCompactThreshold, folderTrust, interactive, trustedFolder, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fc5ce7cc57c..d4ec8e5783e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1667,6 +1667,16 @@ const SETTINGS_SCHEMA = { }, }, }, + autoCompactThreshold: { + type: 'number', + label: 'Auto-Compact Threshold', + category: 'Context', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Fraction of context window at which auto-compaction triggers (0-1). Default is 0.7 (70%).', + showInDialog: false, + }, }, }, diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 951ca87a7f9..b34c73fa732 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -49,6 +49,7 @@ function makeMockConfig(contextWindowSize = 32_000): Config { listSkills: vi.fn().mockResolvedValue([]), }), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn(), } as unknown as Config; } @@ -74,6 +75,7 @@ describe('collectContextData (contextCommand)', () => { listSkills: vi.fn().mockResolvedValue([]), }), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn(), } as unknown as Config; }); @@ -165,6 +167,7 @@ describe('collectContextData (contextCommand)', () => { listSkills: vi.fn().mockResolvedValue([]), }), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn(), } as unknown as Config; const data = await collectContextData(config, true); diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 9f875aa019e..3e9df2ff4af 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -207,7 +207,10 @@ export async function collectContextData( const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens; - const thresholds = computeThresholds(contextWindowSize); + const thresholds = computeThresholds( + contextWindowSize, + config.getAutoCompactThreshold(), + ); // Keep the `(window - auto)` buffer for the legacy three-segment progress // bar in ContextUsage.tsx — it visualizes the headroom between the auto // threshold and the window edge, which is exactly `contextWindowSize - diff --git a/packages/cli/src/ui/hooks/useContextualTips.ts b/packages/cli/src/ui/hooks/useContextualTips.ts index 743d6f4945c..2ec7beb4a11 100644 --- a/packages/cli/src/ui/hooks/useContextualTips.ts +++ b/packages/cli/src/ui/hooks/useContextualTips.ts @@ -85,7 +85,10 @@ export function useContextualTips({ sessionPromptCount, sessionCount: tipHistory.sessionCount, platform: process.platform, - thresholds: computeThresholds(contextWindowSize), + thresholds: computeThresholds( + contextWindowSize, + config.getAutoCompactThreshold(), + ), }; const tip = selectTip('post-response', tipContext, tipRegistry, tipHistory); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 642cc61d3fb..0a1bd7d9cb2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -886,6 +886,7 @@ export interface ConfigParameters { importFormat?: 'tree' | 'flat'; chatRecording?: boolean; chatCompression?: ChatCompressionSettings; + autoCompactThreshold?: number; interactive?: boolean; trustedFolder?: boolean; defaultFileEncoding?: FileEncodingType; @@ -1332,6 +1333,7 @@ export class Config { private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly importFormat: 'tree' | 'flat'; private readonly chatCompression: ChatCompressionSettings | undefined; + private readonly autoCompactThreshold: number | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; private readonly useRipgrep: boolean; @@ -1560,6 +1562,7 @@ export class Config { params.loadMemoryFromIncludeDirectories ?? false; this.importFormat = params.importFormat ?? 'tree'; this.chatCompression = params.chatCompression; + this.autoCompactThreshold = params.autoCompactThreshold; this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; this.skipLoopDetection = params.skipLoopDetection ?? false; @@ -1745,9 +1748,22 @@ export class Config { ); break; case 'Stop': { + // Extract context usage data from input if present + const contextUsageData = + input['context_usage'] !== undefined && + input['context_limit'] !== undefined && + input['input_tokens'] !== undefined + ? { + context_usage: input['context_usage'] as number, + context_limit: input['context_limit'] as number, + input_tokens: input['input_tokens'] as number, + } + : undefined; + const stopResult = await hookSystem.fireStopEvent( (input['stop_hook_active'] as boolean) || false, (input['last_assistant_message'] as string) || '', + contextUsageData, signal, ); result = stopResult.finalOutput @@ -4570,6 +4586,14 @@ export class Config { return this.chatCompression; } + getAutoCompactThreshold(): number | undefined { + const threshold = this.autoCompactThreshold; + if (typeof threshold === 'number' && threshold > 0 && threshold <= 1) { + return threshold; + } + return undefined; + } + isInteractive(): boolean { return this.interactive; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b57446b734c..7218279f202 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -33,6 +33,7 @@ import { } from '../goals/activeGoalStore.js'; import { abortGoalForStopHookCap } from '../goals/goalHook.js'; import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js'; +import { buildContextUsage } from '../hooks/context-usage.js'; const debugLogger = createDebugLogger('CLIENT'); @@ -2281,6 +2282,11 @@ export class GeminiClient { const responseText = this.getLastModelMessageText() || '[no response text]'; + const contextUsage = buildContextUsage( + this.config.getContentGeneratorConfig()?.contextWindowSize, + uiTelemetryService.getLastPromptTokenCount(), + ); + const response = await messageBus.request< HookExecutionRequest, HookExecutionResponse @@ -2291,6 +2297,7 @@ export class GeminiClient { input: { stop_hook_active: true, last_assistant_message: responseText, + ...contextUsage, }, signal, }, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ae969640367..ca67c5f1a2a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1766,7 +1766,10 @@ export class GeminiChat { const contextLimit = this.config.getContentGeneratorConfig()?.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; - const { hard } = computeThresholds(contextLimit); + const { hard } = computeThresholds( + contextLimit, + this.config.getAutoCompactThreshold(), + ); const imageTokenEstimate = resolveSlimmingConfig( this.config.getChatCompression(), ).imageTokenEstimate; diff --git a/packages/core/src/hooks/context-usage.test.ts b/packages/core/src/hooks/context-usage.test.ts new file mode 100644 index 00000000000..78815251fc6 --- /dev/null +++ b/packages/core/src/hooks/context-usage.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { buildContextUsage } from './context-usage.js'; + +describe('buildContextUsage', () => { + it('returns context usage data when both values are valid', () => { + const result = buildContextUsage(200_000, 140_000); + expect(result).toEqual({ + context_usage: 0.7, + context_limit: 200_000, + input_tokens: 140_000, + }); + }); + + it('returns undefined when contextWindowSize is undefined', () => { + expect(buildContextUsage(undefined, 140_000)).toBeUndefined(); + }); + + it('returns undefined when contextWindowSize is 0', () => { + expect(buildContextUsage(0, 140_000)).toBeUndefined(); + }); + + it('returns undefined when inputTokens is 0', () => { + expect(buildContextUsage(200_000, 0)).toBeUndefined(); + }); + + it('returns undefined when inputTokens is negative', () => { + expect(buildContextUsage(200_000, -5)).toBeUndefined(); + }); + + it('returns undefined when contextWindowSize is negative', () => { + expect(buildContextUsage(-1, 140_000)).toBeUndefined(); + }); + + it('handles ratio > 1 (tokens exceed window)', () => { + const result = buildContextUsage(100_000, 120_000); + expect(result?.context_usage).toBe(1.2); + }); +}); diff --git a/packages/core/src/hooks/context-usage.ts b/packages/core/src/hooks/context-usage.ts new file mode 100644 index 00000000000..1a228b31e89 --- /dev/null +++ b/packages/core/src/hooks/context-usage.ts @@ -0,0 +1,15 @@ +import type { ContextUsageData } from './types.js'; + +export function buildContextUsage( + contextWindowSize: number | undefined, + inputTokens: number, +): ContextUsageData | undefined { + if (!contextWindowSize || contextWindowSize <= 0 || inputTokens <= 0) { + return undefined; + } + return { + context_usage: inputTokens / contextWindowSize, + context_limit: contextWindowSize, + input_tokens: inputTokens, + }; +} diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index 568922e2b38..9f86b8ecb66 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -317,6 +317,70 @@ describe('HookEventHandler', () => { expect(input.last_assistant_message).toBe('last assistant message'); }); + it('should include context usage fields when provided', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + const contextUsage = { + context_usage: 0.75, + context_limit: 200000, + input_tokens: 150000, + }; + await hookEventHandler.fireStopEvent(true, 'msg', contextUsage); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + stop_hook_active: boolean; + last_assistant_message: string; + context_usage?: number; + context_limit?: number; + input_tokens?: number; + }; + expect(input.stop_hook_active).toBe(true); + expect(input.context_usage).toBe(0.75); + expect(input.context_limit).toBe(200000); + expect(input.input_tokens).toBe(150000); + }); + + it('should omit context usage fields when not provided', async () => { + const mockPlan = createMockExecutionPlan([ + { + type: HookType.Command, + command: 'echo test', + source: HooksConfigSource.Project, + }, + ]); + vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); + vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]); + vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue( + createMockAggregatedResult(true), + ); + + await hookEventHandler.fireStopEvent(true, 'msg'); + + const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock + .calls; + const input = mockCalls[0][2] as { + context_usage?: number; + context_limit?: number; + input_tokens?: number; + }; + expect(input.context_usage).toBeUndefined(); + expect(input.context_limit).toBeUndefined(); + expect(input.input_tokens).toBeUndefined(); + }); + it('should handle continue=false in final output', async () => { const mockPlan = createMockExecutionPlan([]); vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 2385af12d61..17f7c097f4c 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -18,6 +18,7 @@ import type { UserPromptSubmitInput, UserPromptExpansionInput, StopInput, + ContextUsageData, SessionStartInput, SessionEndInput, SessionStartSource, @@ -188,12 +189,14 @@ export class HookEventHandler { async fireStopEvent( stopHookActive: boolean = false, lastAssistantMessage: string = '', + contextUsage?: ContextUsageData, signal?: AbortSignal, ): Promise { const input: StopInput = { ...this.createBaseInput(HookEventName.Stop), stop_hook_active: stopHookActive, last_assistant_message: lastAssistantMessage, + ...contextUsage, }; return this.executeHooks(HookEventName.Stop, input, undefined, signal); diff --git a/packages/core/src/hooks/hookSystem.test.ts b/packages/core/src/hooks/hookSystem.test.ts index 2ff10561782..0ccfb09e82c 100644 --- a/packages/core/src/hooks/hookSystem.test.ts +++ b/packages/core/src/hooks/hookSystem.test.ts @@ -295,6 +295,7 @@ describe('HookSystem', () => { true, 'last message', undefined, + undefined, ); expect(result).toEqual(mockResult); }); @@ -317,7 +318,40 @@ describe('HookSystem', () => { false, '', undefined, + undefined, + ); + }); + + it('should forward context usage to hookEventHandler', async () => { + const mockResult = { + success: true, + allOutputs: [], + errors: [], + totalDuration: 50, + finalOutput: undefined, + }; + vi.mocked(mockHookEventHandler.fireStopEvent).mockResolvedValue( + mockResult, + ); + + const contextUsage = { + context_usage: 0.75, + context_limit: 200000, + input_tokens: 150000, + }; + const result = await hookSystem.fireStopEvent( + true, + 'last message', + contextUsage, + ); + + expect(mockHookEventHandler.fireStopEvent).toHaveBeenCalledWith( + true, + 'last message', + contextUsage, + undefined, ); + expect(result).toEqual(mockResult); }); it('should return AggregatedHookResult even when no final output', async () => { diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index 5bbd2273854..dbf4e5940c8 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -12,7 +12,11 @@ import { HookPlanner } from './hookPlanner.js'; import { HookEventHandler } from './hookEventHandler.js'; import type { HookRegistryEntry } from './hookRegistry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import type { DefaultHookOutput, HookPhase } from './types.js'; +import type { + ContextUsageData, + DefaultHookOutput, + HookPhase, +} from './types.js'; import { createHookOutput, PermissionMode } from './types.js'; import type { SessionStartSource, @@ -200,11 +204,13 @@ export class HookSystem { async fireStopEvent( stopHookActive: boolean = false, lastAssistantMessage: string = '', + contextUsage?: ContextUsageData, signal?: AbortSignal, ): Promise { return this.hookEventHandler.fireStopEvent( stopHookActive, lastAssistantMessage, + contextUsage, signal, ); } diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index ffe60dfa283..a2298bd9f00 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -859,12 +859,24 @@ export interface NotificationOutput extends HookOutput { }; } +/** + * Context usage data included in Stop hook stdin payload + */ +export interface ContextUsageData { + context_usage: number; + context_limit: number; + input_tokens: number; +} + /** * Stop hook input */ export interface StopInput extends HookInput { stop_hook_active: boolean; last_assistant_message: string; + context_usage?: number; + context_limit?: number; + input_tokens?: number; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b5bcad30a9..81110a73a5d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -510,6 +510,7 @@ export { formatStopHookBlockingCapWarning, } from './hooks/stopHookCap.js'; export { type StopFailureErrorType } from './hooks/types.js'; +export { buildContextUsage } from './hooks/context-usage.js'; // ============================================================================ // Goals (/goal command runtime) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 789f0ebea72..9f193f3aac2 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -48,6 +48,7 @@ describe('ChatCompressionService', () => { mockGetHookSystem = vi.fn().mockReturnValue({}); mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getHookSystem: mockGetHookSystem, @@ -1863,6 +1864,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -1928,6 +1930,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { const warn = vi.fn(); const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -1986,6 +1989,7 @@ describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () function makeFakeConfig(opts: { contextWindowSize: number }): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2127,6 +2131,68 @@ describe('computeThresholds', () => { expect(t.auto).toBeLessThan(t.hard); } }); + + describe('custom pct parameter', () => { + it('uses DEFAULT_PCT when pct is not provided', () => { + const defaultResult = computeThresholds(32_000); + const explicitDefault = computeThresholds(32_000, 0.7); + expect(explicitDefault).toEqual(defaultResult); + }); + + it('custom pct=0.5 lowers proportional auto threshold for small windows', () => { + const t = computeThresholds(32_000, 0.5); + expect(t.auto).toBe(16_000); // 0.5 * 32K + expect(t.warn).toBe(12_800); // (0.5 - 0.1) * 32K + }); + + it('custom pct=0.9 raises proportional auto threshold for small windows', () => { + const t = computeThresholds(32_000, 0.9); + expect(t.auto).toBe(28_800); // 0.9 * 32K + expect(t.warn).toBe(25_600); // (0.9 - 0.1) * 32K + }); + + it('custom pct does not affect absolute-branch-dominated large windows', () => { + const defaultResult = computeThresholds(1_000_000); + const customPct = computeThresholds(1_000_000, 0.5); + // For 1M window, absolute branch dominates regardless of pct + expect(customPct.auto).toBe(defaultResult.auto); + expect(customPct.hard).toBe(defaultResult.hard); + }); + + it('custom pct preserves warn <= auto < hard invariant', () => { + for (const pct of [0.3, 0.5, 0.6, 0.8, 0.9]) { + for (const w of [10_000, 32_000, 128_000, 200_000]) { + const t = computeThresholds(w, pct); + expect(t.warn).toBeLessThanOrEqual(t.auto); + expect(t.auto).toBeLessThan(t.hard); + } + } + }); + + it('pct=0 produces auto=0 for small windows (proportional branch is 0)', () => { + const t = computeThresholds(32_000, 0); + // 0 * 32000 = 0, absolute branch is negative → auto = 0 + expect(t.auto).toBe(0); + // warn = max((0 - 0.1) * 32000, absWarn) = -3200 + expect(t.warn).toBeLessThanOrEqual(t.auto); + // hard is clamped to max(rawHard, auto + HARD_BUFFER) + expect(t.hard).toBeGreaterThan(t.auto); + }); + + it('pct=1 sets proportional auto to full window; hard may equal auto for small windows', () => { + const t = computeThresholds(32_000, 1); + expect(t.auto).toBe(32_000); + expect(t.warn).toBeLessThanOrEqual(t.auto); + // For 32K window: hard = min(32000, max(effectiveWindow - HARD_BUFFER, 32000 + HARD_BUFFER)) = 32000 + expect(t.hard).toBeLessThanOrEqual(t.auto); + }); + + it('pct=1 with large window: auto=window but hard capped below window', () => { + const t = computeThresholds(200_000, 1); + expect(t.auto).toBe(200_000); + expect(t.warn).toBeLessThanOrEqual(t.auto); + }); + }); }); describe('ChatCompressionService.compress — claude-code-style full-history compression', () => { @@ -2145,6 +2211,7 @@ describe('ChatCompressionService.compress — claude-code-style full-history com function makeFakeConfig(): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2255,6 +2322,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto function makeFakeConfig(opts: { contextWindowSize: number }): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2312,6 +2380,51 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto expect(spy).toHaveBeenCalled(); expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP); }); + + it('with custom threshold 0.5, triggers compression at lower token count (32K window)', async () => { + const spy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ text: 's', usage: {} } as never); + + const config = makeFakeConfig({ contextWindowSize: 32_000 }); + vi.mocked(config.getAutoCompactThreshold).mockReturnValue(0.5); + + // computeThresholds(32000, 0.5).auto = max(0.5*32000, 12000-13000) = 16000 + // 20K > 16K → falls through cheap-gate + const result = await new ChatCompressionService().compress(makeFakeChat(), { + promptId: 'p', + force: false, + model: 'qwen-test', + config, + consecutiveFailures: 0, + originalTokenCount: 20_000, + }); + + expect(spy).toHaveBeenCalled(); + expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP); + }); + + it('with default threshold, NOOPs at same token count (32K window, 20K tokens)', async () => { + const spy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ text: 's', usage: {} } as never); + + const config = makeFakeConfig({ contextWindowSize: 32_000 }); + // getAutoCompactThreshold returns undefined → default 0.7 + // computeThresholds(32000).auto = max(0.7*32000, 12000-13000) = 22400 + // 20K < 22.4K → NOOP + const result = await new ChatCompressionService().compress(makeFakeChat(), { + promptId: 'p', + force: false, + model: 'qwen-test', + config, + consecutiveFailures: 0, + originalTokenCount: 20_000, + }); + + expect(spy).not.toHaveBeenCalled(); + expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); + }); }); describe('ChatCompressionService.compress — single-turn computer-use regression', () => { @@ -2330,6 +2443,7 @@ describe('ChatCompressionService.compress — single-turn computer-use regressio function makeFakeConfig(): Config { return { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2490,6 +2604,7 @@ describe('ChatCompressionService.compress — customInstructions plumbing', () = }; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2547,6 +2662,7 @@ describe('ChatCompressionService.compress — customInstructions plumbing', () = } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2775,6 +2891,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() @@ -2970,6 +3087,7 @@ describe('ChatCompressionService.compress — plan-mode + subagent attachment wi } as unknown as GeminiChat; const mockConfig = { getChatCompression: vi.fn(), + getAutoCompactThreshold: vi.fn(), getBaseLlmClient: vi.fn(), getContentGeneratorConfig: vi .fn() diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 3a84726f503..c63a45b324b 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -146,18 +146,22 @@ export interface CompactionThresholds { * Compute the three-tier threshold ladder for a given context window. * * Each tier is `max(proportional, absolute)`: - * auto = max(DEFAULT_PCT * window, effectiveWindow - AUTOCOMPACT_BUFFER) - * warn = max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER) + * auto = max(pct * window, effectiveWindow - AUTOCOMPACT_BUFFER) + * warn = max((pct - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER) * hard = min(window, max(effectiveWindow - HARD_BUFFER, auto + HARD_BUFFER)) * - * Small windows (where the absolute branch goes negative) automatically - * fall back to the proportional branch. Large windows are dominated by - * the absolute branch, capping wasted reservation to ~33K instead of 30% - * of the window. + * `pct` defaults to DEFAULT_PCT when not provided. Small windows (where + * the absolute branch goes negative) automatically fall back to the + * proportional branch. Large windows are dominated by the absolute branch, + * capping wasted reservation to ~33K instead of 30% of the window. * * Pure function — no I/O, no shared state — safe to call repeatedly. */ -export function computeThresholds(window: number): CompactionThresholds { +export function computeThresholds( + window: number, + pct?: number, +): CompactionThresholds { + const effectivePct = pct ?? DEFAULT_PCT; // Clamp to 0 for tiny windows (window < SUMMARY_RESERVE) so the surfaced // value in `/context` stays meaningful. The Math.max guards on auto/warn/hard // below absorb the floor — clamping does not shift those outputs because @@ -166,13 +170,15 @@ export function computeThresholds(window: number): CompactionThresholds { const effectiveWindow = Math.max(0, window - SUMMARY_RESERVE); const absAuto = effectiveWindow - AUTOCOMPACT_BUFFER; - const auto = Math.max(DEFAULT_PCT * window, absAuto); + const auto = Math.max(effectivePct * window, absAuto); const absWarn = auto - WARN_BUFFER; - const warn = Math.max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, absWarn); + const warn = Math.max((effectivePct - WARN_PCT_OFFSET) * window, absWarn); const rawHard = effectiveWindow - HARD_BUFFER; - // Guarantee hard > auto so compaction doesn't wait until the last moment. + // Guarantee hard >= auto so compaction doesn't wait until the last moment. + // When pct=1, auto equals the full window and hard collapses to auto + // (degenerate case: both thresholds trigger simultaneously). // For tiny/zero windows where auto is already at the proportional floor, // clamp hard to the window itself so it never exceeds the actual limit. const hard = Math.min(window, Math.max(rawHard, auto + HARD_BUFFER)); @@ -340,7 +346,10 @@ export class ChatCompressionService { const contextLimit = config.getContentGeneratorConfig()?.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; - const { auto } = computeThresholds(contextLimit); + const { auto } = computeThresholds( + contextLimit, + config.getAutoCompactThreshold(), + ); // Order of preference for the effective-token estimate: // 1. Caller already computed it (sendMessageStream hard-tier rescue) // 2. Compute it here from history + pending user message diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index bb474fcc5fe..c0656900bae 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -753,6 +753,10 @@ "default": true } } + }, + "autoCompactThreshold": { + "description": "Fraction of context window at which auto-compaction triggers (0-1). Default is 0.7 (70%).", + "type": "number" } } }, From 4fa164b7f1a2d84b2bc4110389628d453e69bd17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 00:03:47 +0800 Subject: [PATCH 02/12] fix(test): add getAutoCompactThreshold mock to geminiChat.test.ts, add NaN guard to buildContextUsage --- packages/core/src/core/geminiChat.test.ts | 1 + packages/core/src/hooks/context-usage.test.ts | 4 ++++ packages/core/src/hooks/context-usage.ts | 7 ++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 7befde96a2b..9c33a98ead1 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -173,6 +173,7 @@ describe('GeminiChat', async () => { getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), getBaseLlmClient: vi.fn().mockReturnValue(undefined), getChatCompression: vi.fn().mockReturnValue(undefined), + getAutoCompactThreshold: vi.fn().mockReturnValue(undefined), getHookSystem: vi.fn().mockReturnValue(undefined), getDebugLogger: vi .fn() diff --git a/packages/core/src/hooks/context-usage.test.ts b/packages/core/src/hooks/context-usage.test.ts index 78815251fc6..9b135888beb 100644 --- a/packages/core/src/hooks/context-usage.test.ts +++ b/packages/core/src/hooks/context-usage.test.ts @@ -27,6 +27,10 @@ describe('buildContextUsage', () => { expect(buildContextUsage(200_000, -5)).toBeUndefined(); }); + it('returns undefined when inputTokens is NaN', () => { + expect(buildContextUsage(200_000, NaN)).toBeUndefined(); + }); + it('returns undefined when contextWindowSize is negative', () => { expect(buildContextUsage(-1, 140_000)).toBeUndefined(); }); diff --git a/packages/core/src/hooks/context-usage.ts b/packages/core/src/hooks/context-usage.ts index 1a228b31e89..9ffe7cb876a 100644 --- a/packages/core/src/hooks/context-usage.ts +++ b/packages/core/src/hooks/context-usage.ts @@ -4,7 +4,12 @@ export function buildContextUsage( contextWindowSize: number | undefined, inputTokens: number, ): ContextUsageData | undefined { - if (!contextWindowSize || contextWindowSize <= 0 || inputTokens <= 0) { + if ( + !contextWindowSize || + contextWindowSize <= 0 || + !Number.isFinite(inputTokens) || + inputTokens <= 0 + ) { return undefined; } return { From 6122695fc9ac785beeb0e569f5c454d7a3f4b485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 04:08:47 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix(review):=20address=20round=204=20find?= =?UTF-8?q?ings=20=E2=80=94=20schema=20constraints,=20Partial,=20buildContextUsage=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/config/settingsSchema.ts | 7 ++++++- packages/core/src/config/config.ts | 17 ++++++----------- packages/core/src/hooks/types.ts | 5 +---- .../schemas/settings.schema.json | 6 ++++-- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d4ec8e5783e..77e2f921ed1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1674,8 +1674,13 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: undefined as number | undefined, description: - 'Fraction of context window at which auto-compaction triggers (0-1). Default is 0.7 (70%).', + 'Fraction of context window at which auto-compaction triggers (greater than 0, up to 1). Default is 0.7 (70%).', showInDialog: false, + jsonSchemaOverride: { + type: 'number', + minimum: 0.01, + maximum: 1, + }, }, }, }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0a1bd7d9cb2..78650b4f364 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -101,6 +101,7 @@ import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js' import { WorkflowRunRegistry } from '../agents/workflow-run-registry.js'; import { FileReadCache } from '../services/fileReadCache.js'; import { resolveStopHookBlockingCap } from '../hooks/stopHookCap.js'; +import { buildContextUsage } from '../hooks/context-usage.js'; import { DEFAULT_OTLP_ENDPOINT, DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH, @@ -1748,17 +1749,11 @@ export class Config { ); break; case 'Stop': { - // Extract context usage data from input if present - const contextUsageData = - input['context_usage'] !== undefined && - input['context_limit'] !== undefined && - input['input_tokens'] !== undefined - ? { - context_usage: input['context_usage'] as number, - context_limit: input['context_limit'] as number, - input_tokens: input['input_tokens'] as number, - } - : undefined; + // Extract context usage data from input with runtime validation + const contextUsageData = buildContextUsage( + input['context_limit'] as number | undefined, + (input['input_tokens'] as number | undefined) ?? 0, + ); const stopResult = await hookSystem.fireStopEvent( (input['stop_hook_active'] as boolean) || false, diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index a2298bd9f00..55dc33d4dcb 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -871,12 +871,9 @@ export interface ContextUsageData { /** * Stop hook input */ -export interface StopInput extends HookInput { +export interface StopInput extends HookInput, Partial { stop_hook_active: boolean; last_assistant_message: string; - context_usage?: number; - context_limit?: number; - input_tokens?: number; } /** diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c0656900bae..e5598894b14 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -755,8 +755,10 @@ } }, "autoCompactThreshold": { - "description": "Fraction of context window at which auto-compaction triggers (0-1). Default is 0.7 (70%).", - "type": "number" + "type": "number", + "minimum": 0.01, + "maximum": 1, + "description": "Fraction of context window at which auto-compaction triggers (greater than 0, up to 1). Default is 0.7 (70%)." } } }, From e5a2bbf194186a0c955459b994f814fdb7c22c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 11:04:21 +0800 Subject: [PATCH 04/12] docs: add context.autoCompactThreshold and Stop hook context usage fields documentation --- docs/users/configuration/settings.md | 31 ++++++++++++++-------------- docs/users/features/hooks.md | 7 ++++++- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index bdb6ee1a27b..d7b4b56dd8a 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -149,7 +149,7 @@ Settings are organized into categories. Most settings should be placed within th | `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` | | `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` | | `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (default `true`; splits tool-returned media — including images read by the built-in read_file — into a follow-up user message instead of the spec-violating `role: "tool"` message, so strict OpenAI-compatible servers like doubao / new-api / LM Studio can see it; set `false` to restore the legacy embed-in-tool behavior), `toolResultContentFormat` (default `"parts"`; set `"string"` only for legacy OpenAI-compatible runtimes whose tool templates ignore text content parts), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | -| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored (no startup warning). There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` | +| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Replaced by `context.autoCompactThreshold` (see `#### context` section below). Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function. The old setting is silently ignored (no startup warning). See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale. | `N/A` | | `model.chatCompression.maxRecentFilesToRetain` | number | Number of most-recently-touched files whose current content is restored (embedded if small, otherwise referenced by path) into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. | `5` | | `model.chatCompression.maxRecentImagesToRetain` | number | Number of most-recent images (tool screenshots / user pastes) restored into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. | `3` | | `model.chatCompression.enableScreenshotTrigger` | boolean | When `true`, auto-compaction also fires once the number of tool-returned images accumulated in history reaches `screenshotTriggerThreshold`, independent of token usage — aimed at computer-use sessions where frequent screenshots dilute model attention. Counts only images returned inside tool results, not user-pasted images. Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). | `true` | @@ -238,20 +238,21 @@ The `extra_body` field allows you to add custom parameters to the request body s #### context -| Setting | Type | Description | Default | -| ----------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` | -| `context.importFormat` | string | The format to use when importing memory. | `undefined` | -| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` | -| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` | -| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | -| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore and configured custom ignore files when searching. | `true` | -| `context.fileFiltering.customIgnoreFiles` | array | Project-root-relative ignore files to use instead of the default compatibility files (`.agentignore`, `.aiignore`) when `respectQwenIgnore` is enabled. `.qwenignore` is always included. | `[".agentignore", ".aiignore"]` | -| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | -| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | -| `context.clearContextOnIdle.toolResultsThresholdMinutes` | number | Minutes of inactivity before clearing old tool result content. Use `-1` to disable the idle trigger. | `60` | -| `context.clearContextOnIdle.toolResultsNumToKeep` | integer | Integer number of most-recent compactable tool results to preserve when clearing. Values below 1 are floored to 1. | `5` | -| `context.clearContextOnIdle.toolResultsTotalCharsThreshold` | number | Total compactable tool result output characters allowed in history before clearing oldest results. Use `-1` to disable the size trigger. This is a soft threshold: protected recent tool results may keep the total above it. | `500000` | +| Setting | Type | Description | Default | +| ----------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` | +| `context.autoCompactThreshold` | number | Fraction of the context window at which auto-compaction triggers. Must be greater than 0 and at most 1. Default is `0.7` (70%). For large context windows (>110K tokens), the absolute branch of the three-tier threshold system dominates, so values below ~0.7 may have no visible effect. Custom thresholds primarily affect small-window models (≤128K). Replaces the old `model.chatCompression.contextPercentageThreshold`. | `undefined` (uses internal 0.7) | +| `context.importFormat` | string | The format to use when importing memory. | `undefined` | +| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` | +| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` | +| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` | +| `context.fileFiltering.respectQwenIgnore` | boolean | Respect .qwenignore and configured custom ignore files when searching. | `true` | +| `context.fileFiltering.customIgnoreFiles` | array | Project-root-relative ignore files to use instead of the default compatibility files (`.agentignore`, `.aiignore`) when `respectQwenIgnore` is enabled. `.qwenignore` is always included. | `[".agentignore", ".aiignore"]` | +| `context.fileFiltering.enableRecursiveFileSearch` | boolean | Whether to enable searching recursively for filenames under the current tree when completing `@` prefixes in the prompt. | `true` | +| `context.fileFiltering.enableFuzzySearch` | boolean | When `true`, enables fuzzy search capabilities when searching for files. Set to `false` to improve performance on projects with a large number of files. | `true` | +| `context.clearContextOnIdle.toolResultsThresholdMinutes` | number | Minutes of inactivity before clearing old tool result content. Use `-1` to disable the idle trigger. | `60` | +| `context.clearContextOnIdle.toolResultsNumToKeep` | integer | Integer number of most-recent compactable tool results to preserve when clearing. Values below 1 are floored to 1. | `5` | +| `context.clearContextOnIdle.toolResultsTotalCharsThreshold` | number | Total compactable tool result output characters allowed in history before clearing oldest results. Use `-1` to disable the size trigger. This is a soft threshold: protected recent tool results may keep the total above it. | `500000` | #### Troubleshooting File Search Performance diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index bbed092bfca..2616c1843b5 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -566,10 +566,15 @@ Hook output supports three categories of fields: ```json { "stop_hook_active": "boolean indicating if stop hook is active", - "last_assistant_message": "the last message from the assistant" + "last_assistant_message": "the last message from the assistant", + "context_usage": "0-1 ratio of context window used (optional)", + "context_limit": "context window size in tokens (optional)", + "input_tokens": "current prompt token count (optional)" } ``` +The `context_usage`, `context_limit`, and `input_tokens` fields allow hook scripts to observe context usage and implement custom compact strategies — for example, a script that prints a reminder to run `/compact` when usage exceeds a custom threshold. + **Output Options**: - `decision`: "allow", "deny", "block", or "ask" From b15b23448d575dadfc6187b2e0bbba634ce4c550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 12:38:37 +0800 Subject: [PATCH 05/12] fix(review): add contextWindowSize fallback, pct clamp, doc accuracy, threshold propagation test --- docs/users/features/hooks.md | 4 ++-- packages/cli/src/acp-integration/session/Session.ts | 4 +++- packages/cli/src/ui/commands/contextCommand.test.ts | 11 +++++++++++ packages/core/src/core/client.ts | 4 +++- packages/core/src/services/chatCompressionService.ts | 2 +- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 2616c1843b5..3c78115a722 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -567,9 +567,9 @@ Hook output supports three categories of fields: { "stop_hook_active": "boolean indicating if stop hook is active", "last_assistant_message": "the last message from the assistant", - "context_usage": "0-1 ratio of context window used (optional)", + "context_usage": "ratio of context window used (may exceed 1 when tokens exceed window; optional)", "context_limit": "context window size in tokens (optional)", - "input_tokens": "current prompt token count (optional)" + "input_tokens": "prompt token count (may include output tokens depending on provider; optional)" } ``` diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index ce4e1999a66..bd9fb374ee9 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -103,6 +103,7 @@ import { dedupeToolCallsById, getProviderToolCallId, parsePositiveIntegerEnv, + DEFAULT_TOKEN_LIMIT, } from '@qwen-code/qwen-code-core'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; // Single source of truth shared with the daemon-side answerer (BridgeClient), @@ -1676,7 +1677,8 @@ export class Session implements SessionContext { '[no response text]'; const contextUsage = buildContextUsage( - this.config.getContentGeneratorConfig()?.contextWindowSize, + this.config.getContentGeneratorConfig()?.contextWindowSize ?? + DEFAULT_TOKEN_LIMIT, this.lastPromptTokenCount, ); diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index b34c73fa732..98add08944a 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -250,4 +250,15 @@ describe('/context shows three-tier thresholds', () => { const text = formatContextUsageText(data); expect(text).not.toMatch(/Compaction thresholds/); }); + + it('propagates custom autoCompactThreshold through to /context thresholds', async () => { + // config.getAutoCompactThreshold() returns 0.5 → computeThresholds(32000, 0.5) + // = { warn: 9,600, auto: 16,000, hard: 19,000, effectiveWindow: 12,000 } + const config = makeMockConfig(32_000); + vi.mocked(config.getAutoCompactThreshold).mockReturnValue(0.5); + const data = await collectContextData(config, false); + + expect(data.breakdown.thresholds).toBeDefined(); + expect(data.breakdown.thresholds!.auto).toBe(16_000); + }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 7218279f202..9dc60c13909 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -34,6 +34,7 @@ import { import { abortGoalForStopHookCap } from '../goals/goalHook.js'; import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js'; import { buildContextUsage } from '../hooks/context-usage.js'; +import { DEFAULT_TOKEN_LIMIT } from './tokenLimits.js'; const debugLogger = createDebugLogger('CLIENT'); @@ -2283,7 +2284,8 @@ export class GeminiClient { this.getLastModelMessageText() || '[no response text]'; const contextUsage = buildContextUsage( - this.config.getContentGeneratorConfig()?.contextWindowSize, + this.config.getContentGeneratorConfig()?.contextWindowSize ?? + DEFAULT_TOKEN_LIMIT, uiTelemetryService.getLastPromptTokenCount(), ); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index c63a45b324b..b5382ab2685 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -161,7 +161,7 @@ export function computeThresholds( window: number, pct?: number, ): CompactionThresholds { - const effectivePct = pct ?? DEFAULT_PCT; + const effectivePct = Math.min(1, Math.max(0, pct ?? DEFAULT_PCT)); // Clamp to 0 for tiny windows (window < SUMMARY_RESERVE) so the surfaced // value in `/context` stays meaningful. The Math.max guards on auto/warn/hard // below absorb the floor — clamping does not shift those outputs because From 72346946fbeb50a4a1c7e6d3c127277110371c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 14:03:37 +0800 Subject: [PATCH 06/12] fix: correct warn value in contextCommand test comment --- packages/cli/src/ui/commands/contextCommand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 98add08944a..98a1b602568 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -253,7 +253,7 @@ describe('/context shows three-tier thresholds', () => { it('propagates custom autoCompactThreshold through to /context thresholds', async () => { // config.getAutoCompactThreshold() returns 0.5 → computeThresholds(32000, 0.5) - // = { warn: 9,600, auto: 16,000, hard: 19,000, effectiveWindow: 12,000 } + // = { warn: 16,000, auto: 16,000, hard: 19,000, effectiveWindow: 12,000 } const config = makeMockConfig(32_000); vi.mocked(config.getAutoCompactThreshold).mockReturnValue(0.5); const data = await collectContextData(config, false); From c047c9a89371bf07d16e863eb63241bdb44f36e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 14:41:09 +0800 Subject: [PATCH 07/12] test(chatCompressionService): fix misleading pct=1 test name and assertion --- packages/core/src/services/chatCompressionService.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 9f193f3aac2..12e6a77fa1d 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -2187,9 +2187,10 @@ describe('computeThresholds', () => { expect(t.hard).toBeLessThanOrEqual(t.auto); }); - it('pct=1 with large window: auto=window but hard capped below window', () => { + it('pct=1 with large window: auto and hard both equal window', () => { const t = computeThresholds(200_000, 1); expect(t.auto).toBe(200_000); + expect(t.hard).toBe(200_000); expect(t.warn).toBeLessThanOrEqual(t.auto); }); }); From b66a7133990f68d243b5c2d49a0e928cf4ac1e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 16:35:08 +0800 Subject: [PATCH 08/12] fix(chatCompressionService): prevent negative warn threshold for low pct values --- packages/core/src/services/chatCompressionService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index b5382ab2685..3d98f4051e7 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -173,7 +173,10 @@ export function computeThresholds( const auto = Math.max(effectivePct * window, absAuto); const absWarn = auto - WARN_BUFFER; - const warn = Math.max((effectivePct - WARN_PCT_OFFSET) * window, absWarn); + const warn = Math.max( + 0, + Math.max((effectivePct - WARN_PCT_OFFSET) * window, absWarn), + ); const rawHard = effectiveWindow - HARD_BUFFER; // Guarantee hard >= auto so compaction doesn't wait until the last moment. From 4e58826c1a8b9c5867a6300b71257454bcdf9d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 17:46:07 +0800 Subject: [PATCH 09/12] docs(chatCompressionService): update JSDoc warn formula to include max(0, ...) floor --- packages/core/src/services/chatCompressionService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 3d98f4051e7..52e771f2c87 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -147,7 +147,7 @@ export interface CompactionThresholds { * * Each tier is `max(proportional, absolute)`: * auto = max(pct * window, effectiveWindow - AUTOCOMPACT_BUFFER) - * warn = max((pct - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER) + * warn = max(0, max((pct - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER)) * hard = min(window, max(effectiveWindow - HARD_BUFFER, auto + HARD_BUFFER)) * * `pct` defaults to DEFAULT_PCT when not provided. Small windows (where From ec999622777debd1be2a9936eb941889f08f6231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 19:14:55 +0800 Subject: [PATCH 10/12] test(chatCompressionService): add pct clamping tests and fix NaN handling Add tests for out-of-range pct values (-0.5, 1.5, NaN) to verify computeThresholds clamping behavior. Fix implementation to use Number.isFinite() check so NaN falls back to DEFAULT_PCT instead of propagating through Math.max(0, NaN) which yields NaN. --- .../services/chatCompressionService.test.ts | 18 ++++++++++++++++++ .../src/services/chatCompressionService.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 12e6a77fa1d..942dd042405 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -2193,6 +2193,24 @@ describe('computeThresholds', () => { expect(t.hard).toBe(200_000); expect(t.warn).toBeLessThanOrEqual(t.auto); }); + + it('clamps negative pct to 0', () => { + expect(computeThresholds(32_000, -0.5)).toEqual( + computeThresholds(32_000, 0), + ); + }); + + it('clamps pct > 1 to 1', () => { + expect(computeThresholds(32_000, 1.5)).toEqual( + computeThresholds(32_000, 1), + ); + }); + + it('NaN pct falls back to DEFAULT_PCT (via Number.isFinite check)', () => { + expect(computeThresholds(32_000, NaN)).toEqual( + computeThresholds(32_000), // no pct arg = DEFAULT_PCT + ); + }); }); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 52e771f2c87..de52943a5cd 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -161,7 +161,10 @@ export function computeThresholds( window: number, pct?: number, ): CompactionThresholds { - const effectivePct = Math.min(1, Math.max(0, pct ?? DEFAULT_PCT)); + const effectivePct = Math.min( + 1, + Math.max(0, Number.isFinite(pct) ? pct : DEFAULT_PCT), + ); // Clamp to 0 for tiny windows (window < SUMMARY_RESERVE) so the surfaced // value in `/context` stays meaningful. The Math.max guards on auto/warn/hard // below absorb the floor — clamping does not shift those outputs because From 762f5d9432fddcbd4da52f21ead5321a4646cb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 19:25:50 +0800 Subject: [PATCH 11/12] test(config): add MCP Stop dispatch validation tests Add tests for buildContextUsage runtime validation in MCP Stop dispatch path: - Valid numeric inputs produce correct ContextUsageData - Missing/undefined fields return undefined - String values rejected by Number.isFinite validation - Negative values return undefined Also add Number.isFinite check for contextWindowSize in buildContextUsage to properly validate MCP input types at runtime. --- packages/core/src/config/config.test.ts | 39 ++++++++++++++++++++++++ packages/core/src/hooks/context-usage.ts | 1 + 2 files changed, 40 insertions(+) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index d989188c084..a50a1d7c27c 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -4966,4 +4966,43 @@ describe('Model Switching and Config Updates', () => { expect(config.getAutoSkillConfirmEnabled()).toBe(false); }); }); + + describe('MCP Stop dispatch with context usage data', () => { + it('buildContextUsage handles MCP input patterns with runtime validation', async () => { + // Test the buildContextUsage function that's used in MCP Stop dispatch + // This validates the runtime type coercion and edge cases + const { buildContextUsage } = await import('../hooks/context-usage.js'); + + // Normal case: valid numbers + expect(buildContextUsage(128000, 64000)).toEqual({ + context_usage: 0.5, + context_limit: 128000, + input_tokens: 64000, + }); + + // Missing context_limit: returns undefined + expect(buildContextUsage(undefined, 64000)).toBeUndefined(); + + // Missing input_tokens (defaults to 0): returns undefined + expect(buildContextUsage(128000, 0)).toBeUndefined(); + + // Both missing: returns undefined + expect(buildContextUsage(undefined, 0)).toBeUndefined(); + + // String values (MCP might send strings): Number.isFinite rejects strings + // @ts-expect-error - testing runtime validation + expect(buildContextUsage('128000', 64000)).toBeUndefined(); + + // Invalid string values: returns undefined + // @ts-expect-error - testing runtime validation + expect(buildContextUsage('invalid', 64000)).toBeUndefined(); + + // Negative values: returns undefined + expect(buildContextUsage(-128000, 64000)).toBeUndefined(); + expect(buildContextUsage(128000, -64000)).toBeUndefined(); + + // Zero context_limit: returns undefined + expect(buildContextUsage(0, 64000)).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/hooks/context-usage.ts b/packages/core/src/hooks/context-usage.ts index 9ffe7cb876a..2c6afeccd47 100644 --- a/packages/core/src/hooks/context-usage.ts +++ b/packages/core/src/hooks/context-usage.ts @@ -6,6 +6,7 @@ export function buildContextUsage( ): ContextUsageData | undefined { if ( !contextWindowSize || + !Number.isFinite(contextWindowSize) || contextWindowSize <= 0 || !Number.isFinite(inputTokens) || inputTokens <= 0 From e75ada9a7aaa85f9dc0f788bbd2a443648d316c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 26 Jun 2026 19:35:54 +0800 Subject: [PATCH 12/12] fix(chatCompressionService): fix TypeScript type narrowing for pct parameter Use explicit undefined check before Number.isFinite to properly narrow the number | undefined type in the ternary expression. --- packages/core/src/services/chatCompressionService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index de52943a5cd..2cf254b55cb 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -163,7 +163,7 @@ export function computeThresholds( ): CompactionThresholds { const effectivePct = Math.min( 1, - Math.max(0, Number.isFinite(pct) ? pct : DEFAULT_PCT), + Math.max(0, pct !== undefined && Number.isFinite(pct) ? pct : DEFAULT_PCT), ); // Clamp to 0 for tiny windows (window < SUMMARY_RESERVE) so the surfaced // value in `/context` stays meaningful. The Math.max guards on auto/warn/hard