Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions packages/opencode/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1241,7 +1241,6 @@ async function buildResetPreviewRow(
const precondition = evaluateResetPrecondition(
quota,
ctx.quotaManager.isRateLimited(accountKey),
applicableAvailableCount,
ctx.now(),
)
let reason: string | undefined
Expand Down Expand Up @@ -1313,11 +1312,17 @@ function renderResetConfirm(row: ResetPreviewRow): string {
'',
`Account: **${row.label}** (\`${row.accountKey}\`)`,
`Current quota: **${row.usedPercent ?? 'unknown'}% used**`,
`Credit: **Spend 1 of ${row.applicableAvailableCount ?? 0}**`,
`Credit: **Spend 1 of ${row.availableCount ?? 0}**`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the credit list contains an eligible credit but both available-count fields are omitted, this confirmation still says Spend 1 of 0. Preserve the unknown count or render an explicit unknown/at-least-one value instead of defaulting it to zero.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/commands.ts, line 1315:

<comment>When the credit list contains an eligible credit but both available-count fields are omitted, this confirmation still says `Spend 1 of 0`. Preserve the unknown count or render an explicit unknown/at-least-one value instead of defaulting it to zero.</comment>

<file context>
@@ -1313,11 +1312,17 @@ function renderResetConfirm(row: ResetPreviewRow): string {
     `Account: **${row.label}** (\`${row.accountKey}\`)`,
     `Current quota: **${row.usedPercent ?? 'unknown'}% used**`,
-    `Credit: **Spend 1 of ${row.applicableAvailableCount ?? 0}**`,
+    `Credit: **Spend 1 of ${row.availableCount ?? 0}**`,
     `Credit expires: **${row.selectedCreditExpiresAt ?? 'unavailable'}**`,
     `Quota resets: **${row.resetTime ?? 'unavailable'}**`,
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid. 225c437: when both available_count fields are absent, availableCount falls back to the number of credits the eligibility filter accepts (factored into isResetCreditEligible / countEligibleResetCredits so selectCreditToSpend and the count share one definition); if that is also 0 the confirmation renders unknown, never 0. The TUI DialogConfirm reads the same row value, so both surfaces agree. Test: both counts omitted, one eligible credit → Spend 1 of 1; removing the list fallback makes it fail.

`Credit expires: **${row.selectedCreditExpiresAt ?? 'unavailable'}**`,
`Quota resets: **${row.resetTime ?? 'unavailable'}**`,
'',
]
if ((row.availableCount ?? 0) > 0 && row.applicableAvailableCount === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When WHAM omits applicable_available_count, this condition treats the missing metric as an explicit zero and tells users the server excludes the credit. Keep undefined distinct from 0, and disclose only for an explicitly reported zero.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/commands.ts, line 1320:

<comment>When WHAM omits `applicable_available_count`, this condition treats the missing metric as an explicit zero and tells users the server excludes the credit. Keep `undefined` distinct from `0`, and disclose only for an explicitly reported zero.</comment>

<file context>
@@ -1313,11 +1312,17 @@ function renderResetConfirm(row: ResetPreviewRow): string {
     `Quota resets: **${row.resetTime ?? 'unavailable'}**`,
     '',
   ]
+  if ((row.availableCount ?? 0) > 0 && row.applicableAvailableCount === 0) {
+    lines.push(
+      'The server does not currently count this credit as applicable; redemption may return a no-op, and a no-op does not spend the credit.',
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid. The === 0 was right but commands.ts:1240 coerced the omitted field with ?? 0 before it reached the row. 225c437 keeps undefined through to the row, discloses only on an explicit 0, and renders ?/N in the preview line when the field is absent. Test: omitted resetCreditsApplicable → no disclosure, ? marker; re-adding the ?? 0 makes it fail.

lines.push(
'The server does not currently count this credit as applicable; redemption may return a no-op, and a no-op does not spend the credit.',
)
lines.push('')
}
if (row.eligible && row.chatgptAccountId) {
lines.push(
`Confirm: \`/openai-reset confirm ${encodeURIComponent(row.accountKey)} ${encodeURIComponent(row.chatgptAccountId)}\``,
Expand Down Expand Up @@ -1360,8 +1365,6 @@ function resetErrorPayload(
'There is no active reset redemption to retry. Reopen the account list.',
not_exhausted:
'No credit was spent: the fresh account state is not exhausted.',
no_applicable_credits:
'No credit was spent: no applicable credits are available.',
no_eligible_credit:
'No credit was spent: no eligible credit was returned.',
}
Expand Down
15 changes: 2 additions & 13 deletions packages/opencode/src/core/reset-credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type ResetPrecondition =
| { ok: true }
| {
ok: false
reason: 'not exhausted' | 'no applicable credits'
reason: 'not exhausted'
}

export interface ResetStateDeps {
Expand Down Expand Up @@ -92,7 +92,6 @@ export type ResetRedemptionErrorKind =
| 'expired_unreconciled'
| 'retry_without_inflight'
| 'not_exhausted'
| 'no_applicable_credits'
| 'no_eligible_credit'

export class ResetRedemptionError extends Error {
Expand Down Expand Up @@ -464,17 +463,13 @@ export function resetWindowIsExhausted(
export function evaluateResetPrecondition(
quota: OAuthQuotaSnapshot,
hasActiveRateLimitMark: boolean,
applicableAvailableCount: number,
now: number,
): ResetPrecondition {
const exhausted =
hasActiveRateLimitMark ||
resetWindowIsExhausted(quota.primary, now) ||
resetWindowIsExhausted(quota.secondary, now)
if (!exhausted) return { ok: false, reason: 'not exhausted' }
if (applicableAvailableCount <= 0) {
return { ok: false, reason: 'no applicable credits' }
}
return { ok: true }
}

Expand Down Expand Up @@ -742,16 +737,10 @@ export async function runResetCreditRedemption(
const precondition = evaluateResetPrecondition(
quota,
deps.hasActiveRateLimitMark(input.accountKey),
quota.resetCreditsApplicable ?? 0,
deps.now(),
)
if (!precondition.ok) {
throw new ResetRedemptionError(
precondition.reason === 'not exhausted'
? 'not_exhausted'
: 'no_applicable_credits',
precondition.reason,
)
throw new ResetRedemptionError('not_exhausted', precondition.reason)
}
if (!selectCreditToSpend(credits.credits)) {
throw new ResetRedemptionError(
Expand Down
16 changes: 8 additions & 8 deletions packages/opencode/src/tests/command-dialogs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,9 @@ describe('command dialogs', () => {
usedPercent: 100,
availableCount: 4,
applicableAvailableCount: 0,
eligible: false,
reason: 'no applicable credits',
eligible: true,
selectedCreditId: 'credit-3',
selectedCreditExpiresAt: '2026-08-03T00:00:00.000Z',
},
],
},
Expand All @@ -292,7 +293,8 @@ describe('command dialogs', () => {
label: 'Fallback A',
chatgptAccountId: 'chatgpt/fallback a',
usedPercent: 100,
applicableAvailableCount: 2,
availableCount: 2,
applicableAvailableCount: 0,
eligible: true,
selectedCreditExpiresAt: '2026-08-01T00:00:00.000Z',
resetTime: '2026-07-18T00:00:00.000Z',
Expand Down Expand Up @@ -547,8 +549,8 @@ describe('command dialogs', () => {
expect(
options.find((option) => option.value === 'account:no-credits'),
).toMatchObject({
title: 'No credits — no applicable credits',
description: '100% · 0/4',
title: 'No credits — eligible',
description: '100% · 0/4 · exp 2026-08-03',
})
})

Expand Down Expand Up @@ -576,9 +578,7 @@ describe('command dialogs', () => {
).not.toBe(true)
expect(
options.find((option) => option.value === 'account:no-credits'),
).toMatchObject({
title: expect.stringContaining('no applicable credits'),
})
).toMatchObject({ title: expect.stringContaining('eligible') })
expect(
options.find((option) => option.value === 'account:no-credits')?.disabled,
).not.toBe(true)
Expand Down
61 changes: 56 additions & 5 deletions packages/opencode/src/tests/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function resetCreditResponse(
accountId: string,
fixture: ResetWireFixture,
): Response {
const count = fixture.applicableCount[accountId] ?? 0
const count = fixture.availableCount[accountId] ?? 0
const credits = Array.from({ length: count }, (_, index) => ({
id: `credit-${accountId}-${index + 1}`,
status: 'available',
Expand Down Expand Up @@ -2362,12 +2362,12 @@ describe('commands', () => {
})
expect(rows.find((row) => row.accountKey === 'no-credits')).toMatchObject(
{
eligible: false,
reason: 'no applicable credits',
reason: undefined,
eligible: true,
},
)
expect(payload.text).toContain('not exhausted')
expect(payload.text).toContain('no applicable credits')
expect(payload.text).toContain('eligible')
})

test('exhausted main preview is eligible when usage reports three applicable credits', async () => {
Expand All @@ -2392,6 +2392,29 @@ describe('commands', () => {
)
})

test('exhausted preview remains eligible when the server reports no applicable credits', async () => {
await saveResetAccounts([])
const fixture = resetFixture({
usedPercent: { 'chatgpt-main': 100 },
applicableCount: { 'chatgpt-main': 0 },
availableCount: { 'chatgpt-main': 1 },
})
const { ctx } = await makeResetCommandHarness(configPath, now, fixture)

const payload = await buildDialogPayload('openai-reset', '', ctx)
const rows = payload.knobs.accounts as Array<Record<string, unknown>>

expect(rows).toContainEqual(
expect.objectContaining({
accountKey: 'main',
availableCount: 1,
applicableAvailableCount: 0,
eligible: true,
selectedCreditId: 'credit-chatgpt-main-1',
}),
)
})

test('account preview keeps per-account failures visible and requires a stable identity for action', async () => {
await saveResetAccounts([
makeAccount('broken', {
Expand Down Expand Up @@ -2462,13 +2485,41 @@ describe('commands', () => {
})
expect(payload.text).toContain('Encoded fallback')
expect(payload.text).toContain('100%')
expect(payload.text).toContain('Spend 1 of 2')
expect(payload.text).toContain('Spend 1 of 4')
expect(payload.text).toContain('2026-08-01T00:00:00.000Z')
expect(payload.text).toContain('2026-07-18T00:00:00.000Z')
expect(JSON.stringify(payload.knobs)).not.toContain('fallback/a b-token')
expect(JSON.stringify(payload.knobs)).not.toContain('access')
})

test('confirmation discloses when the server excludes an otherwise available credit', async () => {
await saveResetAccounts([])
const fixture = resetFixture({
usedPercent: { 'chatgpt-main': 100 },
applicableCount: { 'chatgpt-main': 0 },
availableCount: { 'chatgpt-main': 1 },
})
const { ctx } = await makeResetCommandHarness(configPath, now, fixture)

const disclosed = await buildDialogPayload(
'openai-reset',
'select main',
ctx,
)
const disclosure =
'The server does not currently count this credit as applicable; redemption may return a no-op, and a no-op does not spend the credit.'
expect(disclosed.text).toContain(disclosure)
expect(disclosed.text).toContain('Spend 1 of 1')

fixture.applicableCount['chatgpt-main'] = 1
const undisclosed = await buildDialogPayload(
'openai-reset',
'select main',
ctx,
)
expect(undisclosed.text).not.toContain(disclosure)
})

test('select returns an informational result instead of confirmation for an ineligible account', async () => {
await saveResetAccounts()
const fixture = resetFixture({
Expand Down
21 changes: 6 additions & 15 deletions packages/opencode/src/tests/reset-credits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,20 +448,20 @@ describe('reset redemption precondition', () => {

it('accepts an exhausted live window with an applicable credit', () => {
expect(
evaluateResetPrecondition({ primary: quotaWindow(100) }, false, 1, now),
evaluateResetPrecondition({ primary: quotaWindow(100) }, false, now),
).toEqual({ ok: true })
})

it('refuses healthy quota', () => {
expect(
evaluateResetPrecondition({ primary: quotaWindow(20) }, false, 1, now),
evaluateResetPrecondition({ primary: quotaWindow(20) }, false, now),
).toEqual({ ok: false, reason: 'not exhausted' })
})

it('refuses exhausted quota without applicable credits', () => {
it('accepts exhausted quota when the server reports no applicable credits', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The renamed test 'accepts exhausted quota when the server reports no applicable credits' is now identical to the test above it ('accepts an exhausted live window with an applicable credit'): both call evaluateResetPrecondition({ primary: quotaWindow(100) }, false, now) and expect { ok: true }. Because applicableAvailableCount was removed from the function signature, this test no longer tests anything about applicable credits and only duplicates the prior case. Rename it to reflect what it actually asserts (e.g. 'accepts exhausted quota regardless of applicable credits') or drop it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/reset-credits.test.ts, line 461:

<comment>The renamed test 'accepts exhausted quota when the server reports no applicable credits' is now identical to the test above it ('accepts an exhausted live window with an applicable credit'): both call evaluateResetPrecondition({ primary: quotaWindow(100) }, false, now) and expect { ok: true }. Because applicableAvailableCount was removed from the function signature, this test no longer tests anything about applicable credits and only duplicates the prior case. Rename it to reflect what it actually asserts (e.g. 'accepts exhausted quota regardless of applicable credits') or drop it.</comment>

<file context>
@@ -448,20 +448,20 @@ describe('reset redemption precondition', () => {
   })
 
-  it('refuses exhausted quota without applicable credits', () => {
+  it('accepts exhausted quota when the server reports no applicable credits', () => {
     expect(
-      evaluateResetPrecondition({ primary: quotaWindow(100) }, false, 0, now),
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, it was a duplicate after the parameter went away. Dropped in 225c437; the property is pinned at the commands level by exhausted preview remains eligible when the server reports no applicable credits, which is the test that goes red if the refusal comes back.

expect(
evaluateResetPrecondition({ primary: quotaWindow(100) }, false, 0, now),
).toEqual({ ok: false, reason: 'no applicable credits' })
evaluateResetPrecondition({ primary: quotaWindow(100) }, false, now),
).toEqual({ ok: true })
})

it('treats a 100%-used expired window as stale rather than exhausted', () => {
Expand All @@ -471,7 +471,6 @@ describe('reset redemption precondition', () => {
primary: quotaWindow(100, '2026-07-17T11:59:59.999Z'),
},
false,
1,
now,
),
).toEqual({ ok: false, reason: 'not exhausted' })
Expand All @@ -485,7 +484,6 @@ describe('reset redemption precondition', () => {
secondary: quotaWindow(100),
},
false,
1,
now,
),
).toEqual({ ok: true })
Expand All @@ -499,7 +497,6 @@ describe('reset redemption precondition', () => {
secondary: quotaWindow(100, '2026-07-17T11:59:59.999Z'),
},
false,
1,
now,
),
).toEqual({ ok: false, reason: 'not exhausted' })
Expand All @@ -510,26 +507,20 @@ describe('reset redemption precondition', () => {
evaluateResetPrecondition(
{ primary: quotaWindow(100, undefined) },
false,
1,
now,
),
).toEqual({ ok: true })
expect(
evaluateResetPrecondition(
{ primary: quotaWindow(100, 'not-a-date') },
false,
1,
now,
),
).toEqual({ ok: true })
})

it('lets a live rate-limit mark satisfy only exhaustion', () => {
expect(evaluateResetPrecondition({}, true, 1, now)).toEqual({ ok: true })
expect(evaluateResetPrecondition({}, true, 0, now)).toEqual({
ok: false,
reason: 'no applicable credits',
})
expect(evaluateResetPrecondition({}, true, now)).toEqual({ ok: true })
})
})

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/tui/command-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,12 @@ function openResetDialog(
const preview = state.knobs.preview as ResetPreviewKnob | undefined
const accountKey = preview?.accountKey
const chatgptAccountId = preview?.chatgptAccountId
const applicableCount = preview?.applicableAvailableCount ?? 0
const availableCount = preview?.availableCount ?? 0
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.replace(() => (
<DialogConfirm
title='Reset quota window'
message={`${state.text}\n\nThis SPENDS 1 of ${applicableCount} reset credits — irreversible.\n\nChoose Reset to continue or Cancel to return.\n\nEnter = Cancel (host default). Press Tab then Enter to Reset.`}
message={`${state.text}\n\nThis SPENDS 1 of ${availableCount} reset credits — irreversible.\n\nChoose Reset to continue or Cancel to return.\n\nEnter = Cancel (host default). Press Tab then Enter to Reset.`}
onConfirm={() => {
if (!accountKey || !chatgptAccountId) return
applyAndRender(
Expand Down