Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion lib/messages/inject/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions lib/messages/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
170 changes: 170 additions & 0 deletions tests/sync-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})

40 changes: 39 additions & 1 deletion tests/token-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
})