From 0d25ebbb4562f8375061025c42ba0a547dfed06b Mon Sep 17 00:00:00 2001 From: yanglinfang Date: Mon, 24 Aug 2026 22:49:52 -0700 Subject: [PATCH] fix(opencode): honest context arithmetic for small and unreported model limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the budget arithmetic that breaks small local models: - unset/zero limit.context disabled proactive compaction entirely; now a conservative 32k usable window applies, shrunk by a per-session cap learned from provider overflow rejections, with a one-time warning - the compaction reserve was min(20k, maxOutputTokens) — 36%% of a 56k local window; now proportional min(20k, max(2048, 15%% of the window)), with compaction.reserved config keeping absolute priority - unset limit.output was assumed to be 32k, so usable = context - 32000 reached 0 on any window under 32k and compaction re-triggered after every step; now the fallback is window-proportional (25%%, floor 1024) and each request additionally clamps its output budget to the headroom the estimated input actually leaves Co-Authored-By: Claude Fable 5 --- packages/opencode/src/provider/transform.ts | 9 +- packages/opencode/src/session/compaction.ts | 15 ++- packages/opencode/src/session/llm.ts | 1 + packages/opencode/src/session/llm/request.ts | 31 +++++ packages/opencode/src/session/overflow.ts | 63 ++++++++- packages/opencode/src/session/processor.ts | 19 ++- packages/opencode/src/session/prompt.ts | 2 +- .../opencode/test/provider/transform.test.ts | 115 +++++++++++++++++ .../opencode/test/session/compaction.test.ts | 24 +++- .../opencode/test/session/overflow.test.ts | 122 ++++++++++++++++++ 10 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/test/session/overflow.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0667fc2eb098..d7fb53989c42 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1416,7 +1416,14 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a } export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { - return Math.min(model.limit.output, outputTokenMax) || outputTokenMax + const capped = Math.min(model.limit.output, outputTokenMax) + if (capped) return capped + // Unset limit.output: derive a window-proportional fallback so small + // windows don't reserve most of the context for output. When limit.context + // is also unset, keep the flat fallback — the overflow layer applies its + // own conservative default and it must not be double-applied here. + if (!model.limit.context) return outputTokenMax + return Math.min(outputTokenMax, Math.max(1_024, Math.floor(model.limit.context * 0.25))) } type JsonRecord = Record diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..b0b5ac9cf65f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,7 +14,7 @@ import { NotFoundError } from "@/storage/storage" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { isOverflow as overflow, usable } from "./overflow" +import { isOverflow as overflow, usable, shouldWarnUnsetLimit, DEFAULT_USABLE_CONTEXT } from "./overflow" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" @@ -166,6 +166,7 @@ export interface Interface { readonly isOverflow: (input: { tokens: SessionV1.Assistant["tokens"] model: Provider.Model + sessionID?: SessionID }) => Effect.Effect readonly prune: (input: { sessionID: SessionID }) => Effect.Effect readonly process: (input: { @@ -203,12 +204,22 @@ const layer = Layer.effect( const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: SessionV1.Assistant["tokens"] model: Provider.Model + sessionID?: SessionID }) { + const cfg = yield* config.get() + if (input.sessionID && shouldWarnUnsetLimit({ cfg, model: input.model, sessionID: input.sessionID })) { + yield* Effect.logWarning("model reports no context limit; assuming conservative usable window", { + providerID: input.model.providerID, + modelID: input.model.id, + usable: DEFAULT_USABLE_CONTEXT, + }) + } return overflow({ - cfg: yield* config.get(), + cfg, tokens: input.tokens, model: input.model, outputTokenMax: flags.outputTokenMax, + sessionID: input.sessionID, }) }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..0662312100de 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -109,6 +109,7 @@ const live: Layer.Layer< auth: info, plugin, flags, + cfg, isWorkflow, }) diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index e000d6ca49b5..902a99c07047 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import type { Auth } from "@/auth" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { RuntimeFlags } from "@/effect/runtime-flags" @@ -8,6 +9,8 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "../message-v2" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" +import { usable } from "../overflow" +import { Token } from "@/util/token" import { SystemPrompt } from "../system" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Record } from "effect" @@ -32,6 +35,7 @@ type PrepareInput = { readonly auth: Auth.Info | undefined readonly plugin: Plugin.Interface readonly flags: RuntimeFlags.Info + readonly cfg: ConfigV1.Info readonly isWorkflow: boolean } @@ -174,6 +178,33 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }) } + // Window-aware output clamp: never request more output than the usable + // window leaves after the estimated input. This seam is where the fully + // composed request (system + messages + resolved tools) first exists, so + // the input estimate lives here rather than in the prompt loop. + const usableWindow = usable({ + cfg: input.cfg, + model: input.model, + outputTokenMax: input.flags.outputTokenMax, + sessionID: input.sessionID, + }) + if (usableWindow > 0 && params.maxOutputTokens !== undefined) { + const estimated = Token.estimate( + JSON.stringify([system, input.messages, Object.entries(tools).map(([name, item]) => [name, item.description, item.inputSchema])]), + ) + const granted = Math.min(params.maxOutputTokens, Math.max(256, usableWindow - estimated)) + if (granted < params.maxOutputTokens) { + yield* Effect.logWarning("clamped output budget to remaining window", { + "session.id": input.sessionID, + requested: params.maxOutputTokens, + granted, + estimated, + usable: usableWindow, + }) + params.maxOutputTokens = granted + } + } + const opencodeProjectID = input.model.providerID.startsWith("opencode") ? (yield* InstanceState.context).project.id : undefined diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index 3374d1c9f8d4..0c078f368b2a 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -6,16 +6,65 @@ import { ProviderTransform } from "@/provider/transform" import type { MessageV2 } from "./message-v2" const COMPACTION_BUFFER = 20_000 +const RESERVED_MINIMUM = 2_048 +const RESERVED_RATIO = 0.15 -export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) { +// An unset/zero context limit must not read as infinite: router and local +// providers frequently report 0, which previously disabled proactive +// compaction entirely and let every session run into provider overflow +// errors. Unless auto compaction is explicitly off, assume this conservative +// usable window, shrunk further by any session-level cap learned from +// provider overflow errors. +export const DEFAULT_USABLE_CONTEXT = 32_000 + +const learnedLimits = new Map() +const warnedSessions = new Set() + +// Records the estimated input size of a request the provider rejected for +// context overflow. Used as an upper bound on the real window for models that +// report no context limit; only the smallest observation is kept. +export function learnContextLimit(sessionID: string, tokens: number) { + if (tokens <= 0) return + const prior = learnedLimits.get(sessionID) + if (prior === undefined || tokens < prior) learnedLimits.set(sessionID, tokens) +} + +// True exactly once per session when the model reports no context limit while +// auto compaction stays enabled, so the caller can log the fallback loudly. +export function shouldWarnUnsetLimit(input: { cfg: ConfigV1.Info; model: Provider.Model; sessionID: string }) { + if (input.model.limit.context) return false + if (input.cfg.compaction?.auto === false) return false + if (warnedSessions.has(input.sessionID)) return false + warnedSessions.add(input.sessionID) + return true +} + +// Compaction reserve proportional to the window: a fixed 20k reserve is ~36% +// of a 56k local window but only 10% of 200k. `compaction.reserved` config +// keeps absolute priority; the min() keeps large-window behavior unchanged. +export function reserved(cfg: ConfigV1.Info, context: number) { + return ( + cfg.compaction?.reserved ?? + Math.min(COMPACTION_BUFFER, Math.max(RESERVED_MINIMUM, Math.floor(context * RESERVED_RATIO))) + ) +} + +export function usable(input: { + cfg: ConfigV1.Info + model: Provider.Model + outputTokenMax?: number + sessionID?: string +}) { const context = input.model.limit.context - if (context === 0) return 0 + if (!context) { + if (input.cfg.compaction?.auto === false) return 0 + const learned = input.sessionID ? learnedLimits.get(input.sessionID) : undefined + if (learned === undefined) return DEFAULT_USABLE_CONTEXT + return Math.min(DEFAULT_USABLE_CONTEXT, Math.max(0, learned - reserved(input.cfg, learned))) + } - const reserved = - input.cfg.compaction?.reserved ?? - Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) return input.model.limit.input - ? Math.max(0, input.model.limit.input - reserved) + ? Math.max(0, input.model.limit.input - reserved(input.cfg, input.model.limit.input)) : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) } @@ -24,9 +73,9 @@ export function isOverflow(input: { tokens: SessionV1.Assistant["tokens"] model: Provider.Model outputTokenMax?: number + sessionID?: string }) { if (input.cfg.compaction?.auto === false) return false - if (input.model.limit.context === 0) return false const count = input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..d5dd0c3f5f4e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,7 +12,8 @@ import { Snapshot } from "@/snapshot" import { Session } from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" -import { isOverflow } from "./overflow" +import { isOverflow, learnContextLimit } from "./overflow" +import { Token } from "@/util/token" import { PartID } from "./schema" import type { SessionID } from "./schema" import { SessionRetry } from "./retry" @@ -72,6 +73,7 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: SessionV1.TextPart | undefined reasoningMap: Record + lastStream: LLM.StreamInput | undefined } type StreamEvent = LLMEvent @@ -111,6 +113,7 @@ const layer = Layer.effect( needsCompaction: false, currentText: undefined, reasoningMap: {}, + lastStream: undefined, } let aborted = false @@ -476,7 +479,7 @@ const layer = Layer.effect( .pipe(Effect.ignore, Effect.forkIn(scope)) if ( !ctx.assistantMessage.summary && - isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model }) + isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model, sessionID: ctx.sessionID }) ) { ctx.needsCompaction = true } @@ -612,6 +615,17 @@ const layer = Layer.effect( yield* status.set(ctx.sessionID, { type: "idle" }) return } + // With no configured context limit, remember the failing request's + // estimated size as a session-level upper bound so the next proactive + // overflow check compacts before the provider rejects again. + if (!input.model.limit.context && ctx.lastStream) { + const estimated = Token.estimate(JSON.stringify([ctx.lastStream.system, ctx.lastStream.messages])) + learnContextLimit(ctx.sessionID, estimated) + yield* Effect.logWarning("learned session context cap from provider overflow", { + "session.id": ctx.sessionID, + estimated, + }) + } ctx.needsCompaction = true yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return @@ -629,6 +643,7 @@ const layer = Layer.effect( "session.id": input.sessionID, messageID: input.assistantMessage.id, }) + ctx.lastStream = streamInput ctx.needsCompaction = false ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..521ad8050646 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1161,7 +1161,7 @@ const layer = Layer.effect( if ( lastFinished && lastFinished.summary !== true && - (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) + (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model, sessionID })) ) { yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) continue diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 97f0de281483..9427d733f492 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -6,6 +6,10 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" import { jsonSchema } from "ai" +import { Schema } from "effect" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" + +const emptyConfig = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -578,6 +582,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { init: () => Effect.void, } as any, flags: { outputTokenMax: 32_000, client: "test" } as any, + cfg: emptyConfig, isWorkflow: false, }), ) @@ -5680,3 +5685,113 @@ describe("ProviderTransform.options - kimi family adaptive thinking", () => { expect(result.thinking).toBeUndefined() }) }) + +describe("ProviderTransform.maxOutputTokens - window-aware fallback", () => { + const model = (limit: { context: number; output: number }) => + ({ + id: "test/test-model", + providerID: "test", + api: { id: "test-model", url: "https://example.com/v1", npm: "@ai-sdk/openai-compatible" }, + name: "Test", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit, + options: {}, + headers: {}, + }) as any + + test("explicit limit.output stays capped at OUTPUT_TOKEN_MAX", () => { + expect(ProviderTransform.maxOutputTokens(model({ context: 200_000, output: 64_000 }))).toBe(32_000) + expect(ProviderTransform.maxOutputTokens(model({ context: 200_000, output: 8_192 }))).toBe(8_192) + }) + + test("unset limit.output derives a proportional fallback from the window", () => { + expect(ProviderTransform.maxOutputTokens(model({ context: 8_192, output: 0 }))).toBe(2_048) + expect(ProviderTransform.maxOutputTokens(model({ context: 56_320, output: 0 }))).toBe(14_080) + expect(ProviderTransform.maxOutputTokens(model({ context: 2_048, output: 0 }))).toBe(1_024) + }) + + test("unset limit.output and unset limit.context keep the flat fallback", () => { + expect(ProviderTransform.maxOutputTokens(model({ context: 0, output: 0 }))).toBe(32_000) + expect(ProviderTransform.maxOutputTokens(model({ context: 200_000, output: 0 }))).toBe(32_000) + }) +}) + +describe("LLMRequestPrep.prepare - output headroom clamp", () => { + const prepare = (input: { context: number; output: number; text: string }) => + Effect.runPromise( + LLMRequestPrep.prepare({ + user: { + id: "msg_user-clamp", + sessionID: "ses_clamp-test", + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "test", modelID: "test-model" }, + } as any, + sessionID: "ses_clamp-test", + model: { + id: "test/test-model", + providerID: "test", + api: { id: "test-model", url: "https://example.com/v1", npm: "@ai-sdk/openai-compatible" }, + name: "Test", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: input.context, output: input.output }, + options: {}, + headers: {}, + } as any, + agent: { name: "test", mode: "primary", options: {}, permission: [] } as any, + system: [], + messages: [{ role: "user", content: input.text }], + tools: {}, + provider: { id: "test", options: {} } as any, + auth: undefined, + plugin: { + trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, + } as any, + flags: { client: "test" } as any, + cfg: emptyConfig, + isWorkflow: false, + }), + ) + + test("small requests keep the full output budget", async () => { + const result = await prepare({ context: 8_192, output: 0, text: "hello" }) + expect(result.params.maxOutputTokens).toBe(2_048) + }) + + test("nearly-full window clamps output to the 256 floor", async () => { + // usable = 8_192 - 2_048 = 6_144; ~10k estimated input tokens exceed it + const result = await prepare({ context: 8_192, output: 0, text: "x".repeat(40_000) }) + expect(result.params.maxOutputTokens).toBe(256) + }) + + test("partially-full window clamps output to remaining headroom", async () => { + const result = await prepare({ context: 56_320, output: 0, text: "x".repeat(150_000) }) + // usable = 56_320 - 14_080 = 42_240; clamp lands between floor and budget + expect(result.params.maxOutputTokens).toBeLessThan(14_080) + expect(result.params.maxOutputTokens).toBeGreaterThan(256) + }) + + test("large explicit windows keep the configured budget", async () => { + const result = await prepare({ context: 200_000, output: 64_000, text: "hello" }) + expect(result.params.maxOutputTokens).toBe(32_000) + }) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c76dd98b8614..8aac955543a3 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -533,17 +533,37 @@ describe("session.compaction.isOverflow", () => { ) it.live( - "returns false when model context limit is 0", + "applies the conservative default window when model context limit is 0", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service const model = createModel({ context: 0, output: 32_000 }) const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } - expect(yield* compact.isOverflow({ tokens, model })).toBe(false) + expect(yield* compact.isOverflow({ tokens, model })).toBe(true) + const small = { input: 10_000, output: 1_000, reasoning: 0, cache: { read: 0, write: 0 } } + expect(yield* compact.isOverflow({ tokens: small, model })).toBe(false) }), ), ) + it.live( + "keeps unset context limit as no overflow when compaction.auto is disabled", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const model = createModel({ context: 0, output: 32_000 }) + const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } } + expect(yield* compact.isOverflow({ tokens, model })).toBe(false) + }), + { + config: { + compaction: { auto: false }, + }, + }, + ), + ) + it.live( "returns false when compaction.auto is disabled", provideTmpdirInstance( diff --git a/packages/opencode/test/session/overflow.test.ts b/packages/opencode/test/session/overflow.test.ts new file mode 100644 index 000000000000..9689b34fd759 --- /dev/null +++ b/packages/opencode/test/session/overflow.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { + reserved, + usable, + isOverflow, + learnContextLimit, + shouldWarnUnsetLimit, + DEFAULT_USABLE_CONTEXT, +} from "@/session/overflow" +import { ProviderTest } from "../fake/provider" + +function cfg(compaction?: ConfigV1.Info["compaction"]) { + const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info + return { ...base, compaction } +} + +function model(opts: { context: number; output: number; input?: number }) { + return ProviderTest.model({ limit: { context: opts.context, input: opts.input, output: opts.output } }) +} + +describe("overflow.reserved", () => { + test("scales proportionally on small windows", () => { + expect(reserved(cfg(), 56_320)).toBe(8_448) + }) + + test("caps at the legacy 20k buffer on large windows", () => { + expect(reserved(cfg(), 200_000)).toBe(20_000) + }) + + test("floors at 2,048 on tiny windows", () => { + expect(reserved(cfg(), 8_192)).toBe(2_048) + }) + + test("compaction.reserved config keeps absolute priority", () => { + expect(reserved(cfg({ reserved: 5_000 }), 56_320)).toBe(5_000) + expect(reserved(cfg({ reserved: 30_000 }), 8_192)).toBe(30_000) + }) +}) + +describe("overflow.usable", () => { + test("subtracts the proportional reserve from limit.input", () => { + expect(usable({ cfg: cfg(), model: model({ context: 56_320, input: 56_320, output: 8_192 }) })).toBe(47_872) + }) + + test("keeps large explicit windows unchanged (20k reserve)", () => { + expect(usable({ cfg: cfg(), model: model({ context: 200_000, input: 200_000, output: 64_000 }) })).toBe(180_000) + }) + + test("keeps the context minus output path for models without limit.input", () => { + expect(usable({ cfg: cfg(), model: model({ context: 200_000, output: 64_000 }) })).toBe(168_000) + }) + + test("unset context limit falls back to the conservative default window", () => { + expect(usable({ cfg: cfg(), model: model({ context: 0, output: 32_000 }) })).toBe(DEFAULT_USABLE_CONTEXT) + }) + + test("unset context limit stays disabled when compaction.auto is off", () => { + expect(usable({ cfg: cfg({ auto: false }), model: model({ context: 0, output: 32_000 }) })).toBe(0) + }) + + test("learned session cap shrinks the default window", () => { + const sessionID = "ses_learned_cap" + learnContextLimit(sessionID, 10_000) + // 10_000 - reserved(10_000) = 10_000 - 2_048 + expect(usable({ cfg: cfg(), model: model({ context: 0, output: 32_000 }), sessionID })).toBe(7_952) + // only the smallest observation is kept + learnContextLimit(sessionID, 50_000) + expect(usable({ cfg: cfg(), model: model({ context: 0, output: 32_000 }), sessionID })).toBe(7_952) + learnContextLimit(sessionID, 5_000) + expect(usable({ cfg: cfg(), model: model({ context: 0, output: 32_000 }), sessionID })).toBe(2_952) + }) + + test("learned cap never applies to models with explicit limits", () => { + const sessionID = "ses_learned_explicit" + learnContextLimit(sessionID, 1_000) + expect(usable({ cfg: cfg(), model: model({ context: 200_000, output: 64_000 }), sessionID })).toBe(168_000) + }) +}) + +describe("overflow.isOverflow", () => { + const tokens = (total: number) => ({ input: total, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }) + + test("unset context limit overflows at the conservative default window", () => { + const zero = model({ context: 0, output: 32_000 }) + expect(isOverflow({ cfg: cfg(), tokens: tokens(33_000), model: zero })).toBe(true) + expect(isOverflow({ cfg: cfg(), tokens: tokens(10_000), model: zero })).toBe(false) + }) + + test("unset context limit never overflows when compaction.auto is off", () => { + const zero = model({ context: 0, output: 32_000 }) + expect(isOverflow({ cfg: cfg({ auto: false }), tokens: tokens(100_000), model: zero })).toBe(false) + }) + + test("explicit limits behave exactly as before", () => { + const explicit = model({ context: 200_000, output: 64_000 }) + expect(isOverflow({ cfg: cfg(), tokens: tokens(168_000), model: explicit })).toBe(true) + expect(isOverflow({ cfg: cfg(), tokens: tokens(167_999), model: explicit })).toBe(false) + }) +}) + +describe("overflow.shouldWarnUnsetLimit", () => { + test("warns exactly once per session for unset limits", () => { + const input = { cfg: cfg(), model: model({ context: 0, output: 32_000 }), sessionID: "ses_warn_once" } + expect(shouldWarnUnsetLimit(input)).toBe(true) + expect(shouldWarnUnsetLimit(input)).toBe(false) + }) + + test("never warns for explicit limits or disabled auto compaction", () => { + expect( + shouldWarnUnsetLimit({ cfg: cfg(), model: model({ context: 200_000, output: 64_000 }), sessionID: "ses_warn_a" }), + ).toBe(false) + expect( + shouldWarnUnsetLimit({ + cfg: cfg({ auto: false }), + model: model({ context: 0, output: 32_000 }), + sessionID: "ses_warn_b", + }), + ).toBe(false) + }) +})