Skip to content

Preserve empty scoped quota array through load, merge, and sidebar normalization#108

Open
iceteaSA wants to merge 5 commits into
cortexkit:mainfrom
iceteaSA:fix/weekly-scoped-limits
Open

Preserve empty scoped quota array through load, merge, and sidebar normalization#108
iceteaSA wants to merge 5 commits into
cortexkit:mainfrom
iceteaSA:fix/weekly-scoped-limits

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Rebased onto current main (the scoped weekly quota limits feature is now merged). This reduces the PR to two correctness fixes on top of that feature — both are latent bugs in the current implementation.

1. Empty scoped: [] is dropped on load and lost in the runtime-state merge

fetchOAuthQuotaSnapshot always returns scoped as an array, but two paths drop it when empty, which matters because an empty array is a meaningful state (the API returned no model-scoped carve-out) distinct from "no scoped data":

  • normalizeQuota did if (scoped.length) quota.scoped = scoped. When a persisted quota has an empty scoped and no five_hour/seven_day, the whole quota object then collapses to undefined via the Object.keys(quota).length guard. Fixed with an unconditional quota.scoped = scoped inside the existing Array.isArray(value.scoped) guard (so pre-feature data with no scoped key is unaffected).
  • mergeAccountRuntimeState compares freshness via quotaSnapshotCheckedAt, which returned 0 for a windowless {scoped:[]} snapshot — so the merge treated it as stale and resurrected the previous (e.g. exhausted) scoped window instead of writing the empty array. Fixed by adding a top-level checkedAt to OAuthQuotaSnapshot (stamped in fetchOAuthQuotaSnapshot, preserved in normalizeQuota, included in quotaSnapshotCheckedAt's max). For a normal snapshot the top-level stamp equals the per-window stamps, so the freshness comparison is unchanged; a windowless empty-scoped snapshot now carries its real timestamp.

Routing is unaffected: an empty scoped: [] behaves identically to undefined in quotaSnapshotModelScopeIsExhausted / getScopedQuotaWindowForModel (.find() over [] and the optional-chain over undefined both yield no window), and no routing predicate reads the new top-level checkedAt. A regression test locks this invariant.

2. Collapsed sidebar shows 5h: — 7d: — placeholders when only a scoped window is visible

getCollapsedQuotaSummary unconditionally emitted the 5h/7d segment even when both are null, producing e.g. 5h: — 7d: — Fa: 100%. Now the 5h/7d segment is emitted only when at least one is present; when both are null (reachable only because a scoped window is visible) the line is just the scoped segment. The all-windows-present and single-present (dash-for-absent) cases are byte-identical.

Tests

Red-first on the current code for every fix: empty-[] round-trip (scoped-only and alongside five_hour), the windowless-{scoped:[]} merge-freshness repro, top-level checkedAt round-trip, the sidebar normalizeAccountQuota empty-array case, the collapsed-summary placeholder omission, plus a routing-invariant lock (scoped: [] is not treated as exhausted). All suites green — core 6, opencode 768, pi 47, e2e 2; typecheck and lint clean.

Greptile Summary

This PR surfaces model-scoped weekly quota carve-outs (e.g., the Fable-only promo window) by parsing limits[] from /api/oauth/usage, persisting them as quota.scoped[], and rendering them in the collapsed summary alongside the existing 5h/7d windows. Scoped windows are intentionally excluded from routing, policy, and killswitch logic.

  • Parse & persist: mapScopedWeeklyLimits filters for kind:\"weekly_scoped\" + group:\"weekly\", requires a non-empty display_name, deduplicates by model id/slug, and always returns an array (never undefined). A new top-level checkedAt on OAuthQuotaSnapshot ensures windowless empty-scoped snapshots win freshness comparisons in mergeAccountRuntimeState instead of being treated as stale (T=0).
  • Render: getCollapsedQuotaSummary now omits the \"5h: — 7d: —\" prefix when both windows are absent and only scoped data is visible; the fix from the previous thread is present and tested.
  • Isolation: quotaSnapshotPassesPolicy and killswitchPassesPolicy iterate only ['five_hour', 'seven_day']; a fully-exhausted scoped window does not mark an account dead.

Confidence Score: 5/5

Safe to merge. The additive nature of the change (scoped windows are parsed and displayed but never gate routing or killswitch decisions) limits blast radius, and the freshness/merge logic for empty-scoped snapshots is correctly guarded by the new top-level checkedAt field.

All changed paths are additive or narrowing: the existing five_hour/seven_day parse/persist/render paths are untouched, policy and killswitch checks are explicitly constrained to those two windows, and the new top-level checkedAt correctly handles the windowless-empty-scoped merge scenario. Edge cases (malformed limits, non-finite percents, absent display_name, empty arrays through save/load) are all handled and tested. No routing, auth, or data-loss risk is introduced.

No files require special attention. The tui.tsx rendering changes noted in previous review comments are outside this diff; the padding alignment concern there is tracked separately.

Important Files Changed

Filename Overview
packages/core/src/accounts.ts Adds OAuthQuotaSnapshot.checkedAt, propagates scoped: [] through normalizeQuota, and adds top-level checkedAt to fetchOAuthQuotaSnapshot; freshness logic is correct for windowless snapshots.
packages/opencode/src/sidebar-state.ts Fixes collapsed summary to omit '5h: — 7d: —' prefix when only scoped windows are present; normalizeAccountQuota now preserves scoped: [] as intended.
packages/opencode/src/tests/accounts.test.ts Adds round-trip, merge-freshness, policy-exclusion, and empty-array preservation tests; cases are well-chosen and cover the introduced edge conditions.
packages/opencode/src/tests/sidebar-state.test.ts Adds tests for scoped-only collapsed summary, partial-dash primary segment with scoped, and empty-array normalization; all assert the right behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["/api/oauth/usage response"] --> B["fetchOAuthQuotaSnapshot"]
    B --> C["mapUsageWindow x2"]
    B --> D["mapScopedWeeklyLimits"]
    D --> E["AccountScopedQuotaWindow[]"]
    E --> F["scoped: []"]
    B --> G["checkedAt: now"]
    C --> H["OAuthQuotaSnapshot"]
    F --> H
    G --> H
    H --> I["normalizeQuota"]
    I --> J["OAuthAccount.quota"]
    J --> K["quotaSnapshotCheckedAt"]
    K --> L["mergeAccountRuntimeState"]
    J --> M["projectQuotaToSidebar"]
    M --> N["getCollapsedQuotaSummary"]
    J --> R["quotaSnapshotPassesPolicy / killswitchPassesPolicy"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["/api/oauth/usage response"] --> B["fetchOAuthQuotaSnapshot"]
    B --> C["mapUsageWindow x2"]
    B --> D["mapScopedWeeklyLimits"]
    D --> E["AccountScopedQuotaWindow[]"]
    E --> F["scoped: []"]
    B --> G["checkedAt: now"]
    C --> H["OAuthQuotaSnapshot"]
    F --> H
    G --> H
    H --> I["normalizeQuota"]
    I --> J["OAuthAccount.quota"]
    J --> K["quotaSnapshotCheckedAt"]
    K --> L["mergeAccountRuntimeState"]
    J --> M["projectQuotaToSidebar"]
    M --> N["getCollapsedQuotaSummary"]
    J --> R["quotaSnapshotPassesPolicy / killswitchPassesPolicy"]
Loading

Reviews (4): Last reviewed commit: "test: neutral rationale comment on empty..." | Re-trigger Greptile

Comment thread packages/opencode/src/sidebar-state.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found and verified against the latest diff

Confidence score: 3/5

  • In packages/core/src/accounts.ts, normalizeQuota drops empty scoped arrays on persistence round-trips, which breaks the fetchOAuthQuotaSnapshot contract that scoped is always an array and can cause downstream consumers to mis-handle quota state — preserve empty arrays during normalization and add a round-trip regression test before merging.
  • In packages/opencode/src/tui.tsx (ScopedQuotaRow), bar segment coloring is driven by percentage-only tone while the scoped window already carries a severity, so the visual state can contradict the reported status and mislead users — route severity into quotaBarSegments so the bar and percentage text stay consistent.
  • In packages/opencode/src/sidebar-state.ts, duplicated scoped-normalization logic for main.quota and fallback accounts increases the chance of drift as the new scoped shape evolves, leading to inconsistent behavior between account paths — extract a shared normalization helper and use it in both places.
  • In packages/core/src/tests/scoped-quota.test.ts, key regression paths are still untested (dedupe by id vs display name, group: 'weekly' guard, and coexistence with legacy five_hour/seven_day windows), so subtle parsing regressions could ship undetected — add these targeted cases before merge or track them as immediate follow-up test debt.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/sidebar-state.ts Outdated
Comment thread packages/opencode/src/tui.tsx Outdated
Comment thread packages/core/src/accounts.ts Outdated
Comment thread packages/core/src/tests/scoped-quota.test.ts Outdated
Comment thread packages/core/src/tests/scoped-quota.test.ts Outdated
Comment thread packages/core/src/tests/scoped-quota.test.ts Outdated
iceteaSA added 5 commits July 4, 2026 16:57
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.
…ndows visible

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.
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.
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.
@iceteaSA iceteaSA force-pushed the fix/weekly-scoped-limits branch from b149c3b to fc8c7b7 Compare July 4, 2026 15:33
@iceteaSA iceteaSA changed the title Surface model-scoped weekly quota limits Preserve empty scoped quota array through load, merge, and sidebar normalization Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant