diff --git a/src/query.ts b/src/query.ts index fe08e2c6a..c5483c73e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -14,6 +14,7 @@ import { } from './services/compact/autoCompact.js' import { consumeCompactionRequest } from './utils/memoryPressure.js' import { buildPostCompactMessages } from './services/compact/compact.js' +import type { MicrocompactResult } from './services/compact/microCompact.js' /* eslint-disable @typescript-eslint/no-require-imports */ const reactiveCompact = feature('REACTIVE_COMPACT') ? (require('./services/compact/reactiveCompact.js') as typeof import('./services/compact/reactiveCompact.js')) @@ -450,6 +451,12 @@ async function* queryLoop( } let tracking = autoCompactTracking + const configuredMaxMessagesCompactionThreshold = + getGlobalConfig().maxMessagesCompactionThreshold + const maxMessagesCompactionThreshold = + normalizeMaxMessagesCompactionThreshold( + configuredMaxMessagesCompactionThreshold, + ) // Enforce per-message budget on aggregate tool result size. Runs BEFORE // microcompact — cached MC operates purely by tool_use_id (never inspects @@ -502,17 +509,23 @@ async function* queryLoop( // Apply microcompact before autocompact queryCheckpoint('query_microcompact_start') - const microcompactResult = await deps.microcompact( - messagesForQuery, - toolUseContext, - querySource, - ) - messagesForQuery = microcompactResult.messages + let microcompactResult: MicrocompactResult | undefined + if ( + querySource === 'compact' || + configuredMaxMessagesCompactionThreshold !== 'off' + ) { + microcompactResult = await deps.microcompact( + messagesForQuery, + toolUseContext, + querySource, + ) + messagesForQuery = microcompactResult.messages + } // For cached microcompact (cache editing), defer boundary message until after // the API response so we can use actual cache_deleted_input_tokens. // Gated behind feature() so the string is eliminated from external builds. const pendingCacheEdits = feature('CACHED_MICROCOMPACT') - ? microcompactResult.compactionInfo?.pendingCacheEdits + ? microcompactResult?.compactionInfo?.pendingCacheEdits : undefined queryCheckpoint('query_microcompact_end') @@ -568,9 +581,7 @@ async function* queryLoop( const canForceCompact = querySource !== 'compact' && querySource !== 'session_memory' if (canForceCompact) { - const configSetting = normalizeMaxMessagesCompactionThreshold( - getGlobalConfig().maxMessagesCompactionThreshold, - ) + const configSetting = maxMessagesCompactionThreshold const envSetting = process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES const maxActiveMessages = configSetting !== 'off' ? Number.parseInt(configSetting, 10) diff --git a/src/query/autoCompactCooldown.test.ts b/src/query/autoCompactCooldown.test.ts index eb3c63e87..8a8ef76f5 100644 --- a/src/query/autoCompactCooldown.test.ts +++ b/src/query/autoCompactCooldown.test.ts @@ -2,19 +2,33 @@ import { afterEach, beforeEach, expect, mock, test } from 'bun:test' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { - getAutoCompactThreshold, - MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES, - type AutoCompactTrackingState, -} from '../services/compact/autoCompact.js' +import type { AutoCompactTrackingState } from '../services/compact/autoCompact.js' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' import type { Message } from '../types/message.js' -import { query } from '../query.js' import { asSystemPrompt } from '../utils/systemPromptType.js' -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' +import type { MaxMessagesCompactionThreshold } from '../utils/config.js' +import type { QueryDeps } from './deps.js' + +type AutocompactArgs = Parameters + +// Some smoke-suite files mock config globally; bun:test does not unregister +// mock.module() registrations on mock.restore(). Pin this suite to the real +// config before importing query so saved settings are visible to the query loop. +const realConfigModule = (await import( + `../utils/config.js?autoCompactCooldownReal=${Date.now()}-${Math.random()}` +)) as typeof import('../utils/config.js') +mock.module('../utils/config.js', () => ({ ...realConfigModule })) + +const { getGlobalConfig, saveGlobalConfig } = realConfigModule +const { + getAutoCompactThreshold, + MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES, +} = (await import( + `../services/compact/autoCompact.js?autoCompactCooldownReal=${Date.now()}-${Math.random()}` +)) as typeof import('../services/compact/autoCompact.js') const SAVED_ENV = { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, @@ -24,32 +38,52 @@ const SAVED_ENV = { process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, DISABLE_AUTO_COMPACT: process.env.DISABLE_AUTO_COMPACT, DISABLE_COMPACT: process.env.DISABLE_COMPACT, + OPENCLAUDE_MAX_ACTIVE_MESSAGES: process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES, } -let savedAutoCompactEnabled: boolean | undefined +let savedGlobalConfig: + | { + autoCompactEnabled: boolean + maxMessagesCompactionThreshold: + | MaxMessagesCompactionThreshold + | undefined + } + | undefined let tempDir: string | undefined beforeEach(async () => { await acquireSharedMutationLock('query/autoCompactCooldown.test.ts') tempDir = mkdtempSync(join(tmpdir(), 'openclaude-autocompact-test-')) process.env.CLAUDE_CONFIG_DIR = tempDir - savedAutoCompactEnabled = getGlobalConfig().autoCompactEnabled - saveGlobalConfig(current => ({ ...current, autoCompactEnabled: true })) + const globalConfig = getGlobalConfig() + savedGlobalConfig = { + autoCompactEnabled: globalConfig.autoCompactEnabled, + maxMessagesCompactionThreshold: + globalConfig.maxMessagesCompactionThreshold, + } + saveGlobalConfig(current => ({ + ...current, + autoCompactEnabled: true, + maxMessagesCompactionThreshold: undefined, + })) process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '200000' process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '1' delete process.env.DISABLE_AUTO_COMPACT delete process.env.DISABLE_COMPACT + delete process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES }) afterEach(() => { try { - if (savedAutoCompactEnabled !== undefined) { - const autoCompactEnabled = savedAutoCompactEnabled + if (savedGlobalConfig) { + const { autoCompactEnabled, maxMessagesCompactionThreshold } = + savedGlobalConfig saveGlobalConfig(current => ({ ...current, autoCompactEnabled, + maxMessagesCompactionThreshold, })) - savedAutoCompactEnabled = undefined + savedGlobalConfig = undefined } for (const [key, value] of Object.entries(SAVED_ENV)) { @@ -151,17 +185,134 @@ async function drain( } } +async function loadQuery() { + return (await import( + `../query.js?autoCompactCooldown=${Date.now()}-${Math.random()}` + )) as typeof import('../query.js') +} + +function successfulQueryDeps( + microcompactImpl?: (input: Message[]) => Promise<{ messages: Message[] }>, +) { + const callModel = mock(async function* (_params: { messages: Message[] }) { + yield assistantToolUseMessage() + }) + const microcompact = mock( + microcompactImpl ?? (async (input: Message[]) => ({ messages: input })), + ) + const autocompact = mock(async () => ({ + wasCompacted: false, + })) + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], + microcompact: microcompact as QueryDeps['microcompact'], + autocompact: autocompact as QueryDeps['autocompact'], + uuid: () => 'test-uuid', + } + return { + deps, + callModel, + microcompact, + autocompact, + } +} + +async function runSuccessfulQuery( + deps: QueryDeps, + querySource: 'repl_main_thread' | 'compact' = 'repl_main_thread', +) { + const { query } = await loadQuery() + return await drain( + query({ + messages: [userMessage('hello')], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool, + toolUseContext: toolUseContext(), + querySource, + maxTurns: 1, + deps, + }), + ) +} + +test('explicit off skips automatic microcompact during query flow', async () => { + saveGlobalConfig(current => ({ + ...current, + maxMessagesCompactionThreshold: 'off', + })) + const { deps, callModel, microcompact, autocompact } = successfulQueryDeps( + async input => ({ messages: input }), + ) + + const { terminal } = await runSuccessfulQuery(deps) + + expect(terminal.reason).toBe('max_turns') + expect(callModel).toHaveBeenCalledTimes(1) + expect(autocompact).toHaveBeenCalledTimes(1) + expect(microcompact).not.toHaveBeenCalled() +}) + +test('unset message-count threshold keeps automatic microcompact behavior', async () => { + const { deps, microcompact } = successfulQueryDeps() + + const { terminal } = await runSuccessfulQuery(deps) + + expect(terminal.reason).toBe('max_turns') + expect(microcompact).toHaveBeenCalledTimes(1) +}) + +test('automatic microcompact passes compacted messages to the model call', async () => { + const compactedMessages = [userMessage('compacted hello')] + const { deps, callModel, microcompact } = successfulQueryDeps(async () => ({ + messages: compactedMessages, + })) + + const { terminal } = await runSuccessfulQuery(deps) + + expect(terminal.reason).toBe('max_turns') + expect(microcompact).toHaveBeenCalledTimes(1) + expect(callModel.mock.calls[0]?.[0].messages).toEqual(compactedMessages) +}) + +test('numeric message-count threshold keeps automatic microcompact behavior', async () => { + saveGlobalConfig(current => ({ + ...current, + maxMessagesCompactionThreshold: '100', + })) + const { deps, microcompact } = successfulQueryDeps() + + const { terminal } = await runSuccessfulQuery(deps) + + expect(terminal.reason).toBe('max_turns') + expect(microcompact).toHaveBeenCalledTimes(1) +}) + +test('explicit compact query source still runs microcompact when threshold is off', async () => { + saveGlobalConfig(current => ({ + ...current, + maxMessagesCompactionThreshold: 'off', + })) + const { deps, microcompact } = successfulQueryDeps() + + const { terminal } = await runSuccessfulQuery(deps, 'compact') + + expect(terminal.reason).toBe('max_turns') + expect(microcompact).toHaveBeenCalledTimes(1) +}) + test('active auto-compact cooldown blocks before model call with cooldown guidance', async () => { const messages = [overAutoCompactThresholdMessage()] const nextRetryAtMs = Date.now() + 60_000 const callModel = mock(() => { throw new Error('model should not be called while autocompact cools down') }) - const deps = { - callModel, + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], microcompact: mock(async (input: Message[]) => ({ messages: input, - })), + })) as QueryDeps['microcompact'], autocompact: mock( async (): Promise<{ wasCompacted: boolean @@ -176,10 +327,11 @@ test('active auto-compact cooldown blocks before model call with cooldown guidan circuitBreakerActive: true, circuitBreakerTripped: false, }), - ), + ) as QueryDeps['autocompact'], uuid: () => 'test-uuid', - } as never + } + const { query } = await loadQuery() const { yielded, terminal } = await drain( query({ messages, @@ -213,18 +365,18 @@ test('auto-compact cooldown tracking is carried into the next query call', async const callModel = mock(() => { throw new Error('model should not be called while autocompact cools down') }) - const deps = { - callModel, + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], microcompact: mock(async (input: Message[]) => ({ messages: input, - })), + })) as QueryDeps['microcompact'], autocompact: mock( async ( - _messages: never, - _toolUseContext: never, - _params: never, - _querySource: never, - tracking: AutoCompactTrackingState | undefined, + _messages: AutocompactArgs[0], + _toolUseContext: AutocompactArgs[1], + _params: AutocompactArgs[2], + _querySource: AutocompactArgs[3], + tracking: AutocompactArgs[4], ) => { seenTracking.push(tracking) return { @@ -235,9 +387,9 @@ test('auto-compact cooldown tracking is carried into the next query call', async circuitBreakerTripped: false, } }, - ), + ) as QueryDeps['autocompact'], uuid: () => 'test-uuid', - } as never + } let persistedTracking: AutoCompactTrackingState | undefined const queryParams = () => ({ @@ -257,6 +409,7 @@ test('auto-compact cooldown tracking is carried into the next query call', async }, }) + const { query } = await loadQuery() const first = await drain(query(queryParams())) expect(first.terminal.reason).toBe('blocking_limit') expect(persistedTracking?.nextRetryAtMs).toBe(nextRetryAtMs) @@ -280,19 +433,20 @@ test('post-compact turn tracking callback publishes a fresh object', async () => consecutiveFailures: 0, } const trackingUpdates: AutoCompactTrackingState[] = [] - const deps = { + const deps: QueryDeps = { callModel: mock(async function* () { yield assistantToolUseMessage() - }), + }) as QueryDeps['callModel'], microcompact: mock(async (input: Message[]) => ({ messages: input, - })), + })) as QueryDeps['microcompact'], autocompact: mock(async () => ({ wasCompacted: false, - })), + })) as QueryDeps['autocompact'], uuid: () => 'test-uuid', - } as never + } + const { query } = await loadQuery() const { terminal } = await drain( query({ messages: [userMessage('hello')], @@ -332,17 +486,18 @@ test('persisted breaker state does not block when auto-compact is disabled', asy const callModel = mock(async function* () { yield assistantToolUseMessage() }) - const deps = { - callModel, + const deps: QueryDeps = { + callModel: callModel as QueryDeps['callModel'], microcompact: mock(async (input: Message[]) => ({ messages: input, - })), + })) as QueryDeps['microcompact'], autocompact: mock(async () => ({ wasCompacted: false, - })), + })) as QueryDeps['autocompact'], uuid: () => 'test-uuid', - } as never + } + const { query } = await loadQuery() const { yielded, terminal } = await drain( query({ messages: [overAutoCompactThresholdMessage()], @@ -378,13 +533,13 @@ test('breaker metadata tracking callback publishes a fresh object', async () => lastFailureAtMs: 5_000, } const trackingUpdates: AutoCompactTrackingState[] = [] - const deps = { + const deps: QueryDeps = { callModel: mock(() => { throw new Error('model should not be called while autocompact cools down') - }), + }) as QueryDeps['callModel'], microcompact: mock(async (input: Message[]) => ({ messages: input, - })), + })) as QueryDeps['microcompact'], autocompact: mock(async () => ({ wasCompacted: false, consecutiveFailures: MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES, @@ -392,10 +547,11 @@ test('breaker metadata tracking callback publishes a fresh object', async () => lastFailureAtMs: 15_000, circuitBreakerActive: true, circuitBreakerTripped: true, - })), + })) as QueryDeps['autocompact'], uuid: () => 'test-uuid', - } as never + } + const { query } = await loadQuery() const { terminal } = await drain( query({ messages: [overAutoCompactThresholdMessage()], diff --git a/src/utils/config.ts b/src/utils/config.ts index 81ea0b019..09b00696e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -740,7 +740,9 @@ function createDefaultGlobalConfig(): GlobalConfig { providerProfiles: [], openaiAdditionalModelOptionsCacheByProfile: {}, knowledgeGraphEnabled: true, - maxMessagesCompactionThreshold: 'off', + // Omitted by default so callers can distinguish "unset" from an explicit + // persisted "off"; normalizeMaxMessagesCompactionThreshold keeps the + // effective default disabled. } return config } @@ -1150,11 +1152,17 @@ registerCleanup(async () => { * @internal */ function migrateConfigFields(config: GlobalConfig): GlobalConfig { + const { maxMessagesCompactionThreshold, ...restConfig } = config const normalizedConfig = { - ...config, - maxMessagesCompactionThreshold: normalizeMaxMessagesCompactionThreshold( - config.maxMessagesCompactionThreshold, - ), + ...restConfig, + ...(maxMessagesCompactionThreshold === undefined + ? {} + : { + maxMessagesCompactionThreshold: + normalizeMaxMessagesCompactionThreshold( + maxMessagesCompactionThreshold, + ), + }), } // Already migrated