diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index 6d35e4c5..0c8b3c37 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -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 @@ -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( @@ -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, diff --git a/tests/nudge-thresholds.test.ts b/tests/nudge-thresholds.test.ts new file mode 100644 index 00000000..21613a31 --- /dev/null +++ b/tests/nudge-thresholds.test.ts @@ -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 = {}): 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 +}