From 71404c99be90e8515115e45db5dbbf23e045edec Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:53:37 +0000 Subject: [PATCH] fix(native-chat): show when a Codex usage limit resets, like Claude (#40992) * fix(codex): keep the reset time when a usage limit rejects before the report reaches 100% Co-Authored-By: Claude Opus 5.5 * fix(codex): read the usage-limit error code from the renamed error info * fix(codex): skip windows that already reset when blaming a nearly spent one --------- Co-authored-by: moncha3 Co-authored-by: Claude Opus 5.5 --- .../catalog/codex-usage-limits.test.ts | 27 ++++++++++++++++++ .../structured/catalog/codex-usage-limits.ts | 28 ++++++++++++------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/main/codex/structured/catalog/codex-usage-limits.test.ts b/src/main/codex/structured/catalog/codex-usage-limits.test.ts index e84cddfd769..ba885b46b7f 100644 --- a/src/main/codex/structured/catalog/codex-usage-limits.test.ts +++ b/src/main/codex/structured/catalog/codex-usage-limits.test.ts @@ -62,6 +62,33 @@ describe('a Codex usage limit', () => { ]) translator.dispose() }) + it('takes the reset from a nearly spent window when the rejection outruns the last report', () => { + const limits = new CodexUsageLimits() + const rejectedWith = (primaryPercent: number, code = 'usageLimitExceeded') => { + limits.observe('notification:account/rateLimits/updated', { + rateLimits: { + limitId: 'codex', + primary: { usedPercent: primaryPercent, windowDurationMins: 300, resetsAt: RESETS_AT_S }, + secondary: { usedPercent: 70, windowDurationMins: 10080, resetsAt: RESETS_AT_S + 3600 } + } + }) + return limits.failure({ error: { codexErrorInfo: code, message: CODEX_WORDS } })?.usageLimit + } + expect(rejectedWith(99)).toEqual({ window: 'session', resetsAt: RESETS_AT_S * 1000 }) + expect(rejectedWith(50)).toBeUndefined() + expect(rejectedWith(99, 'rateLimitExceeded')).toBeUndefined() + + limits.observe('notification:account/rateLimits/updated', { + rateLimits: { + primary: { usedPercent: 99, windowDurationMins: 300, resetsAt: 1_000_000 }, + secondary: { usedPercent: 95, windowDurationMins: 10080, resetsAt: RESETS_AT_S } + } + }) + expect( + limits.failure({ error: { codexErrorInfo: 'usageLimitExceeded', message: CODEX_WORDS } }) + ?.usageLimit + ).toEqual({ window: 'weekly', resetsAt: RESETS_AT_S * 1000 }) + }) it('merges null account updates and keeps unnamed exhausted windows in reset calculation', () => { const limits = new CodexUsageLimits() const observe = (rateLimits: Record) => diff --git a/src/main/codex/structured/catalog/codex-usage-limits.ts b/src/main/codex/structured/catalog/codex-usage-limits.ts index ca4bfd0c639..4e7fad1cc68 100644 --- a/src/main/codex/structured/catalog/codex-usage-limits.ts +++ b/src/main/codex/structured/catalog/codex-usage-limits.ts @@ -16,6 +16,8 @@ import { const RATE_LIMITS_UPDATED = 'notification:account/rateLimits/updated' const USAGE_LIMIT_ERRORS = ['usageLimitExceeded', 'rateLimitExceeded'] +/** The last report can trail a rejection by the request that went over, so a window this full counts as the spent one. */ +const NEARLY_SPENT_PERCENT = 90 type CodexAccountRateLimits = CodexRateLimitWindowsSnapshot & { rateLimitReachedType?: unknown @@ -62,7 +64,7 @@ export class CodexUsageLimits { const text = [message, additional && additional !== message ? additional : null] .filter((part): part is string => part !== null) .join('\n') - const limit = this.limit() + const limit = this.limit(info.error === 'usageLimitExceeded') return agentSessionFailureFact('usageLimitReached', { ...(limit ? { usageLimit: limit } : {}), detail: providerDiagnostic(text, 'person') @@ -70,21 +72,16 @@ export class CodexUsageLimits { } /** All spent windows must reset; report the latest only if every reset is known. */ - private limit(): AgentSessionUsageLimit | null { + private limit(rejected: boolean): AgentSessionUsageLimit | null { const monthlyPlan = this.snapshot.planType === 'free' || this.snapshot.planType === 'go' - const spent = ( + const reported = ( [ ['primary', monthlyPlan ? 43200 : 300], ['secondary', 10080] ] as const ).flatMap(([position, fallback]) => { const raw = this.snapshot[position] - if ( - !raw || - typeof raw.usedPercent !== 'number' || - !Number.isFinite(raw.usedPercent) || - raw.usedPercent < 100 - ) { + if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) { return [] } const duration = @@ -99,8 +96,19 @@ export class CodexUsageLimits { ? ('weekly' as const) : ('session' as const) : undefined - return [{ window, resetsAt: raw.resetsAt }] + return [{ window, resetsAt: raw.resetsAt, usedPercent: raw.usedPercent }] }) + // A window that already reset cannot be the one that just rejected. + const live = reported.filter( + (entry) => typeof entry.resetsAt !== 'number' || entry.resetsAt * 1000 > this.now() + ) + const fullest = Math.max(...live.map((entry) => entry.usedPercent)) + const spent = + rejected && + fullest >= NEARLY_SPENT_PERCENT && + !reported.some((entry) => entry.usedPercent >= 100) + ? live.filter((entry) => entry.usedPercent >= fullest) + : reported.filter((entry) => entry.usedPercent >= 100) const reached = this.snapshot.rateLimitReachedType const credits = typeof reached === 'string' && reached.endsWith('credits_depleted') const window: AgentSessionUsageLimitWindow | undefined = credits