Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
chioarub marked this conversation as resolved.
Resolved
: undefined
queryCheckpoint('query_microcompact_end')

Expand Down Expand Up @@ -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)
Expand Down
178 changes: 165 additions & 13 deletions src/query/autoCompactCooldown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,30 @@ 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'

// 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,
Expand All @@ -24,32 +35,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)) {
Expand Down Expand Up @@ -151,6 +182,122 @@ 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,
}))
return {
deps: {
callModel,
microcompact,
autocompact,
uuid: () => 'test-uuid',
} as never,
callModel,
microcompact,
autocompact,
}
}

async function runSuccessfulQuery(
deps: never,
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
Expand Down Expand Up @@ -180,6 +327,7 @@ test('active auto-compact cooldown blocks before model call with cooldown guidan
uuid: () => 'test-uuid',
} as never

const { query } = await loadQuery()
const { yielded, terminal } = await drain(
query({
messages,
Expand Down Expand Up @@ -257,6 +405,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)
Expand Down Expand Up @@ -293,6 +442,7 @@ test('post-compact turn tracking callback publishes a fresh object', async () =>
uuid: () => 'test-uuid',
} as never

const { query } = await loadQuery()
const { terminal } = await drain(
query({
messages: [userMessage('hello')],
Expand Down Expand Up @@ -343,6 +493,7 @@ test('persisted breaker state does not block when auto-compact is disabled', asy
uuid: () => 'test-uuid',
} as never

const { query } = await loadQuery()
const { yielded, terminal } = await drain(
query({
messages: [overAutoCompactThresholdMessage()],
Expand Down Expand Up @@ -396,6 +547,7 @@ test('breaker metadata tracking callback publishes a fresh object', async () =>
uuid: () => 'test-uuid',
} as never

const { query } = await loadQuery()
const { terminal } = await drain(
query({
messages: [overAutoCompactThresholdMessage()],
Expand Down
18 changes: 13 additions & 5 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down