diff --git a/lib/config.ts b/lib/config.ts index d7ddee28..ab706a17 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -681,8 +681,10 @@ const defaultConfig: PluginConfig = { permission: "allow", showCompression: false, summaryBuffer: true, - maxContextLimit: 100000, - minContextLimit: 50000, + // 默认按模型 context 的百分比计算阈值:绝对 100K/50K 对现代大 context 模型 + // (256K~1M)过小,会在上下文很早期就触发压缩提醒。 + maxContextLimit: "85%", + minContextLimit: "60%", nudgeFrequency: 5, iterationNudgeThreshold: 15, nudgeForce: "soft", diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index 6d35e4c5..6ecebcc1 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -154,7 +154,12 @@ export function isContextOverLimits( const currentTokens = getCurrentTokenUsage(state, messages) const overMaxLimit = maxContextLimit === undefined ? false : currentTokens > maxContextLimit - const overMinLimit = minContextLimit === undefined ? true : currentTokens >= minContextLimit + // minContextLimit 无法解析时(如重启后第一轮,modelContextLimit 尚未被 + // system.prompt hook 缓存)不能无条件触发:在 1M 模型上会把 300K 的正常 + // 上下文误判为超限,每轮注入压缩提醒。此时跳过 nudge——模型 limit 是 + // 会话常量,第二轮起 system.prompt 已缓存,判定即恢复正常。 + const overMinLimit = + minContextLimit === undefined ? false : currentTokens >= minContextLimit return { overMaxLimit, diff --git a/lib/messages/sync.ts b/lib/messages/sync.ts index 9eca783b..0ed5d378 100644 --- a/lib/messages/sync.ts +++ b/lib/messages/sync.ts @@ -43,10 +43,31 @@ export const syncCompressionBlocks = ( messageIds.has(block.compressMessageId) if (!hasOriginMessage) { - block.active = false - block.deactivatedAt = now + // compressMessageId(执行压缩的 assistant 消息)可能因被 DCP 标记为 + // ignored/synthetic 而从未持久化,重启后会缺失。此时只要锚点消息仍在, + // 压缩摘要依然有效,应保留 active 使摘要继续注入 LLM 上下文; + // 否则每次重启压缩都会失效,上下文重新膨胀导致频繁触发压缩提醒。 + const hasAnchorMessage = + typeof block.anchorMessageId === "string" && + block.anchorMessageId.length > 0 && + messageIds.has(block.anchorMessageId) + + if (!hasAnchorMessage) { + block.active = false + block.deactivatedAt = now + block.deactivatedByBlockId = undefined + missingOriginBlockIds.push(block.blockId) + continue + } + + block.active = true + block.deactivatedAt = undefined block.deactivatedByBlockId = undefined - missingOriginBlockIds.push(block.blockId) + messagesState.activeBlockIds.add(block.blockId) + messagesState.activeByAnchorMessageId.set(block.anchorMessageId, block.blockId) + logger.warn("Compress block origin message missing; keeping active via anchor", { + blockId: block.blockId, + }) continue } diff --git a/tests/sync-blocks.test.ts b/tests/sync-blocks.test.ts new file mode 100644 index 00000000..f48ac17c --- /dev/null +++ b/tests/sync-blocks.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { Logger } from "../lib/logger" +import { createSessionState, type WithParts } from "../lib/state" +import type { CompressionBlock } from "../lib/state" +import { syncCompressionBlocks } from "../lib/messages/sync" +import { prune } from "../lib/messages/prune" +import type { PluginConfig } from "../lib/config" + +function msg(id: string, role: "user" | "assistant" = "user"): WithParts { + return { + info: { + id, + role, + sessionID: "ses-sync-test", + time: { created: 1 }, + }, + parts: [ + { + id: `${id}-part`, + messageID: id, + sessionID: "ses-sync-test", + type: "text" as const, + text: `content of ${id}`, + }, + ], + } as unknown as WithParts +} + +function buildBlock( + anchorMessageId: string, + compressMessageId: string, + rangeMessageIds: string[], + summary: string, +): CompressionBlock { + return { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 1000, + summaryTokens: summary.length, + mode: "range", + topic: "sync-test", + batchTopic: "sync-test", + startId: "m0001", + endId: "m0009", + anchorMessageId, + compressMessageId, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: rangeMessageIds, + directToolIds: [], + effectiveMessageIds: rangeMessageIds, + effectiveToolIds: [], + createdAt: 1, + summary, + } +} + +function buildConfig(): PluginConfig { + return { + enabled: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + manualMode: { enabled: false, automaticStrategies: true }, + turnProtection: { enabled: false, turns: 4 }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [], + compress: { + mode: "range", + permission: "allow", + showCompression: false, + summaryBuffer: true, + maxContextLimit: "85%", + minContextLimit: "60%", + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: ["task"], + protectTags: false, + protectUserMessages: false, + }, + strategies: { + deduplication: { enabled: true, protectedTools: [] }, + purgeErrors: { enabled: false, turns: 4, protectedTools: [] }, + }, + } +} + +test("syncCompressionBlocks keeps block active via anchor when compressMessageId is missing", () => { + const state = createSessionState() + const anchorMsgId = "msg-anchor" + const rangeMsgIds = ["msg-1", "msg-2", "msg-3"] + const messages = [msg(anchorMsgId), ...rangeMsgIds.map((id) => msg(id))] + + // compressMessageId 指向不存在的消息(模拟被标记 ignored 未持久化) + const block = buildBlock(anchorMsgId, "msg-compress-missing", rangeMsgIds, "summary text") + state.prune.messages.blocksById.set(1, block) + for (const id of rangeMsgIds) { + state.prune.messages.byMessageId.set(id, { allBlockIds: [1], activeBlockIds: [1] }) + } + + syncCompressionBlocks(state, new Logger(false), messages) + + assert.equal(block.active, true) + assert.equal(state.prune.messages.activeBlockIds.has(1), true) + assert.equal(state.prune.messages.activeByAnchorMessageId.get(anchorMsgId), 1) +}) + +test("syncCompressionBlocks still deactivates block when both origin and anchor are missing", () => { + const state = createSessionState() + const rangeMsgIds = ["msg-1"] + const messages = rangeMsgIds.map((id) => msg(id)) + + const block = buildBlock("msg-anchor-missing", "msg-compress-missing", rangeMsgIds, "summary") + state.prune.messages.blocksById.set(1, block) + state.prune.messages.byMessageId.set("msg-1", { allBlockIds: [1], activeBlockIds: [1] }) + + syncCompressionBlocks(state, new Logger(false), messages) + + assert.equal(block.active, false) + assert.equal(state.prune.messages.activeBlockIds.has(1), false) +}) + +test("prune injects compressed summary into LLM context after sync keeps block active", () => { + const state = createSessionState() + const anchorMsgId = "msg-anchor" + const rangeMsgIds = ["msg-1", "msg-2", "msg-3"] + const summary = "[Compressed conversation section]\n压缩后的关键摘要内容。" + const messages = [msg(anchorMsgId), ...rangeMsgIds.map((id) => msg(id))] + + const block = buildBlock(anchorMsgId, "msg-compress-missing", rangeMsgIds, summary) + state.prune.messages.blocksById.set(1, block) + for (const id of rangeMsgIds) { + state.prune.messages.byMessageId.set(id, { allBlockIds: [1], activeBlockIds: [1] }) + } + + syncCompressionBlocks(state, new Logger(false), messages) + prune(state, new Logger(false), buildConfig(), messages) + + // 摘要必须实际注入(LLM 能读到被压缩的内容) + const joined = messages + .map((m) => + (m.parts ?? []) + .map((p: any) => (typeof p.text === "string" ? p.text : "")) + .join(" "), + ) + .join("\n") + assert.ok( + joined.includes("[Compressed conversation section]"), + `expected summary marker, got: ${joined.slice(0, 300)}`, + ) + assert.ok(joined.includes("压缩后的关键摘要内容"), "summary content must reach the LLM") + + // 范围内的原始消息被摘要替换(不发送原文) + for (const id of rangeMsgIds) { + assert.equal( + messages.some((m) => m.info.id === id), + false, + `compressed message ${id} should be removed`, + ) + } + // 锚点消息保留 + assert.ok(messages.some((m) => m.info.id === anchorMsgId)) +}) + diff --git a/tests/token-usage.test.ts b/tests/token-usage.test.ts index 549edeae..669434e9 100644 --- a/tests/token-usage.test.ts +++ b/tests/token-usage.test.ts @@ -7,7 +7,10 @@ import { createSessionState, type WithParts } from "../lib/state" import type { CompressionBlock } from "../lib/state" import { getCurrentTokenUsage } from "../lib/token-utils" -function buildConfig(maxContextLimit: number, minContextLimit = 1): PluginConfig { +function buildConfig( + maxContextLimit: number | `${number}%`, + minContextLimit: number | `${number}%` = 1, +): PluginConfig { return { enabled: true, debug: false, @@ -298,3 +301,38 @@ test("isContextOverLimits does not extend the max threshold when summaryBuffer i assert.equal(overLimit.overMaxLimit, true) }) + +test("isContextOverLimits skips min threshold when modelContextLimit is unknown", () => { + // 回归:modelContextLimit 未缓存(如重启后第一轮)时, + // 修复前 overMinLimit 无条件 true(每轮注入压缩提醒); + // 修复后应跳过判定,避免 1M 模型上 300K 正常上下文被误判。 + const messages = buildCompactedMessages() + messages.push(buildPostCompactionAssistantMessage()) + const state = createSessionState() // modelContextLimit = undefined + + const pctConfig = buildConfig("85%", "60%") + const result = isContextOverLimits(pctConfig, state, undefined, undefined, messages) + assert.equal(result.overMinLimit, false) + assert.equal(result.overMaxLimit, false) +}) + +test("isContextOverLimits does not force compression for large-but-normal context when limit is unknown", () => { + // 关键回归:1M 模型上 300K 上下文(30%)在 modelContextLimit 未知时 + // 绝不能触发强制压缩警告(修复前 min 侧 fallback 误判导致误压缩)。 + const messages = buildCompactedMessages() + messages.push(buildPostCompactionAssistantMessage()) + const state = createSessionState() + + const lastMsg = messages[messages.length - 1] + ;(lastMsg.info as any).tokens = { + input: 300000, + output: 500, + reasoning: 0, + cache: { read: 100, write: 0 }, + } + + const pctConfig = buildConfig("85%", "60%") + const result = isContextOverLimits(pctConfig, state, undefined, undefined, messages) + assert.equal(result.overMaxLimit, false) + assert.equal(result.overMinLimit, false) +})