fix(compaction): skip microcompact when compaction is off (#1800)

* fix(compaction): respect disabled microcompact setting

Skip automatic query-loop microcompact when the message-count compaction threshold is explicitly set to off, while preserving default, numeric, and explicit compact behavior.

* test(query): isolate auto-compact config regression

* test(query): type auto-compact deps fixture
This commit is contained in:
Bogdan
2026-06-28 06:16:42 +08:00
committed by GitHub
parent ab8645da9f
commit 320d63c812
3 changed files with 233 additions and 58 deletions
+21 -10
View File
@@ -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)
+199 -43
View File
@@ -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<QueryDeps['autocompact']>
// 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<T, TReturn>(
}
}
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()],
+13 -5
View File
@@ -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