fix(query): bound per-turn latency growth in long REPL sessions (#1949) (#1952)

* fix(query): bound per-turn latency growth in long REPL sessions (#1949)

Addresses the progressive latency regression where consecutive prompts in a
single session grow non-linearly (2nd prompt ~10s, 3rd 10+ min) due to
unbounded message accumulation with no proactive compaction and no per-prompt
turn cap on the main thread.

- Cap the interactive REPL main thread at 50 turns per prompt (DEFAULT_REPL_MAX_TURNS).
  Headless/print mode and the SDK are unchanged (--max-turns flag / SDK callers
  still control it), preserving the SDK API contract.
- Default maxMessagesCompactionThreshold to '200' so message-count compaction
  runs well before the context window fills, instead of 'off'.
- Lower the auto-compact threshold buffer from 13k -> 30k so compaction fires
  earlier with less accumulated history. The effective-context floor buffer is
  kept at 13k and getAutoCompactThreshold() falls back to it for small-context
  models, so the threshold can never go negative (no #635 regression).

Test updates: isolate the hard-cap override test from the new 200-message
default, and correct an outdated constant reference in the autoCompact test.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(query): repair REPL latency guard

* fix(query): cover resume and default guard paths

* fix(query): enforce cap across interactive paths

* docs(compaction): clarify disabled message limits

* fix(query): retain explicit message thresholds

* fix(query): enforce explicit threshold recovery

* fix(query): honor legacy active-message limit

* fix(doctor): report effective message compaction limit

* fix(config): share message threshold validation

* test(doctor): cover disabled message compaction

* fix(compact): preserve latency guard coverage

* test(repl): exercise turn cap defaults

* fix(compact): honor disabled default message guard

* fix(swarm): honor disabled auto compaction

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
JATMN
2026-07-15 23:06:53 +08:00
committed by GitHub
co-authored by Claude
parent 1d053b2a3d
commit a32781537f
21 changed files with 480 additions and 69 deletions
+9 -8
View File
@@ -665,21 +665,22 @@ would otherwise prevent it. Set `OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP=0`
only when you need to suppress that safety cap for diagnostics.
If you frequently resume long sessions that accumulate hundreds of small
tool-result messages with negligible token cost, you can opt in to message-count
tool-result messages with negligible token cost, adjust message-count
compaction via the in-app `/config` command:
```text
/config
```
Select **Message-count compaction** and choose a threshold (`100`, `200`, `500`,
or `1000`). Setting it to `off` (default) leaves only the built-in hard cap.
Message-count compaction defaults to `200` messages. Select
**Message-count compaction** to choose a different threshold (`100`, `500`, or
`1000`), or set it to `off` to disable the setting's proactive guard. The
built-in hard cap remains, and an `OPENCLAUDE_MAX_ACTIVE_MESSAGES` override
remains active when configured.
This setting is intended for power users debugging specific edge cases. Most
users should leave it at `off`.
The legacy `OPENCLAUDE_MAX_ACTIVE_MESSAGES` environment variable is still
honored when the setting is `off`. `OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP`
The legacy `OPENCLAUDE_MAX_ACTIVE_MESSAGES` environment variable is honored
when the setting is unset or `off`. An explicit numeric setting takes
precedence over that legacy value. `OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP`
can override the safety cap; set it to `0` only for diagnostics.
### Long-session memory guard validation
+75 -3
View File
@@ -658,7 +658,21 @@ describe('system-check WebSearch diagnostics', () => {
})
describe('system-check memory guard diagnostics', () => {
test('reports safe default auto-compact and hard-cap guards', () => {
test('reports explicit off without a legacy message-count override', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: 'off',
env: {},
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold off; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
})
test('reports the effective default auto-compact and hard-cap guards', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: undefined,
@@ -668,7 +682,7 @@ describe('system-check memory guard diagnostics', () => {
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold off; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
detail: `Enabled; message-count threshold 200; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
expect(results).toContainEqual({
ok: true,
@@ -679,6 +693,36 @@ describe('system-check memory guard diagnostics', () => {
.toMatchObject({ ok: true })
})
test('reports the legacy message-count override when the setting is unset or off', () => {
for (const maxMessagesCompactionThreshold of [undefined, 'off']) {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold,
env: { OPENCLAUDE_MAX_ACTIVE_MESSAGES: '500' },
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold 500; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
}
})
test('reports an explicit message-count setting ahead of the legacy override', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: '100',
env: { OPENCLAUDE_MAX_ACTIVE_MESSAGES: '500' },
})
expect(results).toContainEqual({
ok: true,
label: 'Auto-compact guard',
detail: `Enabled; message-count threshold 100; hard cap ${DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP}.`,
})
})
test('falls back to the default hard cap when the override is malformed', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
@@ -725,7 +769,35 @@ describe('system-check memory guard diagnostics', () => {
ok: false,
label: 'Auto-compact guard',
detail:
'settings disabled; DISABLE_COMPACT is set; DISABLE_AUTO_COMPACT is set',
'settings disabled; DISABLE_COMPACT is set; DISABLE_AUTO_COMPACT is set; message-count threshold 500 remains active.',
})
})
test('reports an explicit message-count guard when token auto-compact is disabled', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: '100',
env: { DISABLE_AUTO_COMPACT: '1' },
})
expect(results[0]).toEqual({
ok: false,
label: 'Auto-compact guard',
detail: 'DISABLE_AUTO_COMPACT is set; message-count threshold 100 remains active.',
})
})
test('does not report the unset default as active when auto-compact is disabled', () => {
const results = buildMemoryGuardChecks({
autoCompactEnabled: true,
maxMessagesCompactionThreshold: undefined,
env: { DISABLE_AUTO_COMPACT: '1' },
})
expect(results[0]).toEqual({
ok: false,
label: 'Auto-compact guard',
detail: 'DISABLE_AUTO_COMPACT is set',
})
})
+36 -12
View File
@@ -36,6 +36,10 @@ import {
DEFAULT_MAX_ACTIVE_MESSAGES_HARD_CAP,
getMaxActiveMessagesHardCap,
} from '../src/utils/maxActiveMessages.js'
import {
isValidMaxMessagesCompactionThreshold,
normalizeMaxMessagesCompactionThreshold,
} from '../src/utils/config.js'
import {
getAvailableProviders,
getProviderChain,
@@ -120,28 +124,48 @@ export function buildMemoryGuardChecks(
input.autoCompactEnabled && !disableCompact && !disableAutoCompact
const hardCapOverride = env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP
const hardCap = getMaxActiveMessagesHardCap(env)
const configuredLimit =
input.maxMessagesCompactionThreshold &&
input.maxMessagesCompactionThreshold !== 'off'
? input.maxMessagesCompactionThreshold
: undefined
const legacyLimit = parsePositiveInteger(env.OPENCLAUDE_MAX_ACTIVE_MESSAGES)
const configuredLimit =
((input.maxMessagesCompactionThreshold === undefined ||
input.maxMessagesCompactionThreshold === 'off') &&
legacyLimit > 0)
? legacyLimit
: normalizeMaxMessagesCompactionThreshold(
input.maxMessagesCompactionThreshold,
)
const hasExplicitMessageCountGuard =
input.maxMessagesCompactionThreshold !== undefined &&
isValidMaxMessagesCompactionThreshold(
input.maxMessagesCompactionThreshold,
) &&
input.maxMessagesCompactionThreshold !== 'off'
const hasLegacyMessageCountGuard =
(input.maxMessagesCompactionThreshold === undefined ||
input.maxMessagesCompactionThreshold === 'off') &&
legacyLimit > 0
const memoryBudget = parsePositiveInteger(env.OPENCLAUDE_MAX_MEMORY_MB) || 1536
const hasIndependentMessageCountGuard =
configuredLimit !== 'off' &&
(hasExplicitMessageCountGuard || hasLegacyMessageCountGuard)
const autoCompactDisabledReason =
[
input.autoCompactEnabled ? undefined : 'settings disabled',
disableCompact ? 'DISABLE_COMPACT is set' : undefined,
disableAutoCompact ? 'DISABLE_AUTO_COMPACT is set' : undefined,
].filter(Boolean).join('; ') || 'Disabled by configuration.'
results.push(
autoCompactAvailable
? pass(
'Auto-compact guard',
`Enabled; message-count threshold ${configuredLimit ?? (legacyLimit > 0 ? legacyLimit : 'off')}; hard cap ${hardCap === 0 ? 'disabled' : hardCap}.`,
`Enabled; message-count threshold ${configuredLimit}; hard cap ${hardCap === 0 ? 'disabled' : hardCap}.`,
)
: fail(
'Auto-compact guard',
[
input.autoCompactEnabled ? undefined : 'settings disabled',
disableCompact ? 'DISABLE_COMPACT is set' : undefined,
disableAutoCompact ? 'DISABLE_AUTO_COMPACT is set' : undefined,
].filter(Boolean).join('; ') ||
'Disabled by configuration.',
autoCompactDisabledReason +
(hasIndependentMessageCountGuard
? `; message-count threshold ${configuredLimit} remains active.`
: ''),
),
)
+2 -2
View File
@@ -294,7 +294,7 @@ export function Config({
}, {
id: 'maxMessagesCompactionThreshold',
label: 'Message-count compaction',
value: globalConfig.maxMessagesCompactionThreshold ?? 'off',
value: normalizeMaxMessagesCompactionThreshold(globalConfig.maxMessagesCompactionThreshold),
options: [...MAX_MESSAGES_COMPACTION_THRESHOLDS],
type: 'enum' as const,
onChange(maxMessagesCompactionThreshold: string) {
@@ -1242,7 +1242,7 @@ export function Config({
formattedChanges.push(`${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`);
}
if (globalConfig.maxMessagesCompactionThreshold !== initialConfig.current.maxMessagesCompactionThreshold) {
const threshold = globalConfig.maxMessagesCompactionThreshold ?? 'off';
const threshold = normalizeMaxMessagesCompactionThreshold(globalConfig.maxMessagesCompactionThreshold);
formattedChanges.push(threshold === 'off' ? 'Disabled message-count compaction' : `Set message-count compaction to ${threshold}`);
}
if (globalConfig.toolHistoryCompressionEnabled !== initialConfig.current.toolHistoryCompressionEnabled) {
@@ -121,6 +121,10 @@ export function AttachmentMessage({
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/skill_discovery handled before switch
switch (attachment.type) {
case 'max_turns_reached':
return <Line>
<Text color="warning">Reached the maximum number of turns ({attachment.maxTurns}).</Text>
</Line>;
case 'directory':
return <Line>
Listed directory <Text bold>{attachment.displayPath + sep}</Text>
@@ -43,7 +43,6 @@ const NULL_RENDERING_TYPES = [
'token_usage',
'ultrathink_effort',
'ultracode_mode',
'max_turns_reached',
'task_reminder',
'auto_mode',
'auto_mode_exit',
+1 -1
View File
@@ -3045,7 +3045,7 @@ async function run(): Promise<CommanderCommand> {
strictMcpConfig,
systemPrompt,
appendSystemPrompt,
thinkingConfig
thinkingConfig,
};
// Shared context for processResumedConversation calls
+49 -10
View File
@@ -74,7 +74,9 @@ import {
startRelevantMemoryPrefetch,
} from './utils/attachments.js'
import {
getMaxActiveMessagesHardCap,
isAboveMaxActiveMessagesLimit,
parseMaxActiveMessagesLimit,
resolveMaxActiveMessagesLimit,
} from './utils/maxActiveMessages.js'
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -125,7 +127,9 @@ import {
import { AGENT_STEP_LIMIT_TOOL_RESULT_PREFIX } from './query/agentStepLimit.js'
import { buildQueryConfig } from './query/config.js'
import {
MAX_MESSAGES_COMPACTION_THRESHOLDS,
getGlobalConfig,
isValidMaxMessagesCompactionThreshold,
normalizeMaxMessagesCompactionThreshold,
} from './utils/config.js'
import { productionDeps, type QueryDeps } from './query/deps.js'
@@ -813,18 +817,39 @@ async function* queryLoop(
// compaction and forcing would deadlock via recursive autocompaction.
const canForceCompact =
querySource !== 'compact' && querySource !== 'session_memory'
// An unset UI setting keeps the legacy environment override. Without that
// override, enforce the new effective 200-message default.
const hasValidLegacyActiveMessageLimit =
parseMaxActiveMessagesLimit(process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES) > 0
const maxMessagesLimitSetting =
configuredMaxMessagesCompactionThreshold === undefined &&
hasValidLegacyActiveMessageLimit
? undefined
: maxMessagesCompactionThreshold
const hasExplicitMessageCountThreshold =
configuredMaxMessagesCompactionThreshold !== undefined &&
isValidMaxMessagesCompactionThreshold(configuredMaxMessagesCompactionThreshold) &&
configuredMaxMessagesCompactionThreshold !== 'off'
const hasActiveMessageLimitOverride =
hasExplicitMessageCountThreshold ||
((configuredMaxMessagesCompactionThreshold === undefined ||
configuredMaxMessagesCompactionThreshold === 'off') &&
hasValidLegacyActiveMessageLimit)
const activeMessageLimit = canForceCompact
? resolveMaxActiveMessagesLimit(
maxMessagesCompactionThreshold,
maxMessagesLimitSetting,
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES,
)
: 0
if (canForceCompact) {
if (
isAboveMaxActiveMessagesLimit(
messagesForQuery.length,
activeMessageLimit,
)
isAboveMaxActiveMessagesLimit(messagesForQuery.length, activeMessageLimit) &&
(isAutoCompactEnabled() ||
hasActiveMessageLimitOverride ||
isAboveMaxActiveMessagesLimit(
messagesForQuery.length,
getMaxActiveMessagesHardCap(),
))
) {
tracking = {
...(tracking ?? { compacted: false, turnId: '', turnCounter: 0 }),
@@ -1167,6 +1192,14 @@ async function* queryLoop(
// cooling down or otherwise exhausted and context or message count is still
// over the safety threshold, block immediately with a clear message instead
// of burning an oversized API call.
const isAboveActiveMessageHardCap = isAboveMaxActiveMessagesLimit(
messagesForQuery.length,
getMaxActiveMessagesHardCap(),
)
const shouldEnforceActiveMessageLimit =
(!collapseOwnsIt && isAutoCompactEnabled()) ||
hasActiveMessageLimitOverride ||
isAboveActiveMessageHardCap
if (
tracking?.consecutiveFailures !== undefined &&
tracking.consecutiveFailures >=
@@ -1181,14 +1214,16 @@ async function* queryLoop(
tokenUsage,
model,
)
const isAboveActiveMessageSafetyLimit =
isAboveMaxActiveMessagesLimit(
messagesForQuery.length,
activeMessageLimit,
) && shouldEnforceActiveMessageLimit
const isAboveBreakerThreshold =
isAboveAutoCompactThreshold ||
((circuitBreakerActive === true || circuitBreakerTripped === true) &&
tokenUsage >= getAutoCompactThreshold(model)) ||
isAboveMaxActiveMessagesLimit(
messagesForQuery.length,
activeMessageLimit,
)
isAboveActiveMessageSafetyLimit
if (isAboveBreakerThreshold) {
const nowMs = Date.now()
const retryDelayMs =
@@ -1211,7 +1246,11 @@ async function* queryLoop(
}
if (
isAboveMaxActiveMessagesLimit(messagesForQuery.length, activeMessageLimit)
shouldEnforceActiveMessageLimit &&
isAboveMaxActiveMessagesLimit(
messagesForQuery.length,
activeMessageLimit,
)
) {
yield createAssistantAPIErrorMessage({
content:
+97
View File
@@ -368,6 +368,96 @@ test('default active-message hard cap forces compaction', async () => {
expect(seenTracking[0]?.forceReason).toBe('message-count')
})
test('unset message threshold forces compaction at the 200-message default', async () => {
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(201))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBe('message-count')
})
test('invalid legacy message threshold keeps the 200-message default', async () => {
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES = 'not-a-number'
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(201))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBe('message-count')
})
test('disabled auto-compact leaves the default message threshold inactive', async () => {
process.env.DISABLE_AUTO_COMPACT = '1'
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(201))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBeUndefined()
})
test('disabled auto-compact ignores an invalid persisted message threshold', async () => {
saveGlobalConfig(current => ({
...current,
maxMessagesCompactionThreshold:
'not-a-threshold' as MaxMessagesCompactionThreshold,
}))
process.env.DISABLE_AUTO_COMPACT = '1'
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(201))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBeUndefined()
})
test('disabled auto-compact preserves an explicit message threshold', async () => {
saveGlobalConfig(current => ({
...current,
maxMessagesCompactionThreshold: '100',
}))
process.env.DISABLE_AUTO_COMPACT = '1'
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(101))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBe('message-count')
})
test('disabled auto-compact preserves a legacy message threshold', async () => {
process.env.DISABLE_AUTO_COMPACT = '1'
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES = '100'
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(101))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBe('message-count')
})
test('explicit off preserves a legacy message threshold', async () => {
saveGlobalConfig(current => ({
...current,
maxMessagesCompactionThreshold: 'off',
}))
process.env.DISABLE_AUTO_COMPACT = '1'
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES = '100'
const { terminal, callModel, seenTracking } =
await runMessageCountHardCapQuery(manySmallMessages(101))
expect(terminal.reason).toBe('max_turns')
expect(callModel).toHaveBeenCalledTimes(1)
expect(seenTracking[0]?.forceReason).toBe('message-count')
})
test('long-session smoke keeps repeated over-cap turns bounded before provider calls', async () => {
const seenProviderMessageCounts: number[] = []
const seenTracking: Array<AutoCompactTrackingState | undefined> = []
@@ -440,6 +530,13 @@ test('invalid active-message hard cap override keeps default safety cap', async
})
test('explicit zero active-message hard cap override disables safety cap', async () => {
// Isolate the hard-cap override: with the 200-message-count default active,
// a 1001-message history would otherwise force message-count compaction, so
// disable message-count compaction explicitly to test only the hard cap.
saveGlobalConfig(current => ({
...current,
maxMessagesCompactionThreshold: 'off',
}))
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES_HARD_CAP = '0'
const { terminal, callModel, seenTracking } =
+12 -3
View File
@@ -38,6 +38,7 @@ import { logForDebugging } from '../utils/debug.js';
import { QueryGuard } from '../utils/QueryGuard.js';
import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js';
import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js';
import { resolveReplMaxTurns } from './replMaxTurns.js';
import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js';
import { isEnvTruthy } from '../utils/envUtils.js';
import { formatTokens, truncateToWidth } from '../utils/format.js';
@@ -569,6 +570,8 @@ function summarizeActiveOperations(snapshot: QueryActiveOperationSnapshot): stri
function logQueryLifecycle(event: string, context: QueryLifecycleContext, extras = ''): void {
logForDebugging(formatQueryLifecycleLogMessage(event, context, extras));
}
// Default per-prompt cap for every local interactive REPL entrypoint. Headless
// and SDK callers retain their explicit maxTurns contracts.
export type Props = {
commands: Command[];
debug: boolean;
@@ -617,6 +620,8 @@ export type Props = {
thinkingConfig: ThinkingConfig;
// Model to fallback to when primary model returns overloaded errors (529)
fallbackModel?: string;
// Bound a single interactive prompt's sequential tool-use turns.
maxTurns?: number;
};
export type Screen = 'prompt' | 'transcript';
export function REPL({
@@ -646,8 +651,10 @@ export function REPL({
directConnectConfig,
sshSession,
thinkingConfig,
fallbackModel
fallbackModel,
maxTurns: maxTurnsProp
}: Props): React.ReactNode {
const maxTurns = resolveReplMaxTurns(maxTurnsProp)
const isRemoteSession = !!remoteSessionConfig;
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+
@@ -2809,6 +2816,7 @@ export function REPL({
canUseTool,
toolUseContext,
fallbackModel,
maxTurns,
querySource: getQuerySourceForREPL(),
autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId),
onAutoCompactTrackingChange: tracking => {
@@ -2820,7 +2828,7 @@ export function REPL({
agentDefinition: mainThreadAgentDefinition
});
})();
}, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel]);
}, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, maxTurns]);
const {
handleBackgroundSession
} = useSessionBackgrounding({
@@ -3041,6 +3049,7 @@ export function REPL({
toolUseContext,
querySource: getQuerySourceForREPL(),
fallbackModel,
maxTurns,
autoCompactTracking: queryAutoCompactTracking,
onAutoCompactTrackingChange: tracking => {
if (setAutoCompactTrackingForSessionIfUnchanged(querySessionId, expectedAutoCompactTracking, tracking)) {
@@ -3066,7 +3075,7 @@ export function REPL({
// Signal that a query turn has completed successfully
await onTurnComplete?.(messagesRef.current);
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard]);
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, maxTurns, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard]);
const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>, input?: string, effort?: EffortValue): Promise<void | false> => {
// If this is a teammate, mark them as active when starting a turn
if (isAgentSwarmsEnabled()) {
+3 -1
View File
@@ -56,6 +56,7 @@ type Props = {
filterByPr?: boolean | number | string;
thinkingConfig: ThinkingConfig;
fallbackModel?: string;
maxTurns?: number;
onTurnComplete?: (messages: Message[]) => void | Promise<void>;
};
export function ResumeConversation({
@@ -78,6 +79,7 @@ export function ResumeConversation({
filterByPr,
thinkingConfig,
fallbackModel,
maxTurns,
onTurnComplete
}: Props): React.ReactNode {
const {
@@ -286,7 +288,7 @@ export function ResumeConversation({
return <CrossProjectMessage command={crossProjectCommand} />;
}
if (resumeData) {
return <REPL debug={debug} commands={commands} initialTools={initialTools} initialMessages={resumeData.messages} initialFileHistorySnapshots={resumeData.fileHistorySnapshots} initialContentReplacements={resumeData.contentReplacements} initialAgentName={resumeData.agentName} initialAgentColor={resumeData.agentColor} mcpClients={mcpClients} dynamicMcpConfig={dynamicMcpConfig} strictMcpConfig={strictMcpConfig} systemPrompt={systemPrompt} appendSystemPrompt={appendSystemPrompt} mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition} baseMainLoopModel={baseMainLoopModel} hasExplicitModelOverride={hasExplicitModelOverride} autoConnectIdeFlag={autoConnectIdeFlag} disableSlashCommands={disableSlashCommands} thinkingConfig={thinkingConfig} fallbackModel={fallbackModel} onTurnComplete={onTurnComplete} />;
return <REPL debug={debug} commands={commands} initialTools={initialTools} initialMessages={resumeData.messages} initialFileHistorySnapshots={resumeData.fileHistorySnapshots} initialContentReplacements={resumeData.contentReplacements} initialAgentName={resumeData.agentName} initialAgentColor={resumeData.agentColor} mcpClients={mcpClients} dynamicMcpConfig={dynamicMcpConfig} strictMcpConfig={strictMcpConfig} systemPrompt={systemPrompt} appendSystemPrompt={appendSystemPrompt} mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition} baseMainLoopModel={baseMainLoopModel} hasExplicitModelOverride={hasExplicitModelOverride} autoConnectIdeFlag={autoConnectIdeFlag} disableSlashCommands={disableSlashCommands} thinkingConfig={thinkingConfig} fallbackModel={fallbackModel} maxTurns={maxTurns} onTurnComplete={onTurnComplete} />;
}
if (loading) {
return <Box>
+5
View File
@@ -0,0 +1,5 @@
export const DEFAULT_REPL_MAX_TURNS = 50
export function resolveReplMaxTurns(maxTurns?: number): number {
return maxTurns ?? DEFAULT_REPL_MAX_TURNS
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { DEFAULT_REPL_MAX_TURNS, resolveReplMaxTurns } from './replMaxTurns.js'
const screenDir = import.meta.dirname
function readScreen(name: string): string {
return readFileSync(join(screenDir, name), 'utf8')
}
function objectBody(source: string, marker: RegExp): string {
const match = source.match(marker)
expect(match).not.toBeNull()
const start = match!.index! + match![0].length - 1
let depth = 0
for (let index = start; index < source.length; index++) {
if (source[index] === '{') depth++
if (source[index] === '}') {
depth--
if (depth === 0) return source.slice(start, index + 1)
}
}
throw new Error(`Unclosed object after ${marker}`)
}
describe('interactive REPL max-turn cap', () => {
test('supplies the local interactive default at runtime', () => {
expect(DEFAULT_REPL_MAX_TURNS).toBe(50)
expect(resolveReplMaxTurns()).toBe(50)
})
test('preserves an explicit interactive cap at runtime', () => {
expect(resolveReplMaxTurns(7)).toBe(7)
})
test('passes the resolved cap to foreground and background queries', () => {
const source = readScreen('REPL.tsx')
const foreground = objectBody(source, /for await \(const event of query\(\{/)
const background = objectBody(source, /queryParams:\s*\{/)
expect(foreground).toContain('maxTurns,')
expect(background).toContain('maxTurns,')
})
test('passes the cap from the resume selector into REPL', () => {
const source = readScreen('ResumeConversation.tsx')
const repl = source.slice(source.indexOf('<REPL'), source.indexOf('/>', source.indexOf('<REPL')) + 2)
expect(repl).toContain('maxTurns={maxTurns}')
})
})
+35 -1
View File
@@ -179,7 +179,7 @@ describe('getEffectiveContextWindowSize', () => {
try {
const effective = getEffectiveContextWindowSize('some-unknown-3p-model')
expect(effective).toBeGreaterThan(0)
// 21k = CAPPED_DEFAULT_MAX_TOKENS (8k) + AUTOCOMPACT_BUFFER_TOKENS (13k).
// 21k = CAPPED_DEFAULT_MAX_TOKENS (8k) + AUTOCOMPACT_FLOOR_BUFFER_TOKENS (13k).
// Covers the anti-regression intent of issue #635 without assuming
// the GrowthBook flag state.
expect(effective).toBeGreaterThanOrEqual(21_000)
@@ -243,6 +243,40 @@ describe('getAutoCompactThreshold', () => {
restoreEnv()
}
})
test('keeps the floor buffer for constrained context windows', async () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '30000'
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '20000'
const { getAutoCompactThreshold } = await importAutoCompact()
// The effective window is floor-raised to 33k in this configuration.
// Selecting the 30k buffer here would compact after only 3k tokens.
expect(getAutoCompactThreshold('claude-sonnet-4')).toBe(20_000)
})
test('keeps compaction and warning thresholds usable across mid-sized windows', async () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '64000'
const { calculateTokenWarningState, getAutoCompactThreshold } =
await importAutoCompact()
// The effective window is 44k. Do not consume so much headroom that the
// 20k warning/error buffer makes a fresh conversation immediately warn.
expect(getAutoCompactThreshold('claude-sonnet-4')).toBe(30_000)
expect(
calculateTokenWarningState(0, 'claude-sonnet-4').isAboveWarningThreshold,
).toBe(false)
})
test('does not lower the threshold when a configured window grows', async () => {
const { getAutoCompactThreshold } = await importAutoCompact()
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '62999'
const smallerWindowThreshold = getAutoCompactThreshold('claude-sonnet-4')
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '63000'
const largerWindowThreshold = getAutoCompactThreshold('claude-sonnet-4')
expect(largerWindowThreshold).toBeGreaterThanOrEqual(smallerWindowThreshold)
})
})
describe('getAutoCompactFailureCooldownMs', () => {
+33 -5
View File
@@ -49,8 +49,12 @@ export function getEffectiveContextWindowSize(model: string): number {
// Floor: effective context must be at least the summary reservation plus a
// usable buffer. If it goes lower, the auto-compact threshold becomes
// negative and fires on every message (issue #635).
const autocompactBuffer = 13_000 // must match AUTOCOMPACT_BUFFER_TOKENS
// negative and fires on every message (issue #635). This floor buffer is
// intentionally decoupled from AUTOCOMPACT_BUFFER_TOKENS: the latter is the
// (larger) threshold buffer used by getAutoCompactThreshold(), while this
// stays at the conservative 13k so getEffectiveContextWindowSize() —
// also consumed by tool-history compression — is unchanged (issue #1949).
const autocompactBuffer = AUTOCOMPACT_FLOOR_BUFFER_TOKENS
const effectiveContext = contextWindow - reservedTokensForSummary
return Math.max(effectiveContext, reservedTokensForSummary + autocompactBuffer)
}
@@ -74,7 +78,20 @@ export type AutoCompactTrackingState = {
forceReason?: 'memory-pressure' | 'message-count'
}
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
// Threshold buffer: auto-compact fires when token usage reaches this far below
// the effective context window. Bumped from 13_000 -> 30_000 so compaction runs
// earlier and with less accumulated history, bounding per-turn latency growth
// in a single session (issue #1949). Kept below the effective-context floor
// (AUTOCOMPACT_FLOOR_BUFFER_TOKENS) for large-context models; for small-context
// models getAutoCompactThreshold() falls back to the floor buffer so the
// threshold can never go negative (issue #635).
export const AUTOCOMPACT_BUFFER_TOKENS = 30_000
// Conservative floor buffer for getEffectiveContextWindowSize(). Must guarantee
// a non-negative auto-compact threshold for small-context models, so it stays at
// the pre-#1949 value of 13_000 and is decoupled from AUTOCOMPACT_BUFFER_TOKENS.
const AUTOCOMPACT_FLOOR_BUFFER_TOKENS = 13_000
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
@@ -170,8 +187,19 @@ export function resolveAutoCompactCircuitBreakerState(args: {
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
const autocompactThreshold =
effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
// Increase the buffer gradually between the old 13k and new 30k values.
// This keeps the threshold monotonic and preserves the 20k warning/error
// headroom consumed by calculateTokenWarningState(). A direct switch would
// make a one-token window increase cause an earlier compact, and can make
// the warning threshold negative for mid-sized context windows.
const buffer = Math.min(
AUTOCOMPACT_BUFFER_TOKENS,
Math.max(
AUTOCOMPACT_FLOOR_BUFFER_TOKENS,
effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS,
),
)
const autocompactThreshold = effectiveContextWindow - buffer
// Override for easier testing of autocompact
const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
+2 -2
View File
@@ -10,7 +10,7 @@ import { getCommandName } from '../commands.js'
import { getSystemContext } from '../context.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import {
AUTOCOMPACT_BUFFER_TOKENS,
getAutoCompactThreshold,
getEffectiveContextWindowSize,
isAutoCompactEnabled,
MANUAL_COMPACT_BUFFER_TOKENS,
@@ -1056,7 +1056,7 @@ export async function analyzeContextUsage(
// Check if autocompact is enabled and calculate threshold
const isAutoCompact = isAutoCompactEnabled()
const autoCompactThreshold = isAutoCompact
? getEffectiveContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS
? getAutoCompactThreshold(model)
: undefined
// Create categories
+17 -7
View File
@@ -197,14 +197,20 @@ export const MAX_MESSAGES_COMPACTION_THRESHOLDS = [
export type MaxMessagesCompactionThreshold =
(typeof MAX_MESSAGES_COMPACTION_THRESHOLDS)[number]
export function normalizeMaxMessagesCompactionThreshold(
export function isValidMaxMessagesCompactionThreshold(
value: unknown,
): MaxMessagesCompactionThreshold {
): value is MaxMessagesCompactionThreshold {
return MAX_MESSAGES_COMPACTION_THRESHOLDS.includes(
value as MaxMessagesCompactionThreshold,
)
}
export function normalizeMaxMessagesCompactionThreshold(
value: unknown,
): MaxMessagesCompactionThreshold {
return isValidMaxMessagesCompactionThreshold(value)
? (value as MaxMessagesCompactionThreshold)
: 'off'
: '200'
}
export type OutputStyle = string
@@ -681,7 +687,7 @@ export type GlobalConfig = {
logoColor?: string
// Message-count-based compaction threshold. Set via /config.
// 'off' = disabled (default). Otherwise, one of '100', '200', '500', '1000'.
// 'off' = disabled. Otherwise, one of '100', '200', '500', '1000'.
// When enabled, triggers forced compaction if the message count exceeds the
// chosen threshold, regardless of token usage.
maxMessagesCompactionThreshold?: MaxMessagesCompactionThreshold
@@ -741,8 +747,9 @@ function createDefaultGlobalConfig(): GlobalConfig {
openaiAdditionalModelOptionsCacheByProfile: {},
knowledgeGraphEnabled: true,
// Omitted by default so callers can distinguish "unset" from an explicit
// persisted "off"; normalizeMaxMessagesCompactionThreshold keeps the
// effective default disabled.
// persisted "off"; normalizeMaxMessagesCompactionThreshold resolves an
// unset value to the effective default of '200' (message-count compaction
// enabled at 200 messages) to bound per-turn latency growth (issue #1949).
}
return config
}
@@ -1153,9 +1160,12 @@ registerCleanup(async () => {
*/
function migrateConfigFields(config: GlobalConfig): GlobalConfig {
const { maxMessagesCompactionThreshold, ...restConfig } = config
const hasValidMaxMessagesCompactionThreshold =
maxMessagesCompactionThreshold !== undefined &&
isValidMaxMessagesCompactionThreshold(maxMessagesCompactionThreshold)
const normalizedConfig = {
...restConfig,
...(maxMessagesCompactionThreshold === undefined
...(!hasValidMaxMessagesCompactionThreshold
? {}
: {
maxMessagesCompactionThreshold:
+1
View File
@@ -36,6 +36,7 @@ test('explicit zero hard cap disables only the hard cap', () => {
expect(isAboveMaxActiveMessagesLimit(1001)).toBe(false)
expect(resolveMaxActiveMessagesLimit('100', undefined)).toBe(100)
expect(resolveMaxActiveMessagesLimit('off', '5')).toBe(5)
expect(resolveMaxActiveMessagesLimit(undefined, '5')).toBe(5)
})
test('configured and hard cap combine by choosing the tighter positive limit', () => {
+2 -2
View File
@@ -31,11 +31,11 @@ export function getMaxActiveMessagesHardCap(
}
export function resolveMaxActiveMessagesLimit(
configSetting: string,
configSetting: string | undefined,
envSetting: string | undefined,
): number {
const configuredLimit =
configSetting !== 'off'
configSetting !== undefined && configSetting !== 'off'
? parseMaxActiveMessagesLimit(configSetting)
: parseMaxActiveMessagesLimit(envSetting)
const hardCap = getMaxActiveMessagesHardCap()
+1
View File
@@ -3123,6 +3123,7 @@ You have exited auto mode. The user may now want to interact more directly. You
case 'hook_system_message':
case 'structured_output':
case 'hook_permission_decision':
case 'max_turns_reached':
return []
}
+44 -11
View File
@@ -23,7 +23,10 @@ import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { getAutoCompactThreshold } from '../../services/compact/autoCompact.js'
import {
getAutoCompactThreshold,
isAutoCompactEnabled,
} from '../../services/compact/autoCompact.js'
import {
buildPostCompactMessages,
compactConversation,
@@ -71,8 +74,15 @@ import { logForDebugging } from '../debug.js'
import { cloneFileStateCache } from '../fileStateCache.js'
import {
getMaxActiveMessagesHardCap,
shouldCompactActiveMessageHistory,
isAboveMaxActiveMessagesLimit,
parseMaxActiveMessagesLimit,
resolveMaxActiveMessagesLimit,
} from '../maxActiveMessages.js'
import {
getGlobalConfig,
isValidMaxMessagesCompactionThreshold,
normalizeMaxMessagesCompactionThreshold,
} from '../config.js'
import {
SUBAGENT_REJECT_MESSAGE,
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX,
@@ -1104,18 +1114,41 @@ export async function runInProcessTeammate(
// Check if compaction is needed before building context
let contextMessages = allMessages
const tokenCount = tokenCountWithEstimation(allMessages)
const activeMessageHardCap = getMaxActiveMessagesHardCap()
const configuredMessageThreshold =
getGlobalConfig().maxMessagesCompactionThreshold
const legacyMessageThreshold = parseMaxActiveMessagesLimit(
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES,
)
const hasExplicitMessageCountThreshold =
configuredMessageThreshold !== undefined &&
isValidMaxMessagesCompactionThreshold(configuredMessageThreshold) &&
configuredMessageThreshold !== 'off'
const hasLegacyMessageCountThreshold =
(configuredMessageThreshold === undefined ||
configuredMessageThreshold === 'off') &&
legacyMessageThreshold > 0
const shouldApplyMessageCountThreshold =
isAutoCompactEnabled() ||
hasExplicitMessageCountThreshold ||
hasLegacyMessageCountThreshold
const activeMessageLimit = shouldApplyMessageCountThreshold
? resolveMaxActiveMessagesLimit(
configuredMessageThreshold === undefined && legacyMessageThreshold > 0
? undefined
: normalizeMaxMessagesCompactionThreshold(configuredMessageThreshold),
process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES,
)
: getMaxActiveMessagesHardCap()
const tokenThreshold = getAutoCompactThreshold(
toolUseContext.options.mainLoopModel,
)
if (
shouldCompactActiveMessageHistory({
messageCount: allMessages.length,
tokenCount,
tokenThreshold,
activeMessageLimit: activeMessageHardCap,
})
) {
const shouldCompactForTokens =
isAutoCompactEnabled() && tokenCount > tokenThreshold
const shouldCompactForMessages = isAboveMaxActiveMessagesLimit(
allMessages.length,
activeMessageLimit,
)
if (shouldCompactForTokens || shouldCompactForMessages) {
logForDebugging(
`[inProcessRunner] ${identity.agentId} compacting history (${tokenCount} tokens, ${allMessages.length} messages)`,
)