mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
+21
-10
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user