From 932719a8413a3b35a16d345b875dd32bb1715abc Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:57:00 +0200 Subject: [PATCH 1/5] fix: preserve empty scoped quota array on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A downstream reader keys off scoped-key PRESENCE: an empty [] means anthropic-auth owns the quota and no carve-out is visible, distinct from no scoped data on the snapshot. normalizeQuota and mapScopedWeeklyLimits both dropped empty arrays to undefined, which collapsed the whole quota object when scoped was the only key and silently broke that contract. Tracing confirms routing is unaffected: quotaSnapshotModelScopeIsExhausted does quota?.scoped?.find(...), which returns undefined for both [] and undefined — the predicate resolves identically, so this is a pure persistence-layer change. --- packages/core/src/accounts.ts | 12 ++-- packages/opencode/src/tests/accounts.test.ts | 66 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 9786eb46..b5393cc9 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -477,7 +477,11 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] { } }) .filter((entry): entry is AccountScopedQuotaWindow => entry != null) - if (scoped.length) quota.scoped = scoped + // Preserve empty `[]` so a downstream reader can distinguish "scoped + // owned by anthropic-auth, none visible" from "no scoped data on this + // snapshot". Pre-feature inputs without a `scoped` key are not affected + // — only inputs that already carried an array reach this line. + quota.scoped = scoped } return Object.keys(quota).length ? quota : undefined @@ -1943,8 +1947,8 @@ function slugForQuotaIdentity(value: string): string { function mapScopedWeeklyLimits( limits: OAuthUsageLimit[] | undefined, checkedAt: number, -): AccountScopedQuotaWindow[] | undefined { - if (!Array.isArray(limits)) return undefined +): AccountScopedQuotaWindow[] { + if (!Array.isArray(limits)) return [] const seen = new Set() const scoped: AccountScopedQuotaWindow[] = [] for (const limit of limits) { @@ -1974,7 +1978,7 @@ function mapScopedWeeklyLimits( checkedAt, }) } - return scoped.length ? scoped : undefined + return scoped } function mapUsageWindow( diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index a4ee6086..6e008892 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -1123,6 +1123,72 @@ describe('FallbackAccountManager', () => { ).toEqual(['fable-empty', 'fable-ok']) }) + test('preserves an empty scoped quota array through save/load when scoped is the only quota key', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'empty-scoped-only', + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { scoped: [] }, + }) + await saveAccounts(storage) + + const loaded = await loadAccounts() + const account = expectOAuthAccount(loaded?.accounts[0]) + expect(account.quota).toBeDefined() + expect(account.quota?.scoped).toBeDefined() + expect(account.quota?.scoped).toEqual([]) + }) + + test('preserves an empty scoped quota array alongside five_hour', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'empty-scoped-with-five-hour', + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: 1_000, + }, + scoped: [], + }, + }) + await saveAccounts(storage) + + const loaded = await loadAccounts() + const account = expectOAuthAccount(loaded?.accounts[0]) + expect(account.quota?.five_hour?.usedPercent).toBe(10) + expect(account.quota?.scoped).toBeDefined() + expect(account.quota?.scoped).toEqual([]) + }) + + test('routing predicate treats empty scoped array as not exhausted (invariant lock)', () => { + const quota: OAuthQuotaSnapshot = { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: 1_000, + }, + seven_day: { + usedPercent: 5, + remainingPercent: 95, + checkedAt: 1_000, + }, + scoped: [], + } + expect(quotaSnapshotModelScopeIsExhausted(quota, 'claude-fable-5')).toBe( + false, + ) + expect(quotaSnapshotPassesModelScope(quota, 'claude-fable-5')).toBe(true) + expect(quotaSnapshotPassesModelScope(quota, 'claude-opus-4-8')).toBe(true) + }) + test('refreshes fallback tokens within the four-hour minimum window', async () => { const storage = baseStorage() storage.refresh = { From d6b31a68ca03dd10425c7778249ffb20c9908e56 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:57:10 +0200 Subject: [PATCH 2/5] fix: omit 5h/7d placeholders in collapsed summary when only scoped windows visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-scoped sidebar code never emitted "5h: — 7d: —" — those dashes were always real values or omitted. When a model has no five_hour/seven_day data but a scoped window is visible, the line used to read "5h: — 7d: — Fa: 100%"; now it reads "Fa: 100%". The partial-dash case (one of the two present) is preserved. --- packages/opencode/src/sidebar-state.ts | 21 +++++++----- .../opencode/src/tests/sidebar-state.test.ts | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 545ee6f7..30a70f16 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -284,18 +284,23 @@ export function getCollapsedQuotaSummary(quota: AccountQuota | null): { } } + const scopedSegments = scoped.map( + (window) => + `${formatScopedQuotaLabel(window.title)}: ${Math.round(window.usedPercent)}%`, + ) + const primarySegments = + fiveHourUsedPercent == null && sevenDayUsedPercent == null + ? [] + : [ + `5h: ${fiveHourUsedPercent == null ? '—' : `${Math.round(fiveHourUsedPercent)}%`}`, + `7d: ${sevenDayUsedPercent == null ? '—' : `${Math.round(sevenDayUsedPercent)}%`}`, + ] + return { fiveHourUsedPercent, sevenDayUsedPercent, scopedUsedPercents, - text: [ - `5h: ${fiveHourUsedPercent == null ? '—' : `${Math.round(fiveHourUsedPercent)}%`}`, - `7d: ${sevenDayUsedPercent == null ? '—' : `${Math.round(sevenDayUsedPercent)}%`}`, - ...scoped.map( - (window) => - `${formatScopedQuotaLabel(window.title)}: ${Math.round(window.usedPercent)}%`, - ), - ].join(' '), + text: [...primarySegments, ...scopedSegments].join(' '), } } diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 23acc4e0..5d7961d7 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -191,6 +191,40 @@ describe('getCollapsedQuotaSummary', () => { expect(getCollapsedQuotaSummary(null).text).toBeNull() expect(getCollapsedQuotaSummary({}).text).toBeNull() }) + + test('omits 5h/7d placeholders when only scoped windows are visible', () => { + const summary = getCollapsedQuotaSummary({ + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 100, + remainingPercent: 0, + }, + ], + }) + expect(summary.text).toBe('Fa: 100%') + expect(summary.text).not.toContain('5h:') + expect(summary.text).not.toContain('7d:') + expect(summary.text).not.toContain('—') + }) + + test('preserves partial-dash primary segment alongside scoped windows', () => { + const summary = getCollapsedQuotaSummary({ + five_hour: { usedPercent: 23, remainingPercent: 77 }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 42, + remainingPercent: 58, + }, + ], + }) + expect(summary.text).toBe('5h: 23% 7d: — Fa: 42%') + }) }) describe('normalizeSidebarState', () => { From 8d53ee5b56a8dcf36d127e8e75ded09570547d8e Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:21:59 +0200 Subject: [PATCH 3/5] fix: preserve empty scoped snapshot through runtime-state merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPT review surfaced a second site where empty-[] was conflated with missing-data. mergeAccountRuntimeState compares quotaSnapshotCheckedAt(existing) vs quotaSnapshotCheckedAt(incoming); the existing helper only summed per-window checkedAt, so a windowless {scoped:[]} incoming snapshot read as checkedAt=0 and was treated as stale — preserving the old (exhausted-Fable) quota on disk instead of writing the empty array. Fix: stamp a top-level checkedAt on the snapshot at fetch time, propagate it through normalizeQuota so it survives save/load, and include it in quotaSnapshotCheckedAt's Math.max. For normal five_hour+seven_day snapshots the top-level stamp equals the per-window stamps so the merge behavior is unchanged; for a windowless empty-scoped snapshot it now yields the real timestamp and the empty array lands. --- packages/core/src/accounts.ts | 17 ++ packages/opencode/src/tests/accounts.test.ts | 157 +++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index b5393cc9..7a381806 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -118,6 +118,12 @@ export type OAuthQuotaSnapshot = Partial< Record > & { scoped?: AccountScopedQuotaWindow[] + // Top-level freshness stamp for the whole snapshot. mergeAccountRuntimeState + // uses this when the snapshot has no per-window checkedAt (e.g. a windowless + // empty-scoped snapshot) — without it, a windowless refresh gets read as + // checkedAt=0 and treated as stale, so an old per-window quota resurrects + // instead of being overwritten. + checkedAt?: number } export type RoutingMode = 'main-first' | 'fallback-first' @@ -451,6 +457,15 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] { if (normalized) quota[key] = normalized } + // Persist a top-level snapshot checkedAt through normalize so the + // mergeAccountRuntimeState freshness comparison stays meaningful when the + // snapshot has no per-window checkedAt (e.g. {scoped:[]}). Pre-feature + // inputs without this key are unaffected — only on-disk snapshots that + // already carry it reach this branch. + if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) { + quota.checkedAt = value.checkedAt + } + if (Array.isArray(value.scoped)) { const scoped = value.scoped .map((entry): AccountScopedQuotaWindow | undefined => { @@ -701,6 +716,7 @@ function quotaSnapshotCheckedAt(quota: OAuthQuotaSnapshot | undefined) { quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), + quota?.checkedAt ?? 0, ) } @@ -2031,6 +2047,7 @@ export async function fetchOAuthQuotaSnapshot(input: { five_hour: mapUsageWindow(usage.five_hour, checkedAt), seven_day: mapUsageWindow(usage.seven_day, checkedAt), scoped: mapScopedWeeklyLimits(usage.limits, checkedAt), + checkedAt, } satisfies OAuthQuotaSnapshot } diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 6e008892..c6307846 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -1189,6 +1189,163 @@ describe('FallbackAccountManager', () => { expect(quotaSnapshotPassesModelScope(quota, 'claude-opus-4-8')).toBe(true) }) + test('mergeAccountRuntimeState: windowless {scoped:[]} replaces stale exhausted-Fable quota', async () => { + // GPT repro: persist an exhausted-Fable scoped window at T1, then save a + // windowless empty-scoped snapshot stamped T2 > T1. mergeAccountRuntimeState + // must NOT treat the empty snapshot as stale and resurrect the old Fable + // window — loadAccounts must return scoped: []. + const accountId = 'merge-empty-scoped' + const T1 = 1_000 + const T2 = 2_000 + + const first = baseStorage() + first.accounts.push({ + id: accountId, + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: T1, + }, + seven_day: { + usedPercent: 15, + remainingPercent: 85, + checkedAt: T1, + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 100, + remainingPercent: 0, + checkedAt: T1, + }, + ], + }, + }) + await saveAccounts(first) + + const second = baseStorage() + second.accounts.push({ + id: accountId, + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { scoped: [], checkedAt: T2 }, + }) + await saveAccounts(second) + + const loaded = await loadAccounts() + const account = expectOAuthAccount( + loaded?.accounts.find((entry) => entry.id === accountId), + ) + expect(account.quota?.scoped).toEqual([]) + }) + + test('mergeAccountRuntimeState: real promo-end path lands empty scoped', async () => { + // Reachable-path lock: persist an account with five_hour/seven_day + a + // Fable scoped window at T1, then save the post-promo snapshot (5h/7d at + // T2, scoped: [], top-level checkedAt: T2). Loaded quota.scoped must be []. + const accountId = 'merge-promo-end' + const T1 = 1_000 + const T2 = 2_000 + + const first = baseStorage() + first.accounts.push({ + id: accountId, + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: T1, + }, + seven_day: { + usedPercent: 15, + remainingPercent: 85, + checkedAt: T1, + }, + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 42, + remainingPercent: 58, + checkedAt: T1, + }, + ], + }, + }) + await saveAccounts(first) + + const second = baseStorage() + second.accounts.push({ + id: accountId, + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: T2, + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: T2, + }, + scoped: [], + checkedAt: T2, + }, + }) + await saveAccounts(second) + + const loaded = await loadAccounts() + const account = expectOAuthAccount( + loaded?.accounts.find((entry) => entry.id === accountId), + ) + expect(account.quota?.scoped).toEqual([]) + }) + + test('top-level checkedAt round-trips through save/load', async () => { + const storage = baseStorage() + const stampedAt = 1_234_567 + storage.accounts.push({ + id: 'with-checked-at', + type: 'oauth', + access: 'a', + refresh: 'r', + expires: Date.now() + 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: stampedAt, + }, + checkedAt: stampedAt, + }, + }) + await saveAccounts(storage) + + const loaded = await loadAccounts() + const account = expectOAuthAccount( + loaded?.accounts.find((entry) => entry.id === 'with-checked-at'), + ) + expect(account.quota?.checkedAt).toBe(stampedAt) + }) + test('refreshes fallback tokens within the four-hour minimum window', async () => { const storage = baseStorage() storage.refresh = { From a7bc07ed84591af19a689061ba53bc1ad3f73fea Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:22:08 +0200 Subject: [PATCH 4/5] fix: preserve empty scoped array in sidebar quota normalization Same one-liner bug as the core quota normalizer, second file: normalizeAccountQuota dropped an empty scoped:[] inside the Array.isArray guard, collapsing the whole quota object to null when scoped was the only key. Unconditional assignment preserves the empty array; the OUTER Array.isArray guard means pre-feature inputs without a scoped key are unaffected. --- packages/opencode/src/sidebar-state.ts | 6 +++++- packages/opencode/src/tests/sidebar-state.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 30a70f16..17e75f46 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -121,7 +121,11 @@ function normalizeAccountQuota(value: unknown): AccountQuota | null { } }) .filter((entry): entry is ScopedQuotaWindow => entry != null) - if (scoped.length) quota.scoped = scoped + // Preserve empty `[]` so a sidebar reader can distinguish "scoped owned + // by anthropic-auth, none visible" from "no quota data at all". The OUTER + // Array.isArray guard means pre-feature inputs without a `scoped` key are + // not affected — only inputs that already carried an array reach this line. + quota.scoped = scoped } return Object.keys(quota).length ? quota : null diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 5d7961d7..d387da8a 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -263,6 +263,18 @@ describe('normalizeSidebarState', () => { }, ]) }) + + test('preserves empty scoped quota array when scoped is the only quota key', () => { + const normalized = normalizeSidebarState({ + main: { quota: { scoped: [] } }, + fallbacks: [], + route: 'main', + lastUpdated: 0, + }) + + expect(normalized.main.quota).not.toBeNull() + expect(normalized.main.quota?.scoped).toEqual([]) + }) }) describe('computeQuotaPacing', () => { From fc8c7b79feec6bc0a8c941afed1c75552d5c66b6 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:32:50 +0200 Subject: [PATCH 5/5] test: neutral rationale comment on empty-scoped merge test --- packages/opencode/src/tests/accounts.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index c6307846..aa1234ca 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -1190,10 +1190,10 @@ describe('FallbackAccountManager', () => { }) test('mergeAccountRuntimeState: windowless {scoped:[]} replaces stale exhausted-Fable quota', async () => { - // GPT repro: persist an exhausted-Fable scoped window at T1, then save a - // windowless empty-scoped snapshot stamped T2 > T1. mergeAccountRuntimeState - // must NOT treat the empty snapshot as stale and resurrect the old Fable - // window — loadAccounts must return scoped: []. + // Persist an exhausted-Fable scoped window at T1, then save a windowless + // empty-scoped snapshot stamped T2 > T1. mergeAccountRuntimeState must NOT + // treat the empty snapshot as stale and resurrect the old Fable window — + // loadAccounts must return scoped: []. const accountId = 'merge-empty-scoped' const T1 = 1_000 const T2 = 2_000