diff --git a/release-notes/v0.5.5.md b/release-notes/v0.5.5.md index 2933cd9d..e3a5a571 100644 --- a/release-notes/v0.5.5.md +++ b/release-notes/v0.5.5.md @@ -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 都算进去)。现在估算只统计最近一次压缩边界之后的消息,压缩完成即正确回落。 ## 范围 diff --git a/src/query.ts b/src/query.ts index ef1251f2..e98585d4 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index e0b82bed..7b86a9ef 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -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 diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 8d1ace45..4d0031aa 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -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[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, }, } } diff --git a/src/services/compact/autoCompact.test.ts b/src/services/compact/autoCompact.test.ts index 98e49ae5..e2a16ed5 100644 --- a/src/services/compact/autoCompact.test.ts +++ b/src/services/compact/autoCompact.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/services/compact/autoCompact.ts b/src/services/compact/autoCompact.ts index 8c0a786d..01319a73 100644 --- a/src/services/compact/autoCompact.ts +++ b/src/services/compact/autoCompact.ts @@ -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) {