From 4027ffd1c97920fcff04b5b1718f7874abd42266 Mon Sep 17 00:00:00 2001 From: Neko_Yukari <56566898ok@gmail.com> Date: Wed, 19 Aug 2026 00:46:28 +0800 Subject: [PATCH 1/4] 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. --- lib/messages/sync.ts | 27 ++++- tests/sync-blocks.test.ts | 208 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 tests/sync-blocks.test.ts 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..cb3207bf --- /dev/null +++ b/tests/sync-blocks.test.ts @@ -0,0 +1,208 @@ +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" +import { saveSessionState, loadSessionState } from "../lib/state/persistence" +import { existsSync, rmSync, readFileSync } from "node:fs" +import { join } from "node:path" + +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)) +}) + +test("modelContextLimit is persisted and restored across restarts", async () => { + // 回归:重启后第一轮 chat.message hook 先于 system.prompt hook 运行, + // modelContextLimit 若未持久化则阈值无法按百分比解析。 + const sid = "ses-persist-roundtrip" + const filePath = join( + process.env.XDG_DATA_HOME || join(process.env.USERPROFILE || "", ".local", "share"), + "opencode", + "storage", + "plugin", + "dcp", + `${sid}.json`, + ) + try { + const logger = new Logger(false) + + const state = createSessionState() + state.sessionId = sid + state.modelContextLimit = 1000000 // 1M,如 deepseek-v4-flash / kimi k3 + await saveSessionState(state, logger) + + // 模拟重启:从磁盘加载(modelContextLimit 必须恢复) + const loaded = await loadSessionState(sid, logger) + assert.ok(loaded !== null) + assert.equal(loaded.modelContextLimit, 1000000) + + // 持久化文件里确实包含该字段(而非仅内存) + assert.equal(existsSync(filePath), true) + const raw = JSON.parse(readFileSync(filePath, "utf-8")) + assert.equal(raw.modelContextLimit, 1000000) + } finally { + if (existsSync(filePath)) { + rmSync(filePath, { force: true }) + } + } +}) From 70203edf875fdbd5da476839d5d19f5a0e33ea86 Mon Sep 17 00:00:00 2001 From: Neko_Yukari <56566898ok@gmail.com> Date: Wed, 19 Aug 2026 00:46:28 +0800 Subject: [PATCH 2/4] feat: default compress limits to percentages of model context Absolute 100K/50K defaults trigger compression reminders far too early on modern large-context models (256K-1M). Use 85%/60% of the model context by default; falls back gracefully when the limit is unknown. --- lib/config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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", From 275a36c1eaea435c03d388e019b0077fa1bac1bb Mon Sep 17 00:00:00 2001 From: Neko_Yukari <56566898ok@gmail.com> Date: Wed, 19 Aug 2026 00:54:31 +0800 Subject: [PATCH 3/4] fix: skip min-threshold checks when modelContextLimit is unknown opencode runs the chat.message transform before the system.prompt hook, so on the first message after a restart state.modelContextLimit is not yet cached. resolveContextTokenLimit then fails for percentage limits and overMinLimit fell back to unconditional true, injecting compression nudges on every turn for a normal ~300K context on 1M models. Fix: overMinLimit now returns false when the limit cannot be resolved (skip nudge instead of unconditionally triggering). The model limit is a session constant: from the second turn onward the system.prompt hook has cached it and threshold checks work normally. The real model limit is still enforced by the API layer. Tests: 108/108 pass (added min-threshold skip and 300K no-false-alarm regressions). --- lib/messages/inject/utils.ts | 7 ++++++- tests/token-usage.test.ts | 40 +++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) 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/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) +}) From 4ac2d7f73bab103d6a1af55e5dcf3158ca364427 Mon Sep 17 00:00:00 2001 From: Neko_Yukari <56566898ok@gmail.com> Date: Wed, 19 Aug 2026 02:37:14 +0800 Subject: [PATCH 4/4] test: remove dead persistence imports from sync-blocks.test.ts --- tests/sync-blocks.test.ts | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/tests/sync-blocks.test.ts b/tests/sync-blocks.test.ts index cb3207bf..f48ac17c 100644 --- a/tests/sync-blocks.test.ts +++ b/tests/sync-blocks.test.ts @@ -6,9 +6,6 @@ import type { CompressionBlock } from "../lib/state" import { syncCompressionBlocks } from "../lib/messages/sync" import { prune } from "../lib/messages/prune" import type { PluginConfig } from "../lib/config" -import { saveSessionState, loadSessionState } from "../lib/state/persistence" -import { existsSync, rmSync, readFileSync } from "node:fs" -import { join } from "node:path" function msg(id: string, role: "user" | "assistant" = "user"): WithParts { return { @@ -171,38 +168,3 @@ test("prune injects compressed summary into LLM context after sync keeps block a assert.ok(messages.some((m) => m.info.id === anchorMsgId)) }) -test("modelContextLimit is persisted and restored across restarts", async () => { - // 回归:重启后第一轮 chat.message hook 先于 system.prompt hook 运行, - // modelContextLimit 若未持久化则阈值无法按百分比解析。 - const sid = "ses-persist-roundtrip" - const filePath = join( - process.env.XDG_DATA_HOME || join(process.env.USERPROFILE || "", ".local", "share"), - "opencode", - "storage", - "plugin", - "dcp", - `${sid}.json`, - ) - try { - const logger = new Logger(false) - - const state = createSessionState() - state.sessionId = sid - state.modelContextLimit = 1000000 // 1M,如 deepseek-v4-flash / kimi k3 - await saveSessionState(state, logger) - - // 模拟重启:从磁盘加载(modelContextLimit 必须恢复) - const loaded = await loadSessionState(sid, logger) - assert.ok(loaded !== null) - assert.equal(loaded.modelContextLimit, 1000000) - - // 持久化文件里确实包含该字段(而非仅内存) - assert.equal(existsSync(filePath), true) - const raw = JSON.parse(readFileSync(filePath, "utf-8")) - assert.equal(raw.modelContextLimit, 1000000) - } finally { - if (existsSync(filePath)) { - rmSync(filePath, { force: true }) - } - } -})