diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 9786eb46..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 => { @@ -477,7 +492,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 @@ -697,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, ) } @@ -1943,8 +1963,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 +1994,7 @@ function mapScopedWeeklyLimits( checkedAt, }) } - return scoped.length ? scoped : undefined + return scoped } function mapUsageWindow( @@ -2027,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/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 545ee6f7..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 @@ -284,18 +288,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/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index a4ee6086..aa1234ca 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -1123,6 +1123,229 @@ 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('mergeAccountRuntimeState: windowless {scoped:[]} replaces stale exhausted-Fable quota', async () => { + // 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 = { diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 23acc4e0..d387da8a 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', () => { @@ -229,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', () => {