fix(compact): stop re-compaction thrash and post-compact 100% context meter

Two context-management fixes (CLI/sidecar side):

1. Re-compaction loop (方案1+2). Auto-compaction previously only circuit-broke
   on hard failures; a compaction that succeeded but left context still over
   threshold reset the failure counter and re-compacted every turn, burning a
   summary API call each time.
   - autoCompact.ts: isIneffectiveCompaction() — when truePostCompactTokenCount
     is still >= threshold, count it toward the existing circuit breaker instead
     of resetting to 0; query.ts now honours that count on the success path.
   - shouldThrottleAutoCompact() — suppress proactive autocompaction for a few
     turns after a compaction, unless at the hard blocking limit (so we never
     risk prompt-too-long by throttling).

2. Post-compact context meter stuck at ~100% (sessionService.ts). The transcript
   context estimate counted the entire pre-compact history plus the summarization
   call's huge input_tokens, pinning the indicator near full until the next turn.
   Now the estimate is scoped to messages after the latest compact_boundary, with
   a model fallback for the just-compacted/no-new-turn case. Non-compacted
   sessions are unchanged.

Tested:
- bun test src/services/compact/autoCompact.test.ts (12 pass; throttle + ineffective predicates)
- bun test src/server/__tests__/conversations.test.ts -t "context" (6 pass incl. new compact-boundary scoping case)
Not-tested: 4 pre-existing WebSocket runtime-restart integration tests fail in this env (CLI subprocess code 143); unrelated to these changes.
Confidence: high
Scope-risk: moderate
This commit is contained in:
你的姓名 2026-06-09 13:48:40 +08:00
parent 3cbed50ca9
commit b0f368ef97
6 changed files with 314 additions and 27 deletions

View File

@ -19,6 +19,13 @@
- **Read 工具空 `pages` 容错**`pages: ""` 视为读全文件,避免模型传空字符串触发的反复校验报错。
- **Fork 化指向**。仓库链接、检查更新源electron-updater publish + Tauri 端点)改为指向本 fork
`706412584/cc-haha`;设置→关于 新增"Fork 维护者"署名706412584原作者 NanmiCoder 署名与社交链接保留。
- **修复上下文压缩抖动/死循环**。此前自动压缩只对"压缩失败"做熔断,"压缩成功但结果仍超阈值"会被
当成成功并清零失败计数,导致每隔几句话就再压一次、空烧摘要 API。现在(方案1) 压缩后若仍超阈值
记为"无效压缩"并计入熔断,连续若干次后停止自动压缩;(方案2) 一次压缩后的若干轮内不再主动压缩
(触及硬阻塞上限时除外,避免 prompt-too-long
- **修复压缩后上下文进度卡 100%**。设置/编辑框的上下文进度在压缩完成后会停在 ~100% 不回落,直到
下一轮对话。根因是 transcript 估算路径未切到压缩边界之后(把压缩前全部消息 + 摘要调用的巨大
input 都算进去)。现在估算只统计最近一次压缩边界之后的消息,压缩完成即正确回落。
## 范围

View File

@ -520,11 +520,14 @@ async function* queryLoop(
// compact. recompactionInfo (autoCompact.ts:190) already captured the
// old values for turnsSincePreviousCompact/previousCompactTurnId before
// the call, so this reset doesn't lose those.
// consecutiveFailures is normally 0 here, but an "ineffective" compaction
// (post-compact context still over threshold) returns a non-zero count so
// the circuit breaker can stop a re-compaction loop.
tracking = {
compacted: true,
turnId: deps.uuid(),
turnCounter: 0,
consecutiveFailures: 0,
consecutiveFailures: consecutiveFailures ?? 0,
}
const postCompactMessages = buildPostCompactMessages(compactionResult)

View File

@ -12,6 +12,7 @@ import * as os from 'os'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { ConversationService, ConversationStartupError, conversationService } from '../services/conversationService.js'
import { ORCHESTRATION_PROMPT_MARKER } from '../orchestrationPrompt.js'
import { SessionService, sessionService } from '../services/sessionService.js'
import { ProviderService } from '../services/providerService.js'
@ -242,6 +243,20 @@ describe('ConversationService', () => {
])
})
it('should append the orchestration system prompt when coordinator mode is on', () => {
const svc = new ConversationService()
const args = (svc as any).getRuntimeArgs({ coordinatorMode: true }) as string[]
const idx = args.indexOf('--append-system-prompt')
expect(idx).toBeGreaterThanOrEqual(0)
expect(args[idx + 1]).toContain(ORCHESTRATION_PROMPT_MARKER)
})
it('should not append the orchestration system prompt when coordinator mode is off', () => {
const svc = new ConversationService()
expect((svc as any).getRuntimeArgs({ coordinatorMode: false })).not.toContain('--append-system-prompt')
expect((svc as any).getRuntimeArgs({})).not.toContain('--append-system-prompt')
})
it('should send thinking token controls to active CLI sessions', () => {
const svc = new ConversationService() as any
const sent: string[] = []
@ -684,11 +699,87 @@ describe('ConversationService', () => {
await fs.rm(workDir, { recursive: true, force: true })
}
})
})
// ============================================================================
// WebSocket integration tests (with mock CLI using the SDK websocket protocol)
// ============================================================================
it('scopes the transcript context estimate to messages after the latest compact boundary', async () => {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousNodeEnv = process.env.NODE_ENV
const previousUseBedrock = process.env.CLAUDE_CODE_USE_BEDROCK
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-compact-'))
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-compact-'))
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
process.env.NODE_ENV = 'development'
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
try {
const svc = new SessionService()
const { sessionId } = await svc.createSession(workDir)
const found = await svc.findSessionFile(sessionId)
expect(found).not.toBeNull()
// Pre-compact turn: a near-full request (would read as ~100%).
await fs.appendFile(found!.filePath, JSON.stringify({
type: 'assistant',
uuid: crypto.randomUUID(),
timestamp: '2026-04-27T12:00:00.000Z',
cwd: workDir,
version: '999.0.0-test',
message: {
role: 'assistant',
model: 'claude-sonnet-4-6',
content: [{ type: 'text', text: 'pre-compact answer' }],
usage: { input_tokens: 190_000, output_tokens: 50 },
},
}) + '\n')
// Compaction boundary — everything above is no longer in live context.
await fs.appendFile(found!.filePath, JSON.stringify({
type: 'system',
subtype: 'compact_boundary',
uuid: crypto.randomUUID(),
timestamp: '2026-04-27T12:00:01.000Z',
cwd: workDir,
}) + '\n')
// Post-compact: just the small summary, no new real turn yet.
await fs.appendFile(found!.filePath, JSON.stringify({
type: 'user',
uuid: crypto.randomUUID(),
timestamp: '2026-04-27T12:00:02.000Z',
cwd: workDir,
message: {
role: 'user',
content: [{ type: 'text', text: 'Summary of the prior conversation: we did a few things.' }],
},
}) + '\n')
const contextEstimate = await svc.getTranscriptContextEstimate(sessionId)
expect(contextEstimate?.rawMaxTokens).toBe(200_000)
// The pre-compact 190k request must NOT anchor the estimate post-compaction.
expect(contextEstimate?.percentage).toBeLessThan(50)
expect(contextEstimate?.totalTokens).toBeLessThan(50_000)
expect(contextEstimate?.model).toBe('claude-sonnet-4-6')
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
if (previousUseBedrock === undefined) {
delete process.env.CLAUDE_CODE_USE_BEDROCK
} else {
process.env.CLAUDE_CODE_USE_BEDROCK = previousUseBedrock
}
await fs.rm(tmpConfigDir, { recursive: true, force: true })
await fs.rm(workDir, { recursive: true, force: true })
}
})
})
describe('WebSocket Chat Integration', () => {
let server: ReturnType<typeof Bun.serve>

View File

@ -1382,6 +1382,24 @@ export class SessionService {
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
// After a compaction the transcript still holds the full pre-compact
// history, but the live context only contains messages after the latest
// compact boundary. Scope the estimate to post-boundary entries so the
// indicator doesn't report a stale ~100% — both the whole-transcript token
// estimate AND the summarization call's huge input_tokens would otherwise
// keep it pinned near full until the next real turn lands.
let boundaryIndex = -1
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
boundaryIndex = i
break
}
}
const scopedEntries =
boundaryIndex >= 0 ? entries.slice(boundaryIndex + 1) : entries
let latest: {
model: string
inputTokens: number
@ -1390,7 +1408,7 @@ export class SessionService {
cacheCreationInputTokens: number
} | null = null
for (const entry of entries) {
for (const entry of scopedEntries) {
const usage = entry.message?.usage
const model = entry.message?.model
if (!usage || typeof model !== 'string') continue
@ -1411,11 +1429,31 @@ export class SessionService {
}
}
if (!latest) return null
// Resolve the model even when the post-compact segment has no usage yet
// (compaction just happened, no real turn since): we still want a valid
// post-compact estimate rather than null.
let model = latest?.model
if (!model) {
for (let i = entries.length - 1; i >= 0; i--) {
const candidate = entries[i]?.message?.model
if (typeof candidate === 'string') {
model = candidate
break
}
}
}
if (!model) return null
const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model)
const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
const transcriptMessages = entries.filter(entry =>
const usage = latest ?? {
inputTokens: 0,
outputTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
}
const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, model)
const promptTokens = usage.inputTokens + usage.cacheReadInputTokens + usage.cacheCreationInputTokens
const transcriptMessages = scopedEntries.filter(entry =>
entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment',
) as Parameters<typeof roughTokenCountEstimationForMessages>[0]
const estimatedTokens =
@ -1423,12 +1461,16 @@ export class SessionService {
const contextBudget = calculateContextBudget({
estimatedTokens,
contextWindow: rawMaxTokens,
currentUsage: {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
},
// Without a post-compact API turn yet, there's no trustworthy provider
// usage to anchor to — fall back to the pure message-token estimate.
currentUsage: latest
? {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
}
: undefined,
usageTrust: getProviderUsageTrust({
isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(),
}),
@ -1437,10 +1479,10 @@ export class SessionService {
const totalTokens = contextBudget.usedTokens
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
const usageCategories: TranscriptContextEstimate['categories'] = [
{ name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' },
{ name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' },
{ name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' },
{ name: 'Output tokens', tokens: latest.outputTokens, color: '#2f7d32' },
{ name: 'Input tokens', tokens: usage.inputTokens, color: '#8f3217' },
{ name: 'Cache read', tokens: usage.cacheReadInputTokens, color: '#0f5c8f' },
{ name: 'Cache write', tokens: usage.cacheCreationInputTokens, color: '#7c3aed' },
{ name: 'Output tokens', tokens: usage.outputTokens, color: '#2f7d32' },
]
const contextCategories: TranscriptContextEstimate['categories'] =
contextBudget.ignoredUsageReason === 'low_trust_media_usage'
@ -1474,15 +1516,15 @@ export class SessionService {
rawMaxTokens,
percentage,
gridRows,
model: latest.model,
model,
memoryFiles: [],
mcpTools: [],
agents: [],
apiUsage: {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_creation_input_tokens: usage.cacheCreationInputTokens,
cache_read_input_tokens: usage.cacheReadInputTokens,
},
}
}

View File

@ -1,6 +1,13 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { getAutoCompactThreshold, getEffectiveContextWindowSize } from './autoCompact.js'
import {
getAutoCompactThreshold,
getEffectiveContextWindowSize,
isIneffectiveCompaction,
shouldThrottleAutoCompact,
type AutoCompactTrackingState,
} from './autoCompact.js'
import type { CompactionResult } from './compact.js'
import { getContextWindowForModel } from '../../utils/context.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
@ -67,3 +74,65 @@ describe('model context window resolution', () => {
expect(getAutoCompactThreshold('MiniMax-M2.7')).toBe(171_800)
})
})
function makeTracking(
overrides: Partial<AutoCompactTrackingState> = {},
): AutoCompactTrackingState {
return {
compacted: true,
turnId: 'turn-1',
turnCounter: 0,
...overrides,
}
}
function makeCompactionResult(
truePostCompactTokenCount: number | undefined,
): CompactionResult {
// Only the field the predicate reads matters for these tests.
return { truePostCompactTokenCount } as unknown as CompactionResult
}
describe('方案2: shouldThrottleAutoCompact (turn throttle)', () => {
test('throttles for the first few turns after a compaction', () => {
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 0 }), false)).toBe(true)
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 1 }), false)).toBe(true)
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 2 }), false)).toBe(true)
})
test('stops throttling once enough turns have elapsed', () => {
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 3 }), false)).toBe(false)
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 9 }), false)).toBe(false)
})
test('never throttles at the hard blocking limit, even right after a compaction', () => {
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 0 }), true)).toBe(false)
})
test('does not throttle when no compaction has happened yet', () => {
expect(shouldThrottleAutoCompact(undefined, false)).toBe(false)
expect(
shouldThrottleAutoCompact(makeTracking({ compacted: false, turnCounter: 0 }), false),
).toBe(false)
})
})
describe('方案1: isIneffectiveCompaction (recompaction-loop guard)', () => {
// deepseek-v4-pro: autocompact threshold is 967_000 (see math tests above).
const model = 'deepseek-v4-pro'
const threshold = getAutoCompactThreshold(model)
test('flags a compaction whose result is still at/above the threshold', () => {
expect(isIneffectiveCompaction(makeCompactionResult(threshold), model)).toBe(true)
expect(isIneffectiveCompaction(makeCompactionResult(threshold + 50_000), model)).toBe(true)
})
test('treats a compaction that got under the threshold as effective', () => {
expect(isIneffectiveCompaction(makeCompactionResult(threshold - 1), model)).toBe(false)
expect(isIneffectiveCompaction(makeCompactionResult(10_000), model)).toBe(false)
})
test('treats an unknown post-compact size as effective (no false circuit-break)', () => {
expect(isIneffectiveCompaction(makeCompactionResult(undefined), model)).toBe(false)
})
})

View File

@ -69,6 +69,13 @@ export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
// 方案2: minimum turns between two automatic compactions. After a successful
// autocompaction we reset turnCounter to 0 (query.ts); we then refuse to
// proactively compact again until this many turns have elapsed — UNLESS the
// context is at the hard blocking limit (where skipping would risk a
// prompt-too-long error). Prevents "compact every couple of messages" thrash.
const MIN_TURNS_BETWEEN_AUTO_COMPACT = 3
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
@ -238,6 +245,37 @@ export async function shouldAutoCompact(
return isAboveAutoCompactThreshold
}
// 方案2 predicate (pure, exported for tests): suppress proactive autocompaction
// for the first MIN_TURNS_BETWEEN_AUTO_COMPACT turns after a compaction, but
// never when we're already at the hard blocking limit.
export function shouldThrottleAutoCompact(
tracking: AutoCompactTrackingState | undefined,
isAtBlockingLimit: boolean,
): boolean {
if (isAtBlockingLimit) return false
return (
tracking?.compacted === true &&
tracking.turnCounter < MIN_TURNS_BETWEEN_AUTO_COMPACT
)
}
// 方案1: a compaction is "ineffective" when, after it completes, the resulting
// context is still at or above the autocompact threshold — i.e. the very next
// turn would trigger autocompact again. We feed these into the same circuit
// breaker as hard failures so a run of ineffective compactions eventually
// stops the loop instead of re-summarizing on every turn forever (each attempt
// burns a full summarization API call). truePostCompactTokenCount is an
// estimate; when it's absent we conservatively treat the compaction as
// effective so we never falsely trip the breaker.
export function isIneffectiveCompaction(
compactionResult: CompactionResult,
model: string,
): boolean {
const truePost = compactionResult.truePostCompactTokenCount
if (truePost === undefined) return false
return truePost >= getAutoCompactThreshold(model)
}
export async function autoCompactIfNeeded(
messages: Message[],
toolUseContext: ToolUseContext,
@ -276,6 +314,23 @@ export async function autoCompactIfNeeded(
return { wasCompacted: false }
}
// 方案2: turn throttle. If we compacted very recently, don't compact again
// for a few turns — give the context a chance to settle instead of
// re-summarizing every message. The hard blocking limit overrides this:
// when we're that close to the context window we must compact regardless,
// otherwise the next request risks a prompt-too-long failure.
if (tracking?.compacted === true && tracking.turnCounter < MIN_TURNS_BETWEEN_AUTO_COMPACT) {
const tokenCount =
tokenCountWithEstimation(messages) - (snipTokensFreed ?? 0)
const { isAtBlockingLimit } = calculateTokenWarningState(tokenCount, model)
if (shouldThrottleAutoCompact(tracking, isAtBlockingLimit)) {
logForDebugging(
`autocompact: throttled (turnsSinceCompact=${tracking.turnCounter} < ${MIN_TURNS_BETWEEN_AUTO_COMPACT}, not at blocking limit)`,
)
return { wasCompacted: false }
}
}
const recompactionInfo: RecompactionInfo = {
isRecompactionInChain: tracking?.compacted === true,
turnsSincePreviousCompact: tracking?.turnCounter ?? -1,
@ -306,6 +361,9 @@ export async function autoCompactIfNeeded(
return {
wasCompacted: true,
compactionResult: sessionMemoryResult,
consecutiveFailures: isIneffectiveCompaction(sessionMemoryResult, model)
? (tracking?.consecutiveFailures ?? 0) + 1
: 0,
}
}
@ -325,10 +383,27 @@ export async function autoCompactIfNeeded(
setLastSummarizedMessageId(undefined)
runPostCompactCleanup(querySource)
// 方案1: if the compaction didn't actually get us under the threshold, count
// it toward the circuit breaker instead of resetting to 0. Otherwise a
// perpetually-over-threshold session re-compacts every single turn.
const ineffective = isIneffectiveCompaction(compactionResult, model)
if (ineffective) {
const nextFailures = (tracking?.consecutiveFailures ?? 0) + 1
logForDebugging(
`autocompact: ineffective compaction (postCompact=${compactionResult.truePostCompactTokenCount} >= threshold=${getAutoCompactThreshold(model)}); counting toward circuit breaker (${nextFailures}/${MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES})`,
{ level: 'warn' },
)
return {
wasCompacted: true,
compactionResult,
consecutiveFailures: nextFailures,
}
}
return {
wasCompacted: true,
compactionResult,
// Reset failure count on success
// Reset failure count on a successful, effective compaction
consecutiveFailures: 0,
}
} catch (error) {