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
34 changes: 27 additions & 7 deletions lib/messages/inject/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,19 @@ export function getModelInfo(messages: WithParts[]): LastUserModelContext {
}
}

interface ResolvedContextLimit {
/** The user configured a value, even if it could not be turned into a token count. */
configured: boolean
tokens: number | undefined
}

function resolveContextTokenLimit(
config: PluginConfig,
state: SessionState,
providerId: string | undefined,
modelId: string | undefined,
threshold: "max" | "min",
): number | undefined {
): ResolvedContextLimit {
const parseLimitValue = (limit: number | `${number}%` | undefined): number | undefined => {
if (limit === undefined) {
return undefined
Expand Down Expand Up @@ -120,13 +126,13 @@ function resolveContextTokenLimit(
const providerModelId = `${providerId}/${modelId}`
const modelLimit = modelLimits[providerModelId]
if (modelLimit !== undefined) {
return parseLimitValue(modelLimit)
return { configured: true, tokens: parseLimitValue(modelLimit) }
}
}

const globalLimit =
threshold === "max" ? config.compress.maxContextLimit : config.compress.minContextLimit
return parseLimitValue(globalLimit)
return { configured: globalLimit !== undefined, tokens: parseLimitValue(globalLimit) }
}

export function isContextOverLimits(
Expand All @@ -147,14 +153,28 @@ export function isContextOverLimits(
"max",
)
const maxContextLimit =
resolvedMaxContextLimit === undefined
resolvedMaxContextLimit.tokens === undefined
? undefined
: resolvedMaxContextLimit + summaryTokenExtension
const minContextLimit = resolveContextTokenLimit(config, state, providerId, modelId, "min")
: resolvedMaxContextLimit.tokens + summaryTokenExtension
const resolvedMinContextLimit = resolveContextTokenLimit(
config,
state,
providerId,
modelId,
"min",
)
const currentTokens = getCurrentTokenUsage(state, messages)

const overMaxLimit = maxContextLimit === undefined ? false : currentTokens > maxContextLimit
const overMinLimit = minContextLimit === undefined ? true : currentTokens >= minContextLimit

// A threshold that was configured but cannot be evaluated yet (a percentage
// before the host reports the model context window, or an unparseable value)
// must not behave as if no threshold exists. Treating it as "over the lower
// limit" started compression far below the value the user asked for (#548).
const overMinLimit = resolvedMinContextLimit.configured
? resolvedMinContextLimit.tokens !== undefined &&
currentTokens >= resolvedMinContextLimit.tokens
: true

return {
overMaxLimit,
Expand Down
131 changes: 131 additions & 0 deletions tests/nudge-thresholds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mkdtempSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import type { WithParts } from "../lib/state"
import type { PluginConfig } from "../lib/config"

const root = mkdtempSync(join(tmpdir(), "dcp-nudges-"))
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.OPENCODE_CONFIG_DIR = join(root, "config", "opencode")
test.after(() => rmSync(root, { recursive: true, force: true }))

const { createSessionState } = await import("../lib/state")
const { isContextOverLimits } = await import("../lib/messages/inject/utils")

function config(overrides: Record<string, unknown> = {}): PluginConfig {
return {
compress: {
mode: "range",
permission: "allow",
protectedTools: [],
protectUserMessages: false,
nudgeFrequency: 1,
...overrides,
},
manualMode: { enabled: false, automaticStrategies: true },
experimental: { allowSubAgents: false },
turnProtection: { enabled: false, turns: 4 },
strategies: { deduplication: { enabled: false }, purgeErrors: { enabled: false } },
pruneNotification: "off",
protectedFilePatterns: [],
} as unknown as PluginConfig
}

function message(
sessionID: string,
id: string,
role: "user" | "assistant",
created: number,
text: string,
): WithParts {
return {
info: {
id,
role,
sessionID,
agent: "build",
model: { providerID: "lab", modelID: "test" },
time: { created },
tokens:
role === "assistant"
? { input: 1_000, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }
: undefined,
},
parts: [{ id: `prt_${id}`, messageID: id, sessionID, type: "text", text }],
} as unknown as WithParts
}

function conversation(sessionID: string): WithParts[] {
return [
message(sessionID, "msg_1", "user", 1, "first question"),
message(sessionID, "msg_2", "assistant", 2, "first answer"),
message(sessionID, "msg_3", "user", 3, "second question"),
message(sessionID, "msg_4", "assistant", 4, "second answer"),
]
}

// A percentage limit cannot be resolved until the host has reported the model
// context window. While it is unknown the old code fell back to "over the lower
// threshold", which is the opposite of what a configured threshold asks for and
// is what made compression start at 4% of the window (#548).
test("an unresolvable percentage threshold does not enable nudges", () => {
const sessionID = `ses_thresh_${Date.now()}`
const state = createSessionState()
const messages = conversation(sessionID)
const settings = config({ minContextLimit: "15%", maxContextLimit: "80%" })

// The model context window has not been reported yet.
state.modelContextLimit = undefined

const limits = isContextOverLimits(settings, state, "lab", "test", messages)

assert.equal(limits.overMinLimit, false, "percentage threshold silently became permissive")
assert.equal(limits.overMaxLimit, false)
})

test("an unparseable threshold does not enable nudges", () => {
const sessionID = `ses_thresh_bad_${Date.now()}`
const state = createSessionState()
const messages = conversation(sessionID)
const settings = config({ minContextLimit: "not-a-number", maxContextLimit: "80%" })
state.modelContextLimit = 1_000_000

const limits = isContextOverLimits(settings, state, "lab", "test", messages)

assert.equal(limits.overMinLimit, false)
})

test("a resolved threshold is still honoured in both directions", () => {
const sessionID = `ses_thresh_ok_${Date.now()}`
const messages = conversation(sessionID)

const below = isContextOverLimits(
config({ minContextLimit: "50%", maxContextLimit: "80%" }),
stateForThreshold(sessionID, "50%"),
"lab",
"test",
messages,
)
assert.equal(below.overMinLimit, false, "threshold fired below the configured value")
assert.equal(below.overMaxLimit, false)

const above = isContextOverLimits(
config({ minContextLimit: 1, maxContextLimit: "80%" }),
stateForThreshold(sessionID, "50%"),
"lab",
"test",
messages,
)
assert.equal(above.overMinLimit, true)
assert.equal(above.overMaxLimit, false)
})

function stateForThreshold(sessionID: string, _label: string) {
const state = createSessionState()
state.sessionId = sessionID
state.modelContextLimit = 1_000_000
return state
}