From 743548661e0d7142a828008d3e784e90e2f1b18c Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:41:43 +0200 Subject: [PATCH 1/2] fix(reset): stop refusing eligible credits on wham applicable_available_count Remove applicable_available_count from the exhaustion precondition while retaining it for display and confirmation disclosure. Use available credit counts in the confirmation and update reset command, core, and dialog coverage for stale windows and server applicability disagreement. --- packages/opencode/src/commands.ts | 11 ++-- packages/opencode/src/core/reset-credits.ts | 15 +---- .../src/tests/command-dialogs.test.ts | 13 ++-- packages/opencode/src/tests/commands.test.ts | 61 +++++++++++++++++-- .../opencode/src/tests/reset-credits.test.ts | 21 ++----- 5 files changed, 77 insertions(+), 44 deletions(-) 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..37103b0 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', }, ], }, @@ -547,8 +548,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 +577,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 }) }) }) From cc694fb36c25d7b2ac3f3d16d3ff34a5263b465b Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:49:55 +0200 Subject: [PATCH 2/2] fix(reset): show the available credit count in the TUI confirmation The markdown confirmation already spends "1 of ${availableCount}"; the TUI DialogConfirm still interpolated applicableAvailableCount, so the newly eligible shape (available 1, applicable 0) rendered "Spend 1 of 1" and "SPENDS 1 of 0" in the same dialog. Both surfaces now read availableCount. The confirmation fixture carries that exact shape so the existing "SPENDS 1 of 2" assertion pins it. --- packages/opencode/src/tests/command-dialogs.test.ts | 3 ++- packages/opencode/src/tui/command-dialogs.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 37103b0..b2c756d 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -293,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', 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(