Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
38ac287
fix(commands): wire account dialog OAuth flow
iceteaSA Jul 24, 2026
0141af6
fix(quota): refresh sidebar state on modal open
iceteaSA Jul 24, 2026
d7e7f81
refactor(quota): collapse to gemini / non-gemini pools matching the r…
iceteaSA Jul 24, 2026
d56177f
test(quota): cover identity stamp mismatch and fail-open paths
iceteaSA Jul 24, 2026
db2d762
fix(tui): show both quota pools in the collapsed row
iceteaSA Jul 24, 2026
9e1ff83
fix(quota): persist identity stamp + correct pool attribution
Jul 24, 2026
52b7802
fix(commands): add account to live runtime + oauth concurrency/seam
Jul 24, 2026
90337d9
fix(quota): preserve reset times + current-index on sidebar/remove
Jul 24, 2026
50340de
test: strengthen quota/killswitch/oauth coverage + small ui fixes
Jul 24, 2026
c0ae5f2
feat(quota): adopt retrieveUserQuotaSummary as the windowed quota source
Jul 24, 2026
2c0d8be
feat(tui): render per-window quota bars (weekly/5h) per pool
Jul 24, 2026
d645a7c
test: window-aware quota fixtures + e2e summary route
Jul 24, 2026
d69078b
fix(tui): carry quota windows through the sidebar read path
Jul 24, 2026
bb887de
fix(quota): project cached windows through every sidebar producer
Jul 24, 2026
fe5d936
fix(quota): source managedProjectId from the account record
Jul 24, 2026
1cbcfd6
fix(core): project record-level managedProjectId through account meta…
Jul 24, 2026
8bff3ec
fix(auth): delegate loader fetch through the live runtime
Jul 24, 2026
37e4952
fix(accounts): preserve per-family active indexes and cooldowns on re…
Jul 24, 2026
cf3cab1
fix(quota): endpoint failover, tolerant bucket parsing, concurrent fe…
Jul 24, 2026
0aa3248
test: strengthen oauth/removal/back-compat coverage
Jul 24, 2026
7d31887
fix(test): pin fences test events for concurrent quota fetches
Jul 24, 2026
ffeffde
test: make windowed-quota and record-fallback regressions effective
Jul 24, 2026
5602193
fix(accounts): align removal index semantics with the core manager
Jul 25, 2026
bfd519a
fix(accounts): distinguish an absent cooldown field from an empty one
Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build": "bun run --cwd packages/core build && bun run --cwd packages/opencode build && bun run --cwd packages/pi build",
"typecheck": "bun run --cwd packages/core build && bun run --cwd packages/opencode typecheck && bun run --cwd packages/pi typecheck && tsc -p tsconfig.scripts.json",
"test": "bun run --cwd packages/core build && bun test --isolate packages/core/src packages/opencode/src packages/pi/src test/",
"test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts ./packages/e2e-tests/src/fetch-guard.test.ts",
"test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts ./packages/e2e-tests/src/fetch-guard.test.ts ./packages/e2e-tests/src/mock-antigravity-server.test.ts",
"test:e2e:models": "bun run --cwd packages/opencode test:e2e:models",
"test:e2e:regression": "bun run --cwd packages/opencode test:e2e:regression",
"prepublishOnly": "bun run build",
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/__fixtures__/quota/free-ruqs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"groups": [
{
"buckets": [
{
"bucketId": "gemini-weekly",
"displayName": "Weekly Limit",
"window": "weekly",
"resetTime": "2026-07-31T15:54:18Z",
"description": "You have used some of your weekly limit, it will fully refresh in 6 days, 22 hours.",
"remainingFraction": 0.8875432
}
],
"displayName": "Gemini Models",
"description": "Models within this group: Gemini Flash, Gemini Pro"
},
{
"buckets": [
{
"bucketId": "3p-weekly",
"displayName": "Weekly Limit",
"window": "weekly",
"resetTime": "2026-07-31T17:19:52Z",
"remainingFraction": 1
}
],
"displayName": "Claude and GPT models",
"description": "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS"
}
],
"description": "Within each group, models share a weekly limit. Quota is consumed proportionally to the cost of the tokens. Thus, limits will last longer with shorter tasks or using more cost-effective models. Your weekly limit is tied directly to your individual tier."
}
49 changes: 49 additions & 0 deletions packages/core/src/__fixtures__/quota/pro-ruqs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"groups": [
{
"buckets": [
{
"bucketId": "gemini-weekly",
"displayName": "Weekly Limit",
"window": "weekly",
"resetTime": "2026-07-28T18:24:21Z",
"description": "You have used some of your weekly limit, it will fully refresh in 4 days, 1 hour.",
"remainingFraction": 0.9214085
},
{
"bucketId": "gemini-5h",
"displayName": "Five Hour Limit",
"window": "5h",
"resetTime": "2026-07-24T20:43:21Z",
"description": "You have used some of your 5-hour limit, it will fully refresh in 3 hours, 25 minutes.",
"remainingFraction": 0.9885728
}
],
"displayName": "Gemini Models",
"description": "Models within this group: Gemini Flash, Gemini Pro"
},
{
"buckets": [
{
"bucketId": "3p-weekly",
"displayName": "Weekly Limit",
"window": "weekly",
"resetTime": "2026-07-31T13:41:52Z",
"description": "You have used some of your weekly limit, it will fully refresh in 6 days, 20 hours.",
"remainingFraction": 0.9852099
},
{
"bucketId": "3p-5h",
"displayName": "Five Hour Limit",
"window": "5h",
"resetTime": "2026-07-24T18:41:52Z",
"description": "You have used some of your 5-hour limit, it will fully refresh in 1 hour, 23 minutes.",
"remainingFraction": 0.9556296
}
],
"displayName": "Claude and GPT models",
"description": "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS"
}
],
"description": "Within each group, models share a weekly limit and a 5-hour limit. Quota is consumed proportionally to the cost of the tokens. Thus, limits will last longer with shorter tasks or using more cost-effective models. The 5-hour limit smooths out aggregate demand to fairly distribute global capacity across all users, while your weekly limit is tied directly to your individual tier."
}
194 changes: 194 additions & 0 deletions packages/core/src/account-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,133 @@ describe('core AccountManager', () => {
expect(memory.state()?.accounts).toHaveLength(1)
})

it('persists and restores the cachedQuotaAccountId stamp across save→loadFromDisk', async () => {
const seeded: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: 'r1', projectId: 'p1', addedAt: 1, lastUsed: 0 },
{ refreshToken: 'r2', projectId: 'p2', addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
}
const memory = createStore(seeded)
const manager = new AccountManager(undefined, seeded, {
store: memory.store,
now: () => 1_700_000_000_000,
})
// Seed a cached quota for the first account — this also stamps it with
// the opaque identity derived from `r1`.
manager.updateQuotaCache(0, {
gemini: { remainingFraction: 0.42, modelCount: 1 },
})
expect(manager.getAccounts()[0]?.cachedQuotaAccountId).toMatch(
/^[a-f0-9]{16}$/,
)
const expectedStamp = manager.getAccounts()[0]?.cachedQuotaAccountId

await manager.saveToDiskReplace()

const persisted = memory.state()
expect(persisted?.accounts[0]?.cachedQuota).toEqual({
gemini: { remainingFraction: 0.42, modelCount: 1 },
})
expect(persisted?.accounts[0]?.cachedQuotaAccountId).toBe(expectedStamp)

// Roundtrip: a fresh manager built from the persisted snapshot must
// surface the same stamp on the same account (same refresh token).
const reloaded = new AccountManager(undefined, persisted ?? undefined, {
store: memory.store,
now: () => 1_700_000_001_000,
})
expect(reloaded.getAccounts()[0]?.cachedQuotaAccountId).toBe(expectedStamp)
// Stamp mismatch path: a roundtripped account whose stored stamp no
// longer matches its current refresh token is dropped at projection
// time (no quota rendered) — see `toCommandAccountRow` /
// `updateQuotaCache`. Here we just confirm the in-memory stamp is
// present so the projection can decide.
const tampered: AccountStorageV4 = {
version: 4,
accounts: [
{
refreshToken: 'r1',
addedAt: 1,
lastUsed: 0,
// Stale stamp captured for a different refresh token.
cachedQuotaAccountId: 'deadbeefcafebabe',
cachedQuota: { gemini: { remainingFraction: 0.42, modelCount: 1 } },
},
],
activeIndex: 0,
}
const tamperedMemory = createStore(tampered)
const tamperedManager = new AccountManager(undefined, tampered, {
store: tamperedMemory.store,
})
expect(tamperedManager.getAccounts()[0]?.cachedQuotaAccountId).toBe(
'deadbeefcafebabe',
)
// The next legitimate update rewrites the stamp from the current
// refresh token, so a write to the same account cannot persist the
// stale stamp forward.
tamperedManager.updateQuotaCache(0, {
gemini: { remainingFraction: 0.5, modelCount: 1 },
})
expect(tamperedManager.getAccounts()[0]?.cachedQuotaAccountId).not.toBe(
'deadbeefcafebabe',
)
})

it('drops the quota write when the refresh token captured at refresh time is gone (remove-during-refresh race)', () => {
// Race: an async quota refresh is in flight for account A while the
// user removes account A from the pool. When the refresh resolves,
// index 0 now points at a different account (B). Without the
// identity check the quota would be written onto B's slot — exactly
// the cross-account misattribution P1#3 fixes.
const seeded: AccountStorageV4 = {
version: 4,
accounts: [
{ refreshToken: 'r1', projectId: 'p1', addedAt: 1, lastUsed: 0 },
{ refreshToken: 'r2', projectId: 'p2', addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
}
const memory = createStore(seeded)
const manager = new AccountManager(undefined, seeded, {
store: memory.store,
})

// Capture the refresh token BEFORE the (simulated) async refresh
// resolves. The caller is expected to pass this as
// `expectedRefreshToken` so the write is bound to the right account.
const refreshTokenForA = manager.getAccounts()[0]?.parts.refreshToken
expect(refreshTokenForA).toBe('r1')

// Concurrent user action: remove account A. Account B (r2) now sits
// at index 0.
expect(manager.removeAccountByIndex(0)).toBe(true)
expect(manager.getAccounts()[0]?.parts.refreshToken).toBe('r2')

// The async refresh finally resolves. The caller re-resolves the
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// live index for `r1` (which is now `-1`) and the quota write is
// then attempted via `updateQuotaCache` at index 0 with the
// captured `expectedRefreshToken`. The guard MUST drop the write
// because the captured token no longer matches the account at
// index 0 — B would otherwise receive A's quota percentages.
const liveIndex = manager
.getAccounts()
.findIndex((entry) => entry.parts.refreshToken === refreshTokenForA)
expect(liveIndex).toBe(-1)
manager.updateQuotaCache(
0,
{ gemini: { remainingFraction: 0.42, modelCount: 1 } },
refreshTokenForA,
)
// No quota should have landed on whichever account shifted into
// index 0.
expect(manager.getAccounts()[0]?.cachedQuota).toBeUndefined()
expect(manager.getAccounts()[0]?.cachedQuotaAccountId).toBeUndefined()
})

it('coalesces requested saves and dispose flushes immediately', async () => {
const memory = createStore(stored)
const manager = new AccountManager(undefined, stored, {
Expand Down Expand Up @@ -175,3 +302,70 @@ describe('AccountManager instance dependencies', () => {
expect(second.getAccounts()[0]?.coolingDownUntil).toBe(9_500)
})
})

describe('managedProjectId projection', () => {
it('getAccountsForQuotaCheck falls back to record managedProjectId when parts lack it', () => {
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{
email: 'test@example.com',
refreshToken: 'bare-refresh-token',
projectId: 'my-project',
managedProjectId: 'my-managed-project',
addedAt: 1_000,
lastUsed: 2_000,
},
],
activeIndex: 0,
}
const manager = new AccountManager(undefined, stored, {
store: createStore(stored).store,
now: () => 1_000,
})
// Simulate a bare-token rotation that strips managedProjectId from
// parts — the record-level field is the only remaining source.
const allAccounts = manager.getAccounts()
allAccounts[0]!.parts.managedProjectId = undefined

const accounts = manager.getAccountsForQuotaCheck()
expect(accounts).toHaveLength(1)
expect(accounts[0]!.projectId).toBe('my-project')
expect(accounts[0]!.managedProjectId).toBe('my-managed-project')
})

it('save→reload round-trip preserves managedProjectId from the record', async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const { store, state } = createStore(null)
const stored: AccountStorageV4 = {
version: 4,
accounts: [
{
email: 'test@example.com',
refreshToken: 'bare-refresh-token',
projectId: 'my-project',
managedProjectId: 'my-managed-project',
addedAt: 1_000,
lastUsed: 2_000,
},
],
activeIndex: 0,
}
const manager = new AccountManager(undefined, stored, {
store,
now: () => 1_000,
})
// Trigger a save — dispose clears the debounce and forces it immediately.
manager.requestSaveToDisk()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
await manager.dispose()
const saved = state()
expect(saved?.accounts[0]?.managedProjectId).toBe('my-managed-project')

// Reload and verify getAccountsForQuotaCheck still returns it.
const manager2 = new AccountManager(undefined, saved, {
store,
now: () => 1_000,
})
const accounts = manager2.getAccountsForQuotaCheck()
expect(accounts[0]!.managedProjectId).toBe('my-managed-project')
})
})
Loading