Skip to content

Commit 4027ffd

Browse files
committed
fix: keep compress blocks active across restarts when origin message is missing
compressMessageId (the assistant message executing compress) is marked ignored/synthetic and never persisted, so after an opencode restart the block was deactivated and the full original context was re-injected, causing premature compression reminders. Fall back to anchorMessageId (which persists): if the anchor exists, keep the block active so the compressed summary keeps being injected into the LLM context.
1 parent 11f6517 commit 4027ffd

2 files changed

Lines changed: 232 additions & 3 deletions

File tree

lib/messages/sync.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,31 @@ export const syncCompressionBlocks = (
4343
messageIds.has(block.compressMessageId)
4444

4545
if (!hasOriginMessage) {
46-
block.active = false
47-
block.deactivatedAt = now
46+
// compressMessageId(执行压缩的 assistant 消息)可能因被 DCP 标记为
47+
// ignored/synthetic 而从未持久化,重启后会缺失。此时只要锚点消息仍在,
48+
// 压缩摘要依然有效,应保留 active 使摘要继续注入 LLM 上下文;
49+
// 否则每次重启压缩都会失效,上下文重新膨胀导致频繁触发压缩提醒。
50+
const hasAnchorMessage =
51+
typeof block.anchorMessageId === "string" &&
52+
block.anchorMessageId.length > 0 &&
53+
messageIds.has(block.anchorMessageId)
54+
55+
if (!hasAnchorMessage) {
56+
block.active = false
57+
block.deactivatedAt = now
58+
block.deactivatedByBlockId = undefined
59+
missingOriginBlockIds.push(block.blockId)
60+
continue
61+
}
62+
63+
block.active = true
64+
block.deactivatedAt = undefined
4865
block.deactivatedByBlockId = undefined
49-
missingOriginBlockIds.push(block.blockId)
66+
messagesState.activeBlockIds.add(block.blockId)
67+
messagesState.activeByAnchorMessageId.set(block.anchorMessageId, block.blockId)
68+
logger.warn("Compress block origin message missing; keeping active via anchor", {
69+
blockId: block.blockId,
70+
})
5071
continue
5172
}
5273

tests/sync-blocks.test.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { Logger } from "../lib/logger"
4+
import { createSessionState, type WithParts } from "../lib/state"
5+
import type { CompressionBlock } from "../lib/state"
6+
import { syncCompressionBlocks } from "../lib/messages/sync"
7+
import { prune } from "../lib/messages/prune"
8+
import type { PluginConfig } from "../lib/config"
9+
import { saveSessionState, loadSessionState } from "../lib/state/persistence"
10+
import { existsSync, rmSync, readFileSync } from "node:fs"
11+
import { join } from "node:path"
12+
13+
function msg(id: string, role: "user" | "assistant" = "user"): WithParts {
14+
return {
15+
info: {
16+
id,
17+
role,
18+
sessionID: "ses-sync-test",
19+
time: { created: 1 },
20+
},
21+
parts: [
22+
{
23+
id: `${id}-part`,
24+
messageID: id,
25+
sessionID: "ses-sync-test",
26+
type: "text" as const,
27+
text: `content of ${id}`,
28+
},
29+
],
30+
} as unknown as WithParts
31+
}
32+
33+
function buildBlock(
34+
anchorMessageId: string,
35+
compressMessageId: string,
36+
rangeMessageIds: string[],
37+
summary: string,
38+
): CompressionBlock {
39+
return {
40+
blockId: 1,
41+
runId: 1,
42+
active: true,
43+
deactivatedByUser: false,
44+
compressedTokens: 1000,
45+
summaryTokens: summary.length,
46+
mode: "range",
47+
topic: "sync-test",
48+
batchTopic: "sync-test",
49+
startId: "m0001",
50+
endId: "m0009",
51+
anchorMessageId,
52+
compressMessageId,
53+
includedBlockIds: [],
54+
consumedBlockIds: [],
55+
parentBlockIds: [],
56+
directMessageIds: rangeMessageIds,
57+
directToolIds: [],
58+
effectiveMessageIds: rangeMessageIds,
59+
effectiveToolIds: [],
60+
createdAt: 1,
61+
summary,
62+
}
63+
}
64+
65+
function buildConfig(): PluginConfig {
66+
return {
67+
enabled: true,
68+
debug: false,
69+
pruneNotification: "off",
70+
pruneNotificationType: "chat",
71+
commands: { enabled: true, protectedTools: [] },
72+
manualMode: { enabled: false, automaticStrategies: true },
73+
turnProtection: { enabled: false, turns: 4 },
74+
experimental: { allowSubAgents: false, customPrompts: false },
75+
protectedFilePatterns: [],
76+
compress: {
77+
mode: "range",
78+
permission: "allow",
79+
showCompression: false,
80+
summaryBuffer: true,
81+
maxContextLimit: "85%",
82+
minContextLimit: "60%",
83+
nudgeFrequency: 5,
84+
iterationNudgeThreshold: 15,
85+
nudgeForce: "soft",
86+
protectedTools: ["task"],
87+
protectTags: false,
88+
protectUserMessages: false,
89+
},
90+
strategies: {
91+
deduplication: { enabled: true, protectedTools: [] },
92+
purgeErrors: { enabled: false, turns: 4, protectedTools: [] },
93+
},
94+
}
95+
}
96+
97+
test("syncCompressionBlocks keeps block active via anchor when compressMessageId is missing", () => {
98+
const state = createSessionState()
99+
const anchorMsgId = "msg-anchor"
100+
const rangeMsgIds = ["msg-1", "msg-2", "msg-3"]
101+
const messages = [msg(anchorMsgId), ...rangeMsgIds.map((id) => msg(id))]
102+
103+
// compressMessageId 指向不存在的消息(模拟被标记 ignored 未持久化)
104+
const block = buildBlock(anchorMsgId, "msg-compress-missing", rangeMsgIds, "summary text")
105+
state.prune.messages.blocksById.set(1, block)
106+
for (const id of rangeMsgIds) {
107+
state.prune.messages.byMessageId.set(id, { allBlockIds: [1], activeBlockIds: [1] })
108+
}
109+
110+
syncCompressionBlocks(state, new Logger(false), messages)
111+
112+
assert.equal(block.active, true)
113+
assert.equal(state.prune.messages.activeBlockIds.has(1), true)
114+
assert.equal(state.prune.messages.activeByAnchorMessageId.get(anchorMsgId), 1)
115+
})
116+
117+
test("syncCompressionBlocks still deactivates block when both origin and anchor are missing", () => {
118+
const state = createSessionState()
119+
const rangeMsgIds = ["msg-1"]
120+
const messages = rangeMsgIds.map((id) => msg(id))
121+
122+
const block = buildBlock("msg-anchor-missing", "msg-compress-missing", rangeMsgIds, "summary")
123+
state.prune.messages.blocksById.set(1, block)
124+
state.prune.messages.byMessageId.set("msg-1", { allBlockIds: [1], activeBlockIds: [1] })
125+
126+
syncCompressionBlocks(state, new Logger(false), messages)
127+
128+
assert.equal(block.active, false)
129+
assert.equal(state.prune.messages.activeBlockIds.has(1), false)
130+
})
131+
132+
test("prune injects compressed summary into LLM context after sync keeps block active", () => {
133+
const state = createSessionState()
134+
const anchorMsgId = "msg-anchor"
135+
const rangeMsgIds = ["msg-1", "msg-2", "msg-3"]
136+
const summary = "[Compressed conversation section]\n压缩后的关键摘要内容。"
137+
const messages = [msg(anchorMsgId), ...rangeMsgIds.map((id) => msg(id))]
138+
139+
const block = buildBlock(anchorMsgId, "msg-compress-missing", rangeMsgIds, summary)
140+
state.prune.messages.blocksById.set(1, block)
141+
for (const id of rangeMsgIds) {
142+
state.prune.messages.byMessageId.set(id, { allBlockIds: [1], activeBlockIds: [1] })
143+
}
144+
145+
syncCompressionBlocks(state, new Logger(false), messages)
146+
prune(state, new Logger(false), buildConfig(), messages)
147+
148+
// 摘要必须实际注入(LLM 能读到被压缩的内容)
149+
const joined = messages
150+
.map((m) =>
151+
(m.parts ?? [])
152+
.map((p: any) => (typeof p.text === "string" ? p.text : ""))
153+
.join(" "),
154+
)
155+
.join("\n")
156+
assert.ok(
157+
joined.includes("[Compressed conversation section]"),
158+
`expected summary marker, got: ${joined.slice(0, 300)}`,
159+
)
160+
assert.ok(joined.includes("压缩后的关键摘要内容"), "summary content must reach the LLM")
161+
162+
// 范围内的原始消息被摘要替换(不发送原文)
163+
for (const id of rangeMsgIds) {
164+
assert.equal(
165+
messages.some((m) => m.info.id === id),
166+
false,
167+
`compressed message ${id} should be removed`,
168+
)
169+
}
170+
// 锚点消息保留
171+
assert.ok(messages.some((m) => m.info.id === anchorMsgId))
172+
})
173+
174+
test("modelContextLimit is persisted and restored across restarts", async () => {
175+
// 回归:重启后第一轮 chat.message hook 先于 system.prompt hook 运行,
176+
// modelContextLimit 若未持久化则阈值无法按百分比解析。
177+
const sid = "ses-persist-roundtrip"
178+
const filePath = join(
179+
process.env.XDG_DATA_HOME || join(process.env.USERPROFILE || "", ".local", "share"),
180+
"opencode",
181+
"storage",
182+
"plugin",
183+
"dcp",
184+
`${sid}.json`,
185+
)
186+
try {
187+
const logger = new Logger(false)
188+
189+
const state = createSessionState()
190+
state.sessionId = sid
191+
state.modelContextLimit = 1000000 // 1M,如 deepseek-v4-flash / kimi k3
192+
await saveSessionState(state, logger)
193+
194+
// 模拟重启:从磁盘加载(modelContextLimit 必须恢复)
195+
const loaded = await loadSessionState(sid, logger)
196+
assert.ok(loaded !== null)
197+
assert.equal(loaded.modelContextLimit, 1000000)
198+
199+
// 持久化文件里确实包含该字段(而非仅内存)
200+
assert.equal(existsSync(filePath), true)
201+
const raw = JSON.parse(readFileSync(filePath, "utf-8"))
202+
assert.equal(raw.modelContextLimit, 1000000)
203+
} finally {
204+
if (existsSync(filePath)) {
205+
rmSync(filePath, { force: true })
206+
}
207+
}
208+
})

0 commit comments

Comments
 (0)