diff --git a/packages/opencode/src/commands.ts b/packages/opencode/src/commands.ts index d742aff..1c84140 100644 --- a/packages/opencode/src/commands.ts +++ b/packages/opencode/src/commands.ts @@ -1241,7 +1241,6 @@ async function buildResetPreviewRow( const precondition = evaluateResetPrecondition( quota, ctx.quotaManager.isRateLimited(accountKey), - applicableAvailableCount, ctx.now(), ) let reason: string | undefined @@ -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'}**`, '', ] + 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.', + ) + lines.push('') + } if (row.eligible && row.chatgptAccountId) { lines.push( `Confirm: \`/openai-reset confirm ${encodeURIComponent(row.accountKey)} ${encodeURIComponent(row.chatgptAccountId)}\``, @@ -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.', } diff --git a/packages/opencode/src/core/reset-credits.ts b/packages/opencode/src/core/reset-credits.ts index 5365e32..4de92dc 100644 --- a/packages/opencode/src/core/reset-credits.ts +++ b/packages/opencode/src/core/reset-credits.ts @@ -54,7 +54,7 @@ export type ResetPrecondition = | { ok: true } | { ok: false - reason: 'not exhausted' | 'no applicable credits' + reason: 'not exhausted' } export interface ResetStateDeps { @@ -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 { @@ -464,7 +463,6 @@ export function resetWindowIsExhausted( export function evaluateResetPrecondition( quota: OAuthQuotaSnapshot, hasActiveRateLimitMark: boolean, - applicableAvailableCount: number, now: number, ): ResetPrecondition { const exhausted = @@ -472,9 +470,6 @@ export function evaluateResetPrecondition( 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 } } @@ -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( diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 7892f8a..b2c756d 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -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', }, ], }, @@ -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', @@ -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', }) }) @@ -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) diff --git a/packages/opencode/src/tests/commands.test.ts b/packages/opencode/src/tests/commands.test.ts index f5630fe..a69089f 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -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', @@ -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 () => { @@ -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> + + 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', { @@ -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({ diff --git a/packages/opencode/src/tests/reset-credits.test.ts b/packages/opencode/src/tests/reset-credits.test.ts index 4674d58..180f5f2 100644 --- a/packages/opencode/src/tests/reset-credits.test.ts +++ b/packages/opencode/src/tests/reset-credits.test.ts @@ -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', () => { 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', () => { @@ -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' }) @@ -485,7 +484,6 @@ describe('reset redemption precondition', () => { secondary: quotaWindow(100), }, false, - 1, now, ), ).toEqual({ ok: true }) @@ -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' }) @@ -510,7 +507,6 @@ describe('reset redemption precondition', () => { evaluateResetPrecondition( { primary: quotaWindow(100, undefined) }, false, - 1, now, ), ).toEqual({ ok: true }) @@ -518,18 +514,13 @@ describe('reset redemption precondition', () => { 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 }) }) }) diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index 88d6a32..06b2276 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -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(() => ( { if (!accountKey || !chatgptAccountId) return applyAndRender(