From 37a3a3d125dc97ff848be4f2aa6d536681df6b76 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 00:24:41 +0800 Subject: [PATCH 1/5] feat: align context usage metrics --- .../components/session-context-usage-state.ts | 12 ++ .../components/session-context-usage.test.ts | 22 +++ .../src/components/session-context-usage.tsx | 39 ++++- .../session/session-context-metrics.test.ts | 78 +++++++++- .../session/session-context-metrics.ts | 43 +++++- .../session/session-context-tab.tsx | 6 +- packages/app/src/i18n/en.ts | 11 +- packages/app/src/i18n/zh.ts | 11 +- packages/opencode/src/session/overflow.ts | 44 +++--- .../opencode/test/session/compaction.test.ts | 24 +++ .../ui/src/components/progress-circle.css | 2 +- packages/util/src/context-usage.ts | 78 ++++++++++ packages/util/test/context-usage.test.ts | 144 ++++++++++++++++++ 13 files changed, 469 insertions(+), 45 deletions(-) create mode 100644 packages/app/src/components/session-context-usage-state.ts create mode 100644 packages/app/src/components/session-context-usage.test.ts create mode 100644 packages/util/src/context-usage.ts create mode 100644 packages/util/test/context-usage.test.ts diff --git a/packages/app/src/components/session-context-usage-state.ts b/packages/app/src/components/session-context-usage-state.ts new file mode 100644 index 000000000..aa241bdf6 --- /dev/null +++ b/packages/app/src/components/session-context-usage-state.ts @@ -0,0 +1,12 @@ +export type ContextUsageTone = "normal" | "warning" | "danger" + +export function contextUsageTone(usage: number | null | undefined): ContextUsageTone { + if (usage === undefined || usage === null) return "normal" + if (usage >= 90) return "danger" + if (usage >= 70) return "warning" + return "normal" +} + +export function contextUsageRingPercent(usage: number | null | undefined) { + return Math.max(0, Math.min(100, usage ?? 0)) +} diff --git a/packages/app/src/components/session-context-usage.test.ts b/packages/app/src/components/session-context-usage.test.ts new file mode 100644 index 000000000..6ac8ace08 --- /dev/null +++ b/packages/app/src/components/session-context-usage.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { contextUsageRingPercent, contextUsageTone } from "./session-context-usage-state" + +describe("session context usage indicator helpers", () => { + test("uses normal tone for unknown usage and usage below warning", () => { + expect(contextUsageTone(null)).toBe("normal") + expect(contextUsageTone(69.9)).toBe("normal") + }) + + test("uses warning and danger thresholds", () => { + expect(contextUsageTone(70)).toBe("warning") + expect(contextUsageTone(89.9)).toBe("warning") + expect(contextUsageTone(90)).toBe("danger") + }) + + test("clamps only ring drawing percentage", () => { + expect(contextUsageRingPercent(null)).toBe(0) + expect(contextUsageRingPercent(-1)).toBe(0) + expect(contextUsageRingPercent(42.5)).toBe(42.5) + expect(contextUsageRingPercent(120)).toBe(100) + }) +}) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index bd84e7928..b1a26836d 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" +import { contextUsageRingPercent, contextUsageTone } from "./session-context-usage-state" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -23,8 +24,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const variant = createMemo(() => props.variant ?? "button") const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all())) + const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all(), sync.data.config)) const context = createMemo(() => metrics().context) + const tone = createMemo(() => contextUsageTone(context()?.usage)) + const ringColor = createMemo(() => { + if (tone() === "danger") return "var(--icon-error-base)" + if (tone() === "warning") return "var(--icon-warning-base)" + return "var(--border-active)" + }) const cost = createMemo(() => new Intl.NumberFormat(language.intl(), { style: "currency", @@ -38,24 +45,42 @@ export function SessionContextUsage(props: SessionContextUsageProps) { } const circle = () => ( -
- +
+
) + const compactStatus = () => { + const ctx = context() + if (!ctx) return undefined + if (ctx.effectiveInputLimit === undefined) return language.t("context.usage.limitUnknown") + if (!ctx.autoCompactEnabled) return language.t("context.usage.autoCompactOff") + if (ctx.compactThreshold === undefined) return undefined + return language.t("context.usage.autoCompactsAround", { + threshold: ctx.compactThreshold.toLocaleString(language.intl()), + }) + } + const tooltipValue = () => (
{(ctx) => ( <>
- {ctx().total.toLocaleString(language.intl())} - {language.t("context.usage.tokens")} + {language.t("context.usage.title")}
- {ctx().usage ?? 0}% - {language.t("context.usage.usage")} + + {ctx().usedTokens.toLocaleString(language.intl())} + {ctx().effectiveInputLimit !== undefined + ? ` / ${ctx().effectiveInputLimit?.toLocaleString(language.intl())}` + : ""} + + {language.t("context.usage.contextUsed")}
+ + {(status) =>
{status()}
} +
)}
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 0e109a71b..5d3446488 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -4,7 +4,7 @@ import { getSessionContextMetrics } from "./session-context-metrics" const assistant = ( id: string, - tokens: { input: number; output: number; reasoning: number; read: number; write: number }, + tokens: { total?: number; input: number; output: number; reasoning: number; read: number; write: number }, cost: number, providerID = "openai", modelID = "gpt-4.1", @@ -16,6 +16,7 @@ const assistant = ( modelID, cost, tokens: { + total: tokens.total, input: tokens.input, output: tokens.output, reasoning: tokens.reasoning, @@ -51,7 +52,7 @@ describe("getSessionContextMetrics", () => { models: { "gpt-4.1": { name: "GPT-4.1", - limit: { context: 1000 }, + limit: { context: 1000, output: 100 }, }, }, }, @@ -61,12 +62,80 @@ describe("getSessionContextMetrics", () => { expect(metrics.totalCost).toBe(1.75) expect(metrics.context?.message.id).toBe("a2") - expect(metrics.context?.total).toBe(500) - expect(metrics.context?.usage).toBe(50) + expect(metrics.context?.total).toBe(450) + expect(metrics.context?.usedTokens).toBe(450) + expect(metrics.context?.effectiveInputLimit).toBe(1000) + expect(metrics.context?.compactThreshold).toBe(900) + expect(metrics.context?.usage).toBe(45) expect(metrics.context?.providerLabel).toBe("OpenAI") expect(metrics.context?.modelLabel).toBe("GPT-4.1") }) + test("uses input limit and custom compaction reserve for usage metrics", () => { + const messages = [assistant("a1", { total: 238_000, input: 1, output: 1, reasoning: 1, read: 1, write: 1 }, 1)] + const providers = [ + { + id: "openai", + models: { + "gpt-4.1": { + limit: { context: 400_000, input: 272_000, output: 128_000 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers, { compaction: { reserved: 20_000 } }) + + expect(metrics.context?.limit).toBe(272_000) + expect(metrics.context?.contextWindow).toBe(400_000) + expect(metrics.context?.usedTokens).toBe(238_000) + expect(metrics.context?.compactThreshold).toBe(252_000) + expect(metrics.context?.usage).toBe(Math.round((238_000 / 272_000) * 100)) + }) + + test("selects the latest assistant when only total tokens are reported", () => { + const messages = [ + assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1), + assistant("a2", { total: 70_000, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.2), + ] + const providers = [ + { + id: "openai", + models: { + "gpt-4.1": { + limit: { context: 100_000, output: 10_000 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.context?.message.id).toBe("a2") + expect(metrics.context?.usedTokens).toBe(70_000) + }) + + test("treats zero context limit as unknown", () => { + const messages = [assistant("a1", { total: 20_000, input: 1, output: 1, reasoning: 1, read: 1, write: 1 }, 1)] + const providers = [ + { + id: "openai", + models: { + "gpt-4.1": { + limit: { context: 0, output: 0 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.context?.usedTokens).toBe(20_000) + expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.compactThreshold).toBeUndefined() + expect(metrics.context?.usage).toBeNull() + }) + test("preserves fallback labels and null usage when model metadata is missing", () => { const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] const providers = [{ id: "p-1", models: {} }] @@ -76,6 +145,7 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.providerLabel).toBe("p-1") expect(metrics.context?.modelLabel).toBe("m-1") expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.compactThreshold).toBeUndefined() expect(metrics.context?.usage).toBeNull() }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 0789b05f1..bc2903aa5 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,9 @@ import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" +import { + contextUsageDefaultOutputReserve, + contextUsageUsedTokens, + deriveContextUsage, +} from "@opencode-ai/util/context-usage" type Provider = { id: string @@ -10,6 +15,15 @@ type Model = { name?: string limit: { context: number + input?: number + output?: number + } +} + +type Config = { + compaction?: { + auto?: boolean + reserved?: number } } @@ -20,6 +34,11 @@ type Context = { providerLabel: string modelLabel: string limit: number | undefined + effectiveInputLimit: number | undefined + contextWindow: number | undefined + compactThreshold: number | undefined + autoCompactEnabled: boolean + usedTokens: number input: number output: number reasoning: number @@ -35,7 +54,7 @@ type Metrics = { } const tokenTotal = (msg: AssistantMessage) => { - return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write + return contextUsageUsedTokens(msg.tokens) } const lastAssistantWithTokens = (messages: Message[]) => { @@ -47,15 +66,20 @@ const lastAssistantWithTokens = (messages: Message[]) => { } } -const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { +const build = (messages: Message[] = [], providers: Provider[] = [], config: Config = {}): Metrics => { const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) const message = lastAssistantWithTokens(messages) if (!message) return { totalCost, context: undefined } const provider = providers.find((item) => item.id === message.providerID) const model = provider?.models[message.modelID] - const limit = model?.limit.context const total = tokenTotal(message) + const usage = deriveContextUsage({ + model, + tokens: message.tokens, + compaction: config.compaction, + defaultOutputReserve: contextUsageDefaultOutputReserve(model), + }) return { totalCost, @@ -65,18 +89,23 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => model, providerLabel: provider?.name ?? message.providerID, modelLabel: model?.name ?? message.modelID, - limit, + limit: usage.effectiveInputLimit, + effectiveInputLimit: usage.effectiveInputLimit, + contextWindow: model?.limit.context || undefined, + compactThreshold: usage.compactThreshold, + autoCompactEnabled: usage.autoCompactEnabled, + usedTokens: usage.usedTokens, input: message.tokens.input, output: message.tokens.output, reasoning: message.tokens.reasoning, cacheRead: message.tokens.cache.read, cacheWrite: message.tokens.cache.write, total, - usage: limit ? Math.round((total / limit) * 100) : null, + usage: usage.usagePercent === null ? null : Math.round(usage.usagePercent), }, } } -export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { - return build(messages, providers) +export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = [], config: Config = {}) { + return build(messages, providers, config) } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 66468365b..b1c0c50e5 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -129,7 +129,7 @@ export function SessionContextTab() { }), ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all())) + const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all(), sync.data.config)) const ctx = createMemo(() => metrics().context) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) @@ -198,7 +198,9 @@ export function SessionContextTab() { { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, - { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, + { label: "context.stats.contextWindow", value: () => formatter().number(ctx()?.contextWindow) }, + { label: "context.stats.inputLimit", value: () => formatter().number(ctx()?.effectiveInputLimit) }, + { label: "context.stats.compactThreshold", value: () => formatter().number(ctx()?.compactThreshold) }, { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8b1a6a21e..882cf1678 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -407,7 +407,9 @@ export const dict = { "context.stats.messages": "Messages", "context.stats.provider": "Provider", "context.stats.model": "Model", - "context.stats.limit": "Context Limit", + "context.stats.contextWindow": "Context Window", + "context.stats.inputLimit": "Input Limit", + "context.stats.compactThreshold": "Compact Threshold", "context.stats.totalTokens": "Total Tokens", "context.stats.usage": "Usage", "context.stats.inputTokens": "Input Tokens", @@ -420,8 +422,11 @@ export const dict = { "context.stats.sessionCreated": "Session Created", "context.stats.lastActivity": "Last Activity", - "context.usage.tokens": "Tokens", - "context.usage.usage": "Usage", + "context.usage.title": "Context usage", + "context.usage.contextUsed": "context used", + "context.usage.autoCompactsAround": "Auto-compacts around {{threshold}}", + "context.usage.autoCompactOff": "Auto-compact is off", + "context.usage.limitUnknown": "Context limit unknown", "context.usage.cost": "Cost", "context.usage.clickToView": "Click to view context", "context.usage.view": "View context usage", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c9d9f4e26..51a68234a 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -406,7 +406,9 @@ export const dict = { "context.stats.messages": "消息数", "context.stats.provider": "提供商", "context.stats.model": "模型", - "context.stats.limit": "上下文限制", + "context.stats.contextWindow": "上下文窗口", + "context.stats.inputLimit": "输入上限", + "context.stats.compactThreshold": "压缩阈值", "context.stats.totalTokens": "总 token", "context.stats.usage": "使用率", "context.stats.inputTokens": "输入 token", @@ -418,8 +420,11 @@ export const dict = { "context.stats.totalCost": "总成本", "context.stats.sessionCreated": "创建时间", "context.stats.lastActivity": "最后活动", - "context.usage.tokens": "Token", - "context.usage.usage": "使用率", + "context.usage.title": "上下文使用量", + "context.usage.contextUsed": "上下文", + "context.usage.autoCompactsAround": "约 {{threshold}} 自动压缩", + "context.usage.autoCompactOff": "自动压缩已关闭", + "context.usage.limitUnknown": "上下文上限未知", "context.usage.cost": "成本", "context.usage.clickToView": "点击查看上下文", "context.usage.view": "查看上下文用量", diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index 492fff010..cd4dc083e 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -1,30 +1,38 @@ import type { Config } from "@/config" import type { Provider } from "@/provider" -import { ProviderTransform } from "@/provider" +import { contextUsageDefaultOutputReserve, deriveContextUsage } from "@opencode-ai/util/context-usage" import type { MessageV2 } from "./message-v2" -const COMPACTION_BUFFER = 20_000 - export function usable(input: { cfg: Config.Info; model: Provider.Model }) { - const context = input.model.limit.context - if (context === 0) return 0 - - const reserved = - input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) - // Honor reserved consistently: subtract from input cap when present, otherwise from context. - // Upstream's branch dropped reserved in the no-input-cap path; that silently ignores user - // config like `compaction: { reserved: 50_000 }` for models without an explicit input cap. - // Use ?? so an explicit 0 input cap is preserved (treating 0 as "no input - // cap" silently fell back to context, masking model configuration). - const cap = input.model.limit.input ?? context - return Math.max(0, cap - reserved) + return ( + deriveContextUsage({ + model: input.model, + tokens: emptyTokens, + compaction: input.cfg.compaction, + defaultOutputReserve: contextUsageDefaultOutputReserve(input.model), + }).compactThreshold ?? 0 + ) } export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { 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 - return count >= usable(input) + const usage = deriveContextUsage({ + model: input.model, + tokens: input.tokens, + compaction: input.cfg.compaction, + defaultOutputReserve: contextUsageDefaultOutputReserve(input.model), + }) + return usage.compactThreshold !== undefined && usage.usedTokens >= usage.compactThreshold +} + +const emptyTokens: MessageV2.Assistant["tokens"] = { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 92b251f24..5d6808279 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -401,6 +401,30 @@ describe("session.compaction.isOverflow", () => { ), ) + it.live( + "includes cache.write in token count", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const model = createModel({ context: 100_000, output: 32_000 }) + const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 10_000 } } + expect(yield* compact.isOverflow({ tokens, model })).toBe(true) + }), + ), + ) + + it.live( + "uses total tokens when reported by the provider", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const model = createModel({ context: 100_000, output: 32_000 }) + const tokens = { total: 90_000, input: 1, output: 1, reasoning: 80_000, cache: { read: 1, write: 1 } } + expect(yield* compact.isOverflow({ tokens, model })).toBe(true) + }), + ), + ) + it.live( "respects input limit for input caps", provideTmpdirInstance(() => diff --git a/packages/ui/src/components/progress-circle.css b/packages/ui/src/components/progress-circle.css index afaf72af6..f3d45f2eb 100644 --- a/packages/ui/src/components/progress-circle.css +++ b/packages/ui/src/components/progress-circle.css @@ -6,7 +6,7 @@ } [data-slot="progress-circle-progress"] { - stroke: var(--border-active); + stroke: var(--progress-circle-progress, var(--border-active)); transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1); } } diff --git a/packages/util/src/context-usage.ts b/packages/util/src/context-usage.ts new file mode 100644 index 000000000..4dd3761c7 --- /dev/null +++ b/packages/util/src/context-usage.ts @@ -0,0 +1,78 @@ +export type ContextUsageModel = { + limit: { + context?: number + input?: number + output?: number + } +} + +export type ContextUsageTokens = { + total?: number + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } +} + +export type ContextUsageCompaction = { + auto?: boolean + reserved?: number +} + +export type ContextUsageInput = { + model?: ContextUsageModel + tokens: ContextUsageTokens + compaction?: ContextUsageCompaction + defaultOutputReserve?: number +} + +export type ContextUsage = { + usedTokens: number + effectiveInputLimit: number | undefined + compactThreshold: number | undefined + usagePercent: number | null + autoCompactEnabled: boolean +} + +const COMPACTION_BUFFER = 20_000 + +export function contextUsageUsedTokens(tokens: ContextUsageTokens) { + return tokens.total || tokens.input + tokens.output + tokens.cache.read + tokens.cache.write +} + +export function contextUsageDefaultOutputReserve(model?: ContextUsageModel) { + return model?.limit.output || undefined +} + +export function deriveContextUsage(input: ContextUsageInput): ContextUsage { + const usedTokens = contextUsageUsedTokens(input.tokens) + const context = input.model?.limit.context + const autoCompactEnabled = input.compaction?.auto !== false + + if (!context) { + return { + usedTokens, + effectiveInputLimit: undefined, + compactThreshold: undefined, + usagePercent: null, + autoCompactEnabled, + } + } + + const effectiveInputLimit = input.model?.limit.input ?? context + const reserved = + input.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, input.defaultOutputReserve ?? COMPACTION_BUFFER) + const compactThreshold = Math.max(0, effectiveInputLimit - reserved) + const usagePercent = effectiveInputLimit > 0 ? (usedTokens / effectiveInputLimit) * 100 : null + + return { + usedTokens, + effectiveInputLimit, + compactThreshold, + usagePercent, + autoCompactEnabled, + } +} diff --git a/packages/util/test/context-usage.test.ts b/packages/util/test/context-usage.test.ts new file mode 100644 index 000000000..82b5963f3 --- /dev/null +++ b/packages/util/test/context-usage.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test" +import { contextUsageDefaultOutputReserve, deriveContextUsage } from "../src/context-usage" + +const tokens = (input: { + total?: number + input?: number + output?: number + reasoning?: number + cacheRead?: number + cacheWrite?: number +}) => ({ + total: input.total, + input: input.input ?? 0, + output: input.output ?? 0, + reasoning: input.reasoning ?? 0, + cache: { + read: input.cacheRead ?? 0, + write: input.cacheWrite ?? 0, + }, +}) + +describe("deriveContextUsage", () => { + test("uses input limit when it is lower than the context window", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 400_000, input: 272_000, output: 128_000 } }, + tokens: tokens({ total: 238_000 }), + compaction: {}, + defaultOutputReserve: 20_000, + }) + + expect(usage.usedTokens).toBe(238_000) + expect(usage.effectiveInputLimit).toBe(272_000) + expect(usage.compactThreshold).toBe(252_000) + expect(usage.usagePercent).toBeCloseTo(87.5, 1) + expect(usage.autoCompactEnabled).toBe(true) + }) + + test("falls back to context window when input limit is missing", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 128_000, output: 16_000 } }, + tokens: tokens({ input: 40_000, output: 4_000, cacheRead: 8_000, cacheWrite: 1_000, reasoning: 9_000 }), + compaction: {}, + defaultOutputReserve: 16_000, + }) + + expect(usage.usedTokens).toBe(53_000) + expect(usage.effectiveInputLimit).toBe(128_000) + expect(usage.compactThreshold).toBe(112_000) + expect(usage.usagePercent).toBeCloseTo(41.40625, 5) + }) + + test("preserves explicit zero input limits", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 128_000, input: 0, output: 16_000 } }, + tokens: tokens({ input: 10_000 }), + compaction: {}, + defaultOutputReserve: 16_000, + }) + + expect(usage.effectiveInputLimit).toBe(0) + expect(usage.compactThreshold).toBe(0) + expect(usage.usagePercent).toBeNull() + }) + + test("treats zero context as unknown", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 0, output: 16_000 } }, + tokens: tokens({ total: 20_000 }), + compaction: {}, + defaultOutputReserve: 16_000, + }) + + expect(usage.usedTokens).toBe(20_000) + expect(usage.effectiveInputLimit).toBeUndefined() + expect(usage.compactThreshold).toBeUndefined() + expect(usage.usagePercent).toBeNull() + }) + + test("keeps current total-token truthy precedence and zero fallback behavior", () => { + const withTotal = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ total: 70_000, input: 10_000, output: 10_000, cacheRead: 10_000, cacheWrite: 10_000 }), + compaction: {}, + defaultOutputReserve: 10_000, + }) + const zeroTotal = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ total: 0, input: 10_000, output: 2_000, cacheRead: 3_000, cacheWrite: 4_000 }), + compaction: {}, + defaultOutputReserve: 10_000, + }) + + expect(withTotal.usedTokens).toBe(70_000) + expect(zeroTotal.usedTokens).toBe(19_000) + }) + + test("respects custom reserve values including zero", () => { + const custom = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ input: 1_000 }), + compaction: { reserved: 50_000 }, + defaultOutputReserve: 10_000, + }) + const zero = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ input: 1_000 }), + compaction: { reserved: 0 }, + defaultOutputReserve: 10_000, + }) + + expect(custom.compactThreshold).toBe(50_000) + expect(zero.compactThreshold).toBe(100_000) + }) + + test("derives the default output reserve from the model metadata", () => { + expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 12_000 } })).toBe(12_000) + expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 0 } })).toBeUndefined() + expect(contextUsageDefaultOutputReserve()).toBeUndefined() + }) + + test("clamps compact threshold when reserve is larger than the effective limit", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 10_000, output: 50_000 } }, + tokens: tokens({ input: 12_000 }), + compaction: { reserved: 20_000 }, + defaultOutputReserve: 50_000, + }) + + expect(usage.compactThreshold).toBe(0) + expect(usage.usagePercent).toBe(120) + }) + + test("reports disabled auto-compact without changing threshold math", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ input: 1_000 }), + compaction: { auto: false }, + defaultOutputReserve: 10_000, + }) + + expect(usage.autoCompactEnabled).toBe(false) + expect(usage.compactThreshold).toBe(90_000) + }) +}) From 0bd323e02cde0d1a03e45c60b2bc5f5b577d0944 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 00:47:32 +0800 Subject: [PATCH 2/5] fix: address context usage review --- .../src/components/session-context-usage.tsx | 24 ++++++++++++------- .../session/session-context-metrics.test.ts | 23 +++++++++++++++++- .../session/session-context-metrics.ts | 2 ++ .../session/session-context-tab.tsx | 11 +++++++-- packages/app/src/i18n/en.ts | 6 +++-- packages/app/src/i18n/zh.ts | 6 +++-- packages/util/src/context-usage.ts | 4 +++- packages/util/test/context-usage.test.ts | 13 +++++++++- 8 files changed, 71 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index b1a26836d..2f5702ff3 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -26,7 +26,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all(), sync.data.config)) const context = createMemo(() => metrics().context) - const tone = createMemo(() => contextUsageTone(context()?.usage)) + const tone = createMemo(() => contextUsageTone(context()?.usagePercent)) const ringColor = createMemo(() => { if (tone() === "danger") return "var(--icon-error-base)" if (tone() === "warning") return "var(--icon-warning-base)" @@ -46,7 +46,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const circle = () => (
- +
) @@ -61,6 +61,18 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }) } + const contextUsedText = (ctx: NonNullable>) => { + const used = ctx.usedTokens.toLocaleString(language.intl()) + const limit = ctx.effectiveInputLimit + if (limit === undefined) { + return language.t("context.usage.contextUsedUnknownLimit", { used }) + } + return language.t("context.usage.contextUsedWithLimit", { + used, + limit: limit.toLocaleString(language.intl()), + }) + } + const tooltipValue = () => (
@@ -70,13 +82,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { {language.t("context.usage.title")}
- - {ctx().usedTokens.toLocaleString(language.intl())} - {ctx().effectiveInputLimit !== undefined - ? ` / ${ctx().effectiveInputLimit?.toLocaleString(language.intl())}` - : ""} - - {language.t("context.usage.contextUsed")} + {contextUsedText(ctx())}
{(status) =>
{status()}
} diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 5d3446488..4003e1ee5 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -66,13 +66,14 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.usedTokens).toBe(450) expect(metrics.context?.effectiveInputLimit).toBe(1000) expect(metrics.context?.compactThreshold).toBe(900) + expect(metrics.context?.usagePercent).toBe(45) expect(metrics.context?.usage).toBe(45) expect(metrics.context?.providerLabel).toBe("OpenAI") expect(metrics.context?.modelLabel).toBe("GPT-4.1") }) test("uses input limit and custom compaction reserve for usage metrics", () => { - const messages = [assistant("a1", { total: 238_000, input: 1, output: 1, reasoning: 1, read: 1, write: 1 }, 1)] + const messages = [assistant("a1", { total: 238_000, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 1)] const providers = [ { id: "openai", @@ -90,9 +91,29 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.contextWindow).toBe(400_000) expect(metrics.context?.usedTokens).toBe(238_000) expect(metrics.context?.compactThreshold).toBe(252_000) + expect(metrics.context?.usagePercent).toBeCloseTo((238_000 / 272_000) * 100, 5) expect(metrics.context?.usage).toBe(Math.round((238_000 / 272_000) * 100)) }) + test("keeps raw usage separate from rounded display usage", () => { + const messages = [assistant("a1", { total: 696, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 1)] + const providers = [ + { + id: "openai", + models: { + "gpt-4.1": { + limit: { context: 1_000, output: 100 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.context?.usagePercent).toBe(69.6) + expect(metrics.context?.usage).toBe(70) + }) + test("selects the latest assistant when only total tokens are reported", () => { const messages = [ assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1), diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index bc2903aa5..18e63dc94 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -45,6 +45,7 @@ type Context = { cacheRead: number cacheWrite: number total: number + usagePercent: number | null usage: number | null } @@ -101,6 +102,7 @@ const build = (messages: Message[] = [], providers: Provider[] = [], config: Con cacheRead: message.tokens.cache.read, cacheWrite: message.tokens.cache.write, total, + usagePercent: usage.usagePercent, usage: usage.usagePercent === null ? null : Math.round(usage.usagePercent), }, } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index b1c0c50e5..79968bb59 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -169,6 +169,13 @@ export function SessionContextTab() { return c.modelLabel }) + const compactThreshold = createMemo(() => { + const c = ctx() + if (!c) return "—" + if (!c.autoCompactEnabled) return language.t("context.stats.off") + return formatter().number(c.compactThreshold) + }) + const breakdown = createMemo( on( () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], @@ -200,8 +207,8 @@ export function SessionContextTab() { { label: "context.stats.model", value: modelLabel }, { label: "context.stats.contextWindow", value: () => formatter().number(ctx()?.contextWindow) }, { label: "context.stats.inputLimit", value: () => formatter().number(ctx()?.effectiveInputLimit) }, - { label: "context.stats.compactThreshold", value: () => formatter().number(ctx()?.compactThreshold) }, - { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.compactThreshold", value: compactThreshold }, + { label: "context.stats.contextUsed", value: () => formatter().number(ctx()?.usedTokens) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 882cf1678..229f6c9ed 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -410,7 +410,8 @@ export const dict = { "context.stats.contextWindow": "Context Window", "context.stats.inputLimit": "Input Limit", "context.stats.compactThreshold": "Compact Threshold", - "context.stats.totalTokens": "Total Tokens", + "context.stats.contextUsed": "Context Used", + "context.stats.off": "Off", "context.stats.usage": "Usage", "context.stats.inputTokens": "Input Tokens", "context.stats.outputTokens": "Output Tokens", @@ -423,7 +424,8 @@ export const dict = { "context.stats.lastActivity": "Last Activity", "context.usage.title": "Context usage", - "context.usage.contextUsed": "context used", + "context.usage.contextUsedWithLimit": "Used {{used}} / {{limit}} context", + "context.usage.contextUsedUnknownLimit": "Used {{used}} context", "context.usage.autoCompactsAround": "Auto-compacts around {{threshold}}", "context.usage.autoCompactOff": "Auto-compact is off", "context.usage.limitUnknown": "Context limit unknown", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 51a68234a..22415cd6f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -409,7 +409,8 @@ export const dict = { "context.stats.contextWindow": "上下文窗口", "context.stats.inputLimit": "输入上限", "context.stats.compactThreshold": "压缩阈值", - "context.stats.totalTokens": "总 token", + "context.stats.contextUsed": "上下文使用量", + "context.stats.off": "关闭", "context.stats.usage": "使用率", "context.stats.inputTokens": "输入 token", "context.stats.outputTokens": "输出 token", @@ -421,7 +422,8 @@ export const dict = { "context.stats.sessionCreated": "创建时间", "context.stats.lastActivity": "最后活动", "context.usage.title": "上下文使用量", - "context.usage.contextUsed": "上下文", + "context.usage.contextUsedWithLimit": "已用 {{used}} / {{limit}} 上下文", + "context.usage.contextUsedUnknownLimit": "已用 {{used}} 上下文", "context.usage.autoCompactsAround": "约 {{threshold}} 自动压缩", "context.usage.autoCompactOff": "自动压缩已关闭", "context.usage.limitUnknown": "上下文上限未知", diff --git a/packages/util/src/context-usage.ts b/packages/util/src/context-usage.ts index 4dd3761c7..939774e53 100644 --- a/packages/util/src/context-usage.ts +++ b/packages/util/src/context-usage.ts @@ -40,11 +40,13 @@ export type ContextUsage = { const COMPACTION_BUFFER = 20_000 export function contextUsageUsedTokens(tokens: ContextUsageTokens) { + // Match overflow accounting: provider `total` wins when present; otherwise + // reasoning is excluded because providers may also report it inside output. return tokens.total || tokens.input + tokens.output + tokens.cache.read + tokens.cache.write } export function contextUsageDefaultOutputReserve(model?: ContextUsageModel) { - return model?.limit.output || undefined + return model?.limit.output } export function deriveContextUsage(input: ContextUsageInput): ContextUsage { diff --git a/packages/util/test/context-usage.test.ts b/packages/util/test/context-usage.test.ts index 82b5963f3..9ebb77212 100644 --- a/packages/util/test/context-usage.test.ts +++ b/packages/util/test/context-usage.test.ts @@ -114,10 +114,21 @@ describe("deriveContextUsage", () => { test("derives the default output reserve from the model metadata", () => { expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 12_000 } })).toBe(12_000) - expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 0 } })).toBeUndefined() + expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 0 } })).toBe(0) expect(contextUsageDefaultOutputReserve()).toBeUndefined() }) + test("preserves zero output reserve when deriving the compact threshold", () => { + const usage = deriveContextUsage({ + model: { limit: { context: 100_000, output: 0 } }, + tokens: tokens({ input: 1_000 }), + compaction: {}, + defaultOutputReserve: contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 0 } }), + }) + + expect(usage.compactThreshold).toBe(100_000) + }) + test("clamps compact threshold when reserve is larger than the effective limit", () => { const usage = deriveContextUsage({ model: { limit: { context: 10_000, output: 50_000 } }, From 99b0d93ec63977b0c114dcace54b5945fe515a68 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 08:22:29 +0800 Subject: [PATCH 3/5] fix: clarify context usage reserve semantics --- .../src/components/session-context-usage.tsx | 2 +- .../session/session-context-metrics.test.ts | 6 ++-- .../session/session-context-metrics.ts | 6 ++-- packages/opencode/src/session/overflow.ts | 6 ++-- packages/util/src/context-usage.ts | 10 +++--- packages/util/test/context-usage.test.ts | 32 +++++++++---------- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 2f5702ff3..f305d668d 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -53,8 +53,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const compactStatus = () => { const ctx = context() if (!ctx) return undefined - if (ctx.effectiveInputLimit === undefined) return language.t("context.usage.limitUnknown") if (!ctx.autoCompactEnabled) return language.t("context.usage.autoCompactOff") + if (ctx.effectiveInputLimit === undefined) return language.t("context.usage.limitUnknown") if (ctx.compactThreshold === undefined) return undefined return language.t("context.usage.autoCompactsAround", { threshold: ctx.compactThreshold.toLocaleString(language.intl()), diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 4003e1ee5..81acf0ff3 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -87,7 +87,7 @@ describe("getSessionContextMetrics", () => { const metrics = getSessionContextMetrics(messages, providers, { compaction: { reserved: 20_000 } }) - expect(metrics.context?.limit).toBe(272_000) + expect(metrics.context?.effectiveInputLimit).toBe(272_000) expect(metrics.context?.contextWindow).toBe(400_000) expect(metrics.context?.usedTokens).toBe(238_000) expect(metrics.context?.compactThreshold).toBe(252_000) @@ -152,7 +152,7 @@ describe("getSessionContextMetrics", () => { const metrics = getSessionContextMetrics(messages, providers) expect(metrics.context?.usedTokens).toBe(20_000) - expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.effectiveInputLimit).toBeUndefined() expect(metrics.context?.compactThreshold).toBeUndefined() expect(metrics.context?.usage).toBeNull() }) @@ -165,7 +165,7 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.providerLabel).toBe("p-1") expect(metrics.context?.modelLabel).toBe("m-1") - expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.effectiveInputLimit).toBeUndefined() expect(metrics.context?.compactThreshold).toBeUndefined() expect(metrics.context?.usage).toBeNull() }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 18e63dc94..452a787c8 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,6 +1,6 @@ import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" import { - contextUsageDefaultOutputReserve, + contextUsageModelOutputLimit, contextUsageUsedTokens, deriveContextUsage, } from "@opencode-ai/util/context-usage" @@ -33,7 +33,6 @@ type Context = { model?: Model providerLabel: string modelLabel: string - limit: number | undefined effectiveInputLimit: number | undefined contextWindow: number | undefined compactThreshold: number | undefined @@ -79,7 +78,7 @@ const build = (messages: Message[] = [], providers: Provider[] = [], config: Con model, tokens: message.tokens, compaction: config.compaction, - defaultOutputReserve: contextUsageDefaultOutputReserve(model), + defaultReserveTokens: contextUsageModelOutputLimit(model), }) return { @@ -90,7 +89,6 @@ const build = (messages: Message[] = [], providers: Provider[] = [], config: Con model, providerLabel: provider?.name ?? message.providerID, modelLabel: model?.name ?? message.modelID, - limit: usage.effectiveInputLimit, effectiveInputLimit: usage.effectiveInputLimit, contextWindow: model?.limit.context || undefined, compactThreshold: usage.compactThreshold, diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index cd4dc083e..2bb72dd31 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -1,6 +1,6 @@ import type { Config } from "@/config" import type { Provider } from "@/provider" -import { contextUsageDefaultOutputReserve, deriveContextUsage } from "@opencode-ai/util/context-usage" +import { contextUsageModelOutputLimit, deriveContextUsage } from "@opencode-ai/util/context-usage" import type { MessageV2 } from "./message-v2" export function usable(input: { cfg: Config.Info; model: Provider.Model }) { @@ -9,7 +9,7 @@ export function usable(input: { cfg: Config.Info; model: Provider.Model }) { model: input.model, tokens: emptyTokens, compaction: input.cfg.compaction, - defaultOutputReserve: contextUsageDefaultOutputReserve(input.model), + defaultReserveTokens: contextUsageModelOutputLimit(input.model), }).compactThreshold ?? 0 ) } @@ -22,7 +22,7 @@ export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistan model: input.model, tokens: input.tokens, compaction: input.cfg.compaction, - defaultOutputReserve: contextUsageDefaultOutputReserve(input.model), + defaultReserveTokens: contextUsageModelOutputLimit(input.model), }) return usage.compactThreshold !== undefined && usage.usedTokens >= usage.compactThreshold } diff --git a/packages/util/src/context-usage.ts b/packages/util/src/context-usage.ts index 939774e53..01cf59b3b 100644 --- a/packages/util/src/context-usage.ts +++ b/packages/util/src/context-usage.ts @@ -26,7 +26,7 @@ export type ContextUsageInput = { model?: ContextUsageModel tokens: ContextUsageTokens compaction?: ContextUsageCompaction - defaultOutputReserve?: number + defaultReserveTokens?: number } export type ContextUsage = { @@ -40,12 +40,12 @@ export type ContextUsage = { const COMPACTION_BUFFER = 20_000 export function contextUsageUsedTokens(tokens: ContextUsageTokens) { - // Match overflow accounting: provider `total` wins when present; otherwise + // Match overflow accounting: non-zero provider `total` wins; otherwise // reasoning is excluded because providers may also report it inside output. return tokens.total || tokens.input + tokens.output + tokens.cache.read + tokens.cache.write } -export function contextUsageDefaultOutputReserve(model?: ContextUsageModel) { +export function contextUsageModelOutputLimit(model?: ContextUsageModel) { return model?.limit.output } @@ -65,8 +65,10 @@ export function deriveContextUsage(input: ContextUsageInput): ContextUsage { } const effectiveInputLimit = input.model?.limit.input ?? context + // The caller passes the reserve source tokens. This helper applies the shared + // 20K cap so runtime and UI cannot drift. const reserved = - input.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, input.defaultOutputReserve ?? COMPACTION_BUFFER) + input.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, input.defaultReserveTokens ?? COMPACTION_BUFFER) const compactThreshold = Math.max(0, effectiveInputLimit - reserved) const usagePercent = effectiveInputLimit > 0 ? (usedTokens / effectiveInputLimit) * 100 : null diff --git a/packages/util/test/context-usage.test.ts b/packages/util/test/context-usage.test.ts index 9ebb77212..b8f694cb6 100644 --- a/packages/util/test/context-usage.test.ts +++ b/packages/util/test/context-usage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { contextUsageDefaultOutputReserve, deriveContextUsage } from "../src/context-usage" +import { contextUsageModelOutputLimit, deriveContextUsage } from "../src/context-usage" const tokens = (input: { total?: number @@ -25,7 +25,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 400_000, input: 272_000, output: 128_000 } }, tokens: tokens({ total: 238_000 }), compaction: {}, - defaultOutputReserve: 20_000, + defaultReserveTokens: 20_000, }) expect(usage.usedTokens).toBe(238_000) @@ -40,7 +40,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 128_000, output: 16_000 } }, tokens: tokens({ input: 40_000, output: 4_000, cacheRead: 8_000, cacheWrite: 1_000, reasoning: 9_000 }), compaction: {}, - defaultOutputReserve: 16_000, + defaultReserveTokens: 16_000, }) expect(usage.usedTokens).toBe(53_000) @@ -54,7 +54,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 128_000, input: 0, output: 16_000 } }, tokens: tokens({ input: 10_000 }), compaction: {}, - defaultOutputReserve: 16_000, + defaultReserveTokens: 16_000, }) expect(usage.effectiveInputLimit).toBe(0) @@ -67,7 +67,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 0, output: 16_000 } }, tokens: tokens({ total: 20_000 }), compaction: {}, - defaultOutputReserve: 16_000, + defaultReserveTokens: 16_000, }) expect(usage.usedTokens).toBe(20_000) @@ -81,13 +81,13 @@ describe("deriveContextUsage", () => { model: { limit: { context: 100_000, output: 10_000 } }, tokens: tokens({ total: 70_000, input: 10_000, output: 10_000, cacheRead: 10_000, cacheWrite: 10_000 }), compaction: {}, - defaultOutputReserve: 10_000, + defaultReserveTokens: 10_000, }) const zeroTotal = deriveContextUsage({ model: { limit: { context: 100_000, output: 10_000 } }, tokens: tokens({ total: 0, input: 10_000, output: 2_000, cacheRead: 3_000, cacheWrite: 4_000 }), compaction: {}, - defaultOutputReserve: 10_000, + defaultReserveTokens: 10_000, }) expect(withTotal.usedTokens).toBe(70_000) @@ -99,23 +99,23 @@ describe("deriveContextUsage", () => { model: { limit: { context: 100_000, output: 10_000 } }, tokens: tokens({ input: 1_000 }), compaction: { reserved: 50_000 }, - defaultOutputReserve: 10_000, + defaultReserveTokens: 10_000, }) const zero = deriveContextUsage({ model: { limit: { context: 100_000, output: 10_000 } }, tokens: tokens({ input: 1_000 }), compaction: { reserved: 0 }, - defaultOutputReserve: 10_000, + defaultReserveTokens: 10_000, }) expect(custom.compactThreshold).toBe(50_000) expect(zero.compactThreshold).toBe(100_000) }) - test("derives the default output reserve from the model metadata", () => { - expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 12_000 } })).toBe(12_000) - expect(contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 0 } })).toBe(0) - expect(contextUsageDefaultOutputReserve()).toBeUndefined() + test("derives the reserve source from the model output limit", () => { + expect(contextUsageModelOutputLimit({ limit: { context: 100_000, output: 12_000 } })).toBe(12_000) + expect(contextUsageModelOutputLimit({ limit: { context: 100_000, output: 0 } })).toBe(0) + expect(contextUsageModelOutputLimit()).toBeUndefined() }) test("preserves zero output reserve when deriving the compact threshold", () => { @@ -123,7 +123,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 100_000, output: 0 } }, tokens: tokens({ input: 1_000 }), compaction: {}, - defaultOutputReserve: contextUsageDefaultOutputReserve({ limit: { context: 100_000, output: 0 } }), + defaultReserveTokens: contextUsageModelOutputLimit({ limit: { context: 100_000, output: 0 } }), }) expect(usage.compactThreshold).toBe(100_000) @@ -134,7 +134,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 10_000, output: 50_000 } }, tokens: tokens({ input: 12_000 }), compaction: { reserved: 20_000 }, - defaultOutputReserve: 50_000, + defaultReserveTokens: 50_000, }) expect(usage.compactThreshold).toBe(0) @@ -146,7 +146,7 @@ describe("deriveContextUsage", () => { model: { limit: { context: 100_000, output: 10_000 } }, tokens: tokens({ input: 1_000 }), compaction: { auto: false }, - defaultOutputReserve: 10_000, + defaultReserveTokens: 10_000, }) expect(usage.autoCompactEnabled).toBe(false) From 61768d0f150e1bbb201aec7fd1baa71831bad292 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 08:27:38 +0800 Subject: [PATCH 4/5] ci: order typecheck dependencies --- turbo.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index 575b38059..2fb5f2bc2 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,9 @@ "globalEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "tasks": { - "typecheck": {}, + "typecheck": { + "dependsOn": ["^typecheck"] + }, "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] From 7378df885506d0a15523e8be59f814ce1467ce89 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 08:29:08 +0800 Subject: [PATCH 5/5] fix: sanitize context reserve values --- packages/util/src/context-usage.ts | 9 +++++++-- packages/util/test/context-usage.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/util/src/context-usage.ts b/packages/util/src/context-usage.ts index 01cf59b3b..c7fa413bb 100644 --- a/packages/util/src/context-usage.ts +++ b/packages/util/src/context-usage.ts @@ -49,6 +49,11 @@ export function contextUsageModelOutputLimit(model?: ContextUsageModel) { return model?.limit.output } +function nonNegativeFinite(value: number | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined + return Math.max(0, value) +} + export function deriveContextUsage(input: ContextUsageInput): ContextUsage { const usedTokens = contextUsageUsedTokens(input.tokens) const context = input.model?.limit.context @@ -67,8 +72,8 @@ export function deriveContextUsage(input: ContextUsageInput): ContextUsage { const effectiveInputLimit = input.model?.limit.input ?? context // The caller passes the reserve source tokens. This helper applies the shared // 20K cap so runtime and UI cannot drift. - const reserved = - input.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, input.defaultReserveTokens ?? COMPACTION_BUFFER) + const fallbackReserve = Math.min(COMPACTION_BUFFER, nonNegativeFinite(input.defaultReserveTokens) ?? COMPACTION_BUFFER) + const reserved = nonNegativeFinite(input.compaction?.reserved) ?? fallbackReserve const compactThreshold = Math.max(0, effectiveInputLimit - reserved) const usagePercent = effectiveInputLimit > 0 ? (usedTokens / effectiveInputLimit) * 100 : null diff --git a/packages/util/test/context-usage.test.ts b/packages/util/test/context-usage.test.ts index b8f694cb6..01a992083 100644 --- a/packages/util/test/context-usage.test.ts +++ b/packages/util/test/context-usage.test.ts @@ -112,6 +112,24 @@ describe("deriveContextUsage", () => { expect(zero.compactThreshold).toBe(100_000) }) + test("sanitizes invalid reserve values before threshold math", () => { + const negative = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ input: 1_000 }), + compaction: { reserved: -1 }, + defaultReserveTokens: 10_000, + }) + const nonFinite = deriveContextUsage({ + model: { limit: { context: 100_000, output: 10_000 } }, + tokens: tokens({ input: 1_000 }), + compaction: { reserved: Number.NaN }, + defaultReserveTokens: 10_000, + }) + + expect(negative.compactThreshold).toBe(100_000) + expect(nonFinite.compactThreshold).toBe(90_000) + }) + test("derives the reserve source from the model output limit", () => { expect(contextUsageModelOutputLimit({ limit: { context: 100_000, output: 12_000 } })).toBe(12_000) expect(contextUsageModelOutputLimit({ limit: { context: 100_000, output: 0 } })).toBe(0)