Skip to content

Commit 58cd6ed

Browse files
committed
fix: persist modelContextLimit and skip min-threshold checks when unknown
Root cause of 'restart then immediately asked to compress': opencode runs the chat.message transform before the system.prompt hook, so on the first message after a restart state.modelContextLimit was not yet cached and not persisted. resolveContextTokenLimit then failed 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: - persist modelContextLimit in session state (save + load) so restarts restore it before the first chat.message transform - overMinLimit now returns false when the limit cannot be resolved (skip nudge instead of unconditionally triggering); the real model limit is still enforced by the API layer Tests: 109/109 pass (added min-threshold skip, 300K no-false-alarm, persistence round-trip and sync-block regressions).
1 parent 70203ed commit 58cd6ed

5 files changed

Lines changed: 57 additions & 8 deletions

File tree

lib/messages/inject/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ export function isContextOverLimits(
154154
const currentTokens = getCurrentTokenUsage(state, messages)
155155

156156
const overMaxLimit = maxContextLimit === undefined ? false : currentTokens > maxContextLimit
157-
const overMinLimit = minContextLimit === undefined ? true : currentTokens >= minContextLimit
157+
// minContextLimit 无法解析(如重启后第一轮 modelContextLimit 尚未缓存/持久化)时
158+
// 不能无条件触发:在 1M 模型上 fallback 会把 300K 的正常上下文误判为超限,
159+
// 每轮注入压缩提醒。此时跳过 nudge,等阈值可用后再正常工作。
160+
const overMinLimit =
161+
minContextLimit === undefined ? false : currentTokens >= minContextLimit
158162

159163
return {
160164
overMaxLimit,

lib/state/persistence.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface PersistedNudges {
3636
export interface PersistedSessionState {
3737
sessionName?: string
3838
manualMode?: boolean
39+
modelContextLimit?: number
3940
prune: PersistedPrune
4041
nudges: PersistedNudges
4142
stats: SessionStats
@@ -90,6 +91,10 @@ export async function saveSessionState(
9091
const state: PersistedSessionState = {
9192
sessionName: sessionName,
9293
manualMode: !!sessionState.manualMode,
94+
// modelContextLimit 必须持久化:opencode 的 system.prompt hook 在
95+
// chat.message hook 之后才缓存该值,重启后第一轮注入 nudge 时内存中
96+
// 尚未缓存 → resolveContextTokenLimit 失败 → 无法按百分比解析阈值。
97+
modelContextLimit: sessionState.modelContextLimit ?? undefined,
9398
prune: {
9499
tools: Object.fromEntries(sessionState.prune.tools),
95100
messages: serializePruneMessagesState(sessionState.prune.messages),

lib/state/state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,13 @@ export async function ensureSessionInitialized(
171171
state.manualMode = persisted.manualMode ? "active" : false
172172
}
173173

174+
// 重启后第一轮 chat.message hook 先于 system.prompt hook 运行,
175+
// 若不恢复持久化的 modelContextLimit,阈值无法按百分比解析,
176+
// 导致 1M 模型上 300K 的正常上下文在重启后立即触发压缩提醒。
177+
if (typeof persisted.modelContextLimit === "number") {
178+
state.modelContextLimit = persisted.modelContextLimit
179+
}
180+
174181
state.prune.tools = loadPruneMap(persisted.prune.tools)
175182
state.prune.messages = loadPruneMessagesState(persisted.prune.messages)
176183
state.nudges.contextLimitAnchors = new Set<string>(persisted.nudges.contextLimitAnchors || [])

package-lock.json

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/token-usage.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import { createSessionState, type WithParts } from "../lib/state"
77
import type { CompressionBlock } from "../lib/state"
88
import { getCurrentTokenUsage } from "../lib/token-utils"
99

10-
function buildConfig(maxContextLimit: number, minContextLimit = 1): PluginConfig {
10+
function buildConfig(
11+
maxContextLimit: number | `${number}%`,
12+
minContextLimit: number | `${number}%` = 1,
13+
): PluginConfig {
1114
return {
1215
enabled: true,
1316
debug: false,
@@ -298,3 +301,39 @@ test("isContextOverLimits does not extend the max threshold when summaryBuffer i
298301

299302
assert.equal(overLimit.overMaxLimit, true)
300303
})
304+
305+
306+
test("isContextOverLimits skips min threshold when modelContextLimit is unknown", () => {
307+
// 回归:modelContextLimit 未缓存/未持久化(如重启后第一轮)时,
308+
// 修复前 overMinLimit 无条件 true(每轮注入压缩提醒);
309+
// 修复后应跳过判定,避免 1M 模型上 300K 正常上下文被误判。
310+
const messages = buildCompactedMessages()
311+
messages.push(buildPostCompactionAssistantMessage())
312+
const state = createSessionState() // modelContextLimit = undefined
313+
314+
const pctConfig = buildConfig("85%", "60%")
315+
const result = isContextOverLimits(pctConfig, state, undefined, undefined, messages)
316+
assert.equal(result.overMinLimit, false)
317+
assert.equal(result.overMaxLimit, false)
318+
})
319+
320+
test("isContextOverLimits does not force compression for large-but-normal context when limit is unknown", () => {
321+
// 关键回归:1M 模型上 300K 上下文(30%)在 modelContextLimit 未知时
322+
// 绝不能触发强制压缩警告(修复前 min 侧 fallback 误判导致误压缩)。
323+
const messages = buildCompactedMessages()
324+
messages.push(buildPostCompactionAssistantMessage())
325+
const state = createSessionState()
326+
327+
const lastMsg = messages[messages.length - 1]
328+
;(lastMsg.info as any).tokens = {
329+
input: 300000,
330+
output: 500,
331+
reasoning: 0,
332+
cache: { read: 100, write: 0 },
333+
}
334+
335+
const pctConfig = buildConfig("85%", "60%")
336+
const result = isContextOverLimits(pctConfig, state, undefined, undefined, messages)
337+
assert.equal(result.overMaxLimit, false)
338+
assert.equal(result.overMinLimit, false)
339+
})

0 commit comments

Comments
 (0)