diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 63529ae..92287eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,8 +111,8 @@ **Commands (dialogs):** - Purpose: Per-slash-command payload builders producing `OpenDialogPayload` (text + knobs) and applying user selections to storage. Copies the command context copy per invocation to prevent concurrent sessions from crossing feedback. - Location: `packages/opencode/src/commands.ts` -- Contains: Command name constants (`OPENAI_*_COMMAND_NAME`), `MODAL_COMMANDS`, `CommandContext` DI shape, `buildDialogPayload`, `applyCommand`, `executeQuotaCommand`/`executeAccountCommand`/`executeRoutingCommand`/`executeKillswitchCommand`/`executeDumpCommand`/`executeLoggingCommand`/`executeCachekeepCommand`. -- Depends on: `core/accounts.ts`, `core/cachekeep.ts`, `core/oauth.ts`, `core/refresh-all-quota.ts`, `quota-manager.ts`, `rpc/protocol.ts`, `logger.ts`, `config.ts`. +- Contains: Command name constants (`OPENAI_*_COMMAND_NAME`), `MODAL_COMMANDS`, `CommandContext` DI shape, `buildDialogPayload`, `applyCommand`, `executeQuotaCommand`/`executeAccountCommand`/`executeRoutingCommand`/`executeKillswitchCommand`/`executeDumpCommand`/`executeLoggingCommand`/`executeCachekeepCommand`/`executeResetCommand`. +- Depends on: `core/accounts.ts`, `core/cachekeep.ts`, `core/oauth.ts`, `core/refresh-all-quota.ts`, `core/reset-credits.ts`, `quota-manager.ts`, `rpc/protocol.ts`, `logger.ts`, `config.ts`. - Used by: Plugin loader (`auth.loader`), RPC `apply` dispatch. **CLI (`openai-auth`):** @@ -164,6 +164,18 @@ 4. TUI's `tui.tsx` polls the loader's loopback RPC (`/rpc/pending-notifications`), receives the dialog, and renders it via `command-dialogs.tsx`. 5. User clicks Apply → TUI POSTs `/rpc/apply` → loader's `apply` calls `buildDialogPayload`, mutates storage via `mutateAccounts`, and returns updated knobs for the TUI to re-render. +**`/openai-reset` credit redemption:** + +1. The account list reuses each account's valid L1 access token to fetch `wham/usage` and reset-credit inventory in parallel, producing a per-account preview. Only exhausted accounts with an applicable, eligible credit and a stable ChatGPT account identity can continue. +2. Selecting an account opens an explicit L2 confirmation bound to its stable `chatgptAccountId`; the dialog states that one reset credit will be spent and that the action is irreversible. +3. Confirmation resolves the target again and rejects the redemption if its ChatGPT identity no longer matches the bound identity. +4. A new attempt re-fetches quota and credits and re-checks exhaustion and applicable-credit preconditions immediately before claiming a credit. Under the persisted-pair retry rule (3a), an explicit retry instead requires an active in-flight attempt and reuses its `creditId` and `redeemRequestId` pair. +5. `consumeResetCredit` sends the explicit credit ID and redemption UUID to the consume endpoint in a POST bounded by a 60-second timeout. The read-only credit-list GET is bounded by a 15-second timeout; an abort surfaces as an `http_error` list failure. +6. Terminal server outcomes (`reset`, `already_redeemed`, `nothing_to_reset`, `no_credit`) clear the matching in-flight pair and persist `lastOutcome`; only the credit-spending `reset` and `already_redeemed` outcomes start cooldown. HTTP and ambiguous outcomes preserve the pair so a retry can reuse the same identifiers; an expired unreconciled pair requires an explicit replay, while corrupt local state is recorded as locally ambiguous instead of issuing a consume request. +7. A successful or already-redeemed outcome runs the normal targeted quota refresh for the selected account, pushes the result through `QuotaManager`, refreshes the sidebar snapshot, and fetches the remaining applicable-credit count. + +Verification debt: server-side deduplication of a repeated `redeem_request_id` is assumed from Codex client source, not live-verified. Before trust expands beyond operator-confirmed interactive use, one safe check must re-POST a consumed UUID and `creditId` and verify an `already_redeemed` response with no second count decrement. + **Cache keep-warm (idle session):** 1. Every main-agent (and optionally subagent) request is captured by `buildKeepwarmCapture` from `sendWithAccessToken`. Outside of the configured clock window, capture is skipped. @@ -194,6 +206,11 @@ - Location: `packages/opencode/src/core/cachekeep.ts` - Pattern: Target map keyed by session id; interval timer; bounded (`maxTargets`, `maxBytes`) so a long-lived process cannot leak; model-aware TTL adjustment (30-min TTL for GPT-5.6 models) and gpt-5.6 subagent 2-warm limits. +**Reset credit redemption coordinator:** +- Purpose: Preview reset-credit eligibility and redeem exactly one explicit credit for an exhausted account after identity-bound confirmation. +- Location: `packages/opencode/src/core/reset-credits.ts`; command orchestration in `packages/opencode/src/commands.ts` `executeResetCommand`. +- Pattern: Persisted `(creditId, redeemRequestId)` claim before the consume POST; confirm-time identity and new-attempt precondition checks; terminal-only finalization with bounded, identifier-stable retry for ambiguous outcomes. + **`OpenAIWebSocketPool` / `createWebSocketFetch`:** - Purpose: Session-keyed WebSocket pool with continuation chaining (`previous_response_id`), per-account discriminator so a switch forces a fresh socket, and stream-failure retries. - Location: `packages/opencode/src/ws-pool.ts` @@ -262,4 +279,4 @@ **Versioning & build:** `packages/opencode/src/version.ts` exposes `PackageVersion` (currently `0.3.4`); the TUI plugin header reads `package.json` at runtime via `import.meta.url` so the version badge tracks the package version without baking it into the dist. Use `packages/opencode/scripts/build-tui.ts` during the build to precompile TUI Solid JSX source files into `packages/opencode/src/tui-compiled/` using the `@opentui/solid` compiler transform, binding Solid/OpenTUI imports to the host's virtual runtime registry (`opentui:runtime-module:`) so the TUI shares the host's single Solid/OpenTUI runtime. The release pipeline is tag-driven (`.github/workflows` + `scripts/release.sh`); see `README.md` for the exact command surface. -**Formatting/linting:** Biome 2.4.16 (single quotes, no semicolons, trailing commas, 2-space indent). Lefthook runs `biome check` on staged files. Tests run via `bun test src/tests`; typecheck via `tsc`. \ No newline at end of file +**Formatting/linting:** Biome 2.4.16 (single quotes, no semicolons, trailing commas, 2-space indent). Lefthook runs `biome check` on staged files. Tests run via `bun test src/tests`; typecheck via `tsc`. diff --git a/README.md b/README.md index a8ec47d..db78ca6 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Token values and authorization/cookie headers are redacted from the log. Convers ## Slash commands -All commands open an interactive control surface in the TUI (a selectable dialog), and also accept the explicit argument forms below. +All commands open an interactive control surface in the TUI (a selectable dialog). Commands with listed arguments also accept those explicit argument forms. | Command | Arguments | Purpose | | --- | --- | --- | @@ -120,6 +120,7 @@ All commands open an interactive control surface in the TUI (a selectable dialog | `/openai-routing` | `main-first` · `fallback-first` | Set account preference order. | | `/openai-killswitch` | `on` · `off` · `set :<5h>,<1w> ...` | Hard-block accounts below per-window quota thresholds. | | `/openai-cachekeep` | `on` · `off` · `subagents on` · `subagents off` | Idle prompt-cache keep-warm; optional subagent mode. | +| `/openai-reset` | Modal only | Spend one applicable reset credit for an exhausted account after explicit confirmation. | | `/openai-logging` | `` | Set log level (`error`/`warn`/`info`/`debug`/`trace`) live. | | `/openai-dump` | `on` · `off` | Toggle transport request dumps for cache debugging. | diff --git a/STRUCTURE.md b/STRUCTURE.md index c53c61a..571dfeb 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -137,6 +137,7 @@ - `packages/opencode/src/core/oauth.ts` — PKCE, OAuth flow, JWT parsing. - `packages/opencode/src/core/quota-manager.ts` — quota cache, backoff, and mid-stream rate limit marking. - `packages/opencode/src/core/cachekeep.ts` — prompt-cache warmer with model-aware TTL, clock window, and subagent warm caps. +- `packages/opencode/src/core/reset-credits.ts` — reset-credit listing, eligibility checks, persisted redemption claims, bounded consume requests, and terminal-outcome finalization. - `packages/opencode/src/prompt-context.ts` — assistant model/variant resolver for synthetic command replies. - `packages/opencode/src/core/provider.ts` — Codex injection seam (`codexRefreshFn`, `whamUsageFn`). - `packages/opencode/src/core/backoff.ts` — retry/backoff math. @@ -154,6 +155,7 @@ **Tests:** - `packages/opencode/src/tests/` — co-located bun tests (`*.test.ts`). +- `packages/opencode/src/tests/reset-credits.test.ts` — reset-credit listing and consumption, redemption preconditions, and atomic persisted redemption state. - `packages/opencode/bunfig.toml` — bun test config. - Run: `bun run test` (root) → `cd packages/opencode && bun run test`. @@ -171,7 +173,7 @@ Example: `packages/opencode/src/tests/accounts-store.test.ts` tests `packages/op **Types/classes:** PascalCase (`CodexAuthPlugin`, `FallbackAccountManager`, `QuotaManager`, `CacheKeepManager`, `OpenAIWebSocketPool`, `ResponseStreamError`). Example: `packages/opencode/src/core/cachekeep.ts` exports `CacheKeepManager`. -**Command name constants:** SCREAMING_SNAKE_CASE prefixed with `OPENAI_` (`OPENAI_QUOTA_COMMAND_NAME`, `OPENAI_ACCOUNT_COMMAND_NAME`, `OPENAI_ROUTING_COMMAND_NAME`, `OPENAI_KILLSWITCH_COMMAND_NAME`, `OPENAI_DUMP_COMMAND_NAME`, `OPENAI_LOGGING_COMMAND_NAME`, `OPENAI_CACHEKEEP_COMMAND_NAME`). +**Command name constants:** SCREAMING_SNAKE_CASE prefixed with `OPENAI_` (`OPENAI_QUOTA_COMMAND_NAME`, `OPENAI_ACCOUNT_COMMAND_NAME`, `OPENAI_ROUTING_COMMAND_NAME`, `OPENAI_KILLSWITCH_COMMAND_NAME`, `OPENAI_DUMP_COMMAND_NAME`, `OPENAI_LOGGING_COMMAND_NAME`, `OPENAI_CACHEKEEP_COMMAND_NAME`, `OPENAI_RESET_COMMAND_NAME`). Example: `packages/opencode/src/commands.ts`. **Environment variables:** SCREAMING_SNAKE_CASE with the `CORTEXKIT_OPENAI_AUTH_*` and `OPENCODE_OPENAI_AUTH_*` prefixes (negative-prefixed `CORTEXKIT_OPENAI_AUTH_NO_WEB_SEARCH` for the default-on cache fix). @@ -201,4 +203,4 @@ Example: `CORTEXKIT_OPENAI_AUTH_WEBSOCKETS`, `CORTEXKIT_OPENAI_AUTH_RAW_WS`, `OP **New package (sibling to `opencode` or `pi`):** create `packages//` with its own `package.json`, `src/`, `tsconfig.json`, `tsconfig.build.json`, and add it under `workspaces` in the root `package.json`. Mirror the existing `opencode` or `pi` layout — Bun workspaces, `bun run build`, `bun run typecheck`. -**New plugin command constant / TUI preferences key:** add to `packages/opencode/src/tui-preferences.ts` (`DEFAULT_PREFS` + a typed key under the `PLUGIN_KEY = 'openai-auth'` top-level key in `~/.config/opencode/tui-preferences.jsonc`); the schema-validated reader will accept the new key automatically because `resolveOpenaiAuthPrefs` per-key defaults. \ No newline at end of file +**New plugin command constant / TUI preferences key:** add to `packages/opencode/src/tui-preferences.ts` (`DEFAULT_PREFS` + a typed key under the `PLUGIN_KEY = 'openai-auth'` top-level key in `~/.config/opencode/tui-preferences.jsonc`); the schema-validated reader will accept the new key automatically because `resolveOpenaiAuthPrefs` per-key defaults. diff --git a/packages/opencode/README.md b/packages/opencode/README.md index cce4ce0..626f0c8 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -35,7 +35,7 @@ Restart OpenCode after changing plugin config, then authenticate: ## Commands -Each opens an interactive dialog in the TUI and also accepts explicit arguments: +Each opens an interactive dialog in the TUI. Commands with listed arguments also accept those explicit arguments: | Command | Arguments | Purpose | | --- | --- | --- | @@ -44,6 +44,7 @@ Each opens an interactive dialog in the TUI and also accepts explicit arguments: | `/openai-routing` | `main-first` · `fallback-first` | Routing mode: which account is tried first. | | `/openai-killswitch` | `on` · `off` · `set :<5h>,<1w> ...` | Hard-block accounts below quota thresholds. | | `/openai-cachekeep` | `on` · `off` · `subagents on` · `subagents off` | Idle prompt-cache keep-warm. | +| `/openai-reset` | Modal only | Spend one applicable reset credit for an exhausted account after explicit confirmation. | | `/openai-logging` | `` | Set log level live. | | `/openai-dump` | `on` · `off` | Toggle transport request dumps. | diff --git a/packages/opencode/scripts/build-tui.ts b/packages/opencode/scripts/build-tui.ts index e34b379..ac117ca 100644 --- a/packages/opencode/scripts/build-tui.ts +++ b/packages/opencode/scripts/build-tui.ts @@ -17,6 +17,7 @@ const shippedSourceFiles = [ 'rpc/rpc-dir.ts', 'rpc/port-file.ts', 'rpc/protocol.ts', + 'util/error.ts', 'util/open-url.ts', ] as const const runtimeSpecifiers = new Set([ diff --git a/packages/opencode/src/commands.ts b/packages/opencode/src/commands.ts index 426d1f6..e8cc051 100644 --- a/packages/opencode/src/commands.ts +++ b/packages/opencode/src/commands.ts @@ -2,6 +2,7 @@ import { getSettings } from './config' import { DEFAULT_KILLSWITCH_THRESHOLDS, type loadAccounts as defaultLoadAccounts, + isSafeResetAccountKey, type KillswitchConfig, mutateAccounts, type OAuthAccount, @@ -9,8 +10,19 @@ import { } from './core/accounts' import type { CacheKeepManager, CacheKeepWindow } from './core/cachekeep' import { beginAccountLogin, upsertAccount } from './core/oauth' +import { whamUsageFn } from './core/provider' import type { QuotaManager } from './core/quota-manager' import type { RefreshAllQuotaResult } from './core/refresh-all-quota' +import { + evaluateResetPrecondition, + listResetCredits, + ResetCreditError, + ResetRedemptionError, + type RunResetCreditResult, + resetWindowIsExhausted, + runResetCreditRedemption, + selectCreditToSpend, +} from './core/reset-credits' import { createLogger, setLogLevel } from './logger' import type { ApplyRequest, @@ -30,6 +42,7 @@ export const OPENAI_KILLSWITCH_COMMAND_NAME = 'openai-killswitch' export const OPENAI_DUMP_COMMAND_NAME = 'openai-dump' export const OPENAI_LOGGING_COMMAND_NAME = 'openai-logging' export const OPENAI_CACHEKEEP_COMMAND_NAME = 'openai-cachekeep' +export const OPENAI_RESET_COMMAND_NAME = 'openai-reset' export const MODAL_COMMANDS: CommandModalName[] = [ 'openai-quota', @@ -39,6 +52,7 @@ export const MODAL_COMMANDS: CommandModalName[] = [ 'openai-dump', 'openai-logging', 'openai-cachekeep', + 'openai-reset', ] // --------------------------------------------------------------------------- @@ -78,6 +92,20 @@ export interface CommandContext { setCacheKeepSubagents?: (enabled: boolean) => void /** Updates the live loader's clock-hour warm window. undefined = no window. */ setCacheKeepWindow?: (window: CacheKeepWindow | undefined) => void + resolveResetTarget?: (accountKey: string) => Promise + fetchImpl?: typeof fetch + now?: () => number + randomUUID?: () => string + refreshResetTargetQuota?: ( + accountKey: string, + ) => Promise +} + +export interface ResetTargetIdentity { + accountKey: string + label: string + accessToken: string + chatgptAccountId?: string } const log = createLogger('commands') @@ -916,6 +944,549 @@ async function executeCachekeepCommand( } } +type ResetPreviewRow = { + accountKey: string + label: string + chatgptAccountId?: string + usedPercent?: number + resetTime?: string + availableCount?: number + applicableAvailableCount?: number + eligible: boolean + reason?: string + selectedCreditId?: string + selectedCreditExpiresAt?: string +} + +type ResetCommandContext = CommandContext & + Required< + Pick< + CommandContext, + | 'resolveResetTarget' + | 'fetchImpl' + | 'now' + | 'randomUUID' + | 'refreshResetTargetQuota' + > + > + +function resetUsedPercent(snapshot: { + primary?: { usedPercent: number } + secondary?: { usedPercent: number } +}): number | undefined { + const values = [ + snapshot.primary?.usedPercent, + snapshot.secondary?.usedPercent, + ].filter((value): value is number => value !== undefined) + return values.length > 0 ? Math.max(...values) : undefined +} + +function resetWindowTime( + snapshot: { + primary?: { usedPercent: number; resetsAt?: string } + secondary?: { usedPercent: number; resetsAt?: string } + }, + now: number, +): string | undefined { + const windows = [snapshot.primary, snapshot.secondary].filter( + (window): window is { usedPercent: number; resetsAt?: string } => + window !== undefined, + ) + const liveExhausted = windows.filter((window) => + resetWindowIsExhausted(window, now), + ) + return (liveExhausted.length > 0 ? liveExhausted : windows).sort( + (left, right) => right.usedPercent - left.usedPercent, + )[0]?.resetsAt +} + +function resetSnapshotIsHealthy( + snapshot: + | { + primary?: { usedPercent: number; resetsAt?: string } + secondary?: { usedPercent: number; resetsAt?: string } + } + | undefined, + now: number, +): boolean { + if (!snapshot) return false + const windows = [snapshot.primary, snapshot.secondary].filter( + (window): window is { usedPercent: number; resetsAt?: string } => + window !== undefined, + ) + if (windows.length === 0) return false + return windows.every((window) => !resetWindowIsExhausted(window, now)) +} + +function decodeResetArg(value: string | undefined): string | undefined { + if (!value) return undefined + try { + return decodeURIComponent(value) + } catch { + return undefined + } +} + +async function buildResetPreviewRow( + accountKey: string, + ctx: ResetCommandContext, +): Promise { + try { + const target = await ctx.resolveResetTarget(accountKey) + const wireAccountId = + target.accountKey === 'main' ? undefined : target.chatgptAccountId + const [quota, credits] = await Promise.all([ + whamUsageFn({ + accessToken: target.accessToken, + fetchImpl: ctx.fetchImpl, + now: ctx.now, + accountId: target.chatgptAccountId, + }), + listResetCredits(ctx.fetchImpl, target.accessToken, wireAccountId), + ]) + const selectedCredit = selectCreditToSpend(credits.credits) + const availableCount = + credits.availableCount ?? quota.resetCreditsAvailable ?? 0 + const applicableAvailableCount = quota.resetCreditsApplicable ?? 0 + const precondition = evaluateResetPrecondition( + quota, + ctx.quotaManager.isRateLimited(accountKey), + applicableAvailableCount, + ctx.now(), + ) + let reason: string | undefined + if (!target.chatgptAccountId) { + reason = 'stable ChatGPT account identity unavailable' + } else if (!precondition.ok) { + reason = precondition.reason + } else if (!selectedCredit) { + reason = 'no eligible credit' + } + return { + accountKey: target.accountKey, + label: target.label, + chatgptAccountId: target.chatgptAccountId, + usedPercent: resetUsedPercent(quota), + resetTime: resetWindowTime(quota, ctx.now()), + availableCount, + applicableAvailableCount, + eligible: reason === undefined, + reason, + selectedCreditId: selectedCredit?.id, + selectedCreditExpiresAt: selectedCredit?.expiresAt, + } + } catch (error) { + log.warn('reset preview row failed', { + accountKey, + error: (error as Error)?.message ?? String(error), + }) + return { + accountKey, + label: accountKey === 'main' ? 'Main account' : accountKey, + eligible: false, + reason: (error as Error)?.message ?? String(error), + } + } +} + +function renderResetAccountList(rows: readonly ResetPreviewRow[]): string { + const lines = [ + '## Reset credits', + '', + 'Select an account to fetch a fresh confirmation preview:', + '', + ] + for (const row of rows) { + const usage = + row.usedPercent === undefined + ? 'quota unavailable' + : `${row.usedPercent}% used` + const credits = + row.availableCount === undefined + ? 'credits unavailable' + : `${row.applicableAvailableCount ?? 0}/${row.availableCount} applicable/available` + const status = row.eligible + ? `eligible · credit ${row.selectedCreditId} expires ${row.selectedCreditExpiresAt}` + : row.reason + lines.push( + `- **${row.label}** (\`${row.accountKey}\`) — ${usage}; ${credits}; ${status}`, + ) + } + lines.push('') + lines.push('Command: `/openai-reset select `') + return lines.join('\n') +} + +function renderResetConfirm(row: ResetPreviewRow): string { + const lines = [ + '## Confirm reset credit', + '', + `Account: **${row.label}** (\`${row.accountKey}\`)`, + `Current quota: **${row.usedPercent ?? 'unknown'}% used**`, + `Credit: **Spend 1 of ${row.applicableAvailableCount ?? 0}**`, + `Credit expires: **${row.selectedCreditExpiresAt ?? 'unavailable'}**`, + `Quota resets: **${row.resetTime ?? 'unavailable'}**`, + '', + ] + if (row.eligible && row.chatgptAccountId) { + lines.push( + `Confirm: \`/openai-reset confirm ${encodeURIComponent(row.accountKey)} ${encodeURIComponent(row.chatgptAccountId)}\``, + ) + } else { + lines.push(`Cannot reset: **${row.reason ?? 'not eligible'}**`) + } + return lines.join('\n') +} + +function resetResultPayload( + accountKey: string, + code: string, + text: string, + knobs: Record = {}, +): OpenDialogPayload { + return { + command: OPENAI_RESET_COMMAND_NAME, + text, + knobs: { stage: 'result', accountKey, code, ...knobs }, + } +} + +function resetErrorPayload( + accountKey: string, + error: unknown, + boundChatgptAccountId?: string, +): OpenDialogPayload { + if (error instanceof ResetRedemptionError) { + const messages: Record = { + identity_mismatch: + 'The account identity changed before redemption. Reopen the reset account list.', + invalid_account_key: + 'The selected account key is reserved. Reopen the reset account list.', + cooldown_active: + 'This account just reset — re-checking quota. Wait for the cooldown before another redemption.', + expired_unreconciled: + 'The previous attempt outcome is unknown — retry replays the same identifiers, or wait until quota reflects the earlier attempt.', + retry_without_inflight: + '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.', + } + return resetResultPayload( + accountKey, + error.kind, + `## Reset credit\n\n${messages[error.kind]}\n\nCode: \`${error.kind}\``, + { + cooldownUntil: error.cooldownUntil, + ...(error.kind === 'expired_unreconciled' + ? { + chatgptAccountId: boundChatgptAccountId, + retryGuidance: + 'Retry replays the same request and credit identifiers from the previous attempt.', + } + : {}), + }, + ) + } + const identityCode = (error as { code?: unknown })?.code + if ( + identityCode === 'unknown_account' || + identityCode === 'disabled_account' || + identityCode === 'non_oauth_account' || + identityCode === 'token_unavailable' + ) { + const messages = { + unknown_account: + 'Account unavailable: the selected account no longer exists. Reopen the reset account list.', + disabled_account: + 'Account unavailable: the selected account is disabled. Reopen the reset account list.', + non_oauth_account: + 'Account unavailable: the selected account is not authenticated with OAuth. Reopen the reset account list.', + token_unavailable: + 'Authentication problem: the selected account token is unavailable. Reauthenticate the account before retrying.', + } as const + return resetResultPayload( + accountKey, + identityCode, + `## Reset credit\n\n${messages[identityCode]}\n\nCode: \`${identityCode}\``, + ) + } + if (error instanceof ResetCreditError) { + return resetResultPayload( + accountKey, + error.kind, + `## Reset credit\n\nNo redemption was attempted: reset credit availability could not be loaded.\n\nCode: \`${error.kind}\``, + ) + } + log.warn('reset command failed before a known result', { + accountKey, + error: (error as Error)?.message ?? String(error), + }) + return resetResultPayload( + accountKey, + 'error', + '## Reset credit\n\nThe reset request failed before a known result: internal command failure — see plugin log.', + ) +} + +export async function renderResetCoordinatorResult( + result: RunResetCreditResult, + ctx: ResetCommandContext, + boundChatgptAccountId?: string, +): Promise { + const { accountKey } = result.target + const code = result.outcome.kind + if (result.finalizeStateWriteFailed) { + return resetResultPayload( + accountKey, + code, + `## Reset credit result\n\nAccount: **${result.target.label}** (\`${accountKey}\`)\n\nThe server outcome recorded as \`${code}\`, but the state write failed. A retry within five minutes reuses the same request and credit identifiers; this does not prove the server did nothing.`, + { + stateWriteFailed: true, + retryGuidance: result.retrySafety, + chatgptAccountId: boundChatgptAccountId, + }, + ) + } + if (code === 'reset' || code === 'already_redeemed') { + let refresh: RefreshAllQuotaResult = { + account: accountKey, + ok: false, + error: 'targeted quota refresh did not complete', + } + try { + refresh = await ctx.refreshResetTargetQuota(accountKey) + } catch (error) { + refresh.error = (error as Error)?.message ?? String(error) + } + const refreshFailed = !refresh.ok && refresh.error !== undefined + if (refreshFailed) { + log.warn('reset quota re-check failed', { + accountKey, + error: refresh.error, + }) + } + const entry = + accountKey === 'main' + ? ctx.quotaManager.getMain() + : ctx.quotaManager.getFallback(accountKey) + const verifiedFresh = + refresh.ok && resetSnapshotIsHealthy(entry?.quota, ctx.now()) + const remainingCredits = entry?.quota.resetCreditsApplicable + const verification = verifiedFresh + ? 'Post-verification: **window fresh**.' + : `Post-verification: **window not yet refreshed** (server code \`${code}\`).` + const refreshDiagnostic = refreshFailed + ? '\n\nquota re-check failed — see log.' + : '' + return resetResultPayload( + accountKey, + code, + `## Reset credit result\n\nAccount: **${result.target.label}** (\`${accountKey}\`)\n\nCode: \`${code}\`\n\n${verification}${refreshDiagnostic}${remainingCredits === undefined ? '' : `\n\nRemaining applicable credits: **${remainingCredits}**`}`, + { + verifiedFresh, + afterUsedPercent: resetUsedPercent(entry?.quota ?? {}), + remainingCredits, + }, + ) + } + if (code === 'ambiguous_local') { + return resetResultPayload( + accountKey, + code, + `## Reset credit result\n\nAccount: **${result.target.label}** (\`${accountKey}\`)\n\nCode: \`${code}\`\n\n${result.retrySafety}`, + { retryGuidance: result.retrySafety }, + ) + } + if (code === 'ambiguous' || code === 'http_error') { + const retryCommand = boundChatgptAccountId + ? `/openai-reset retry ${encodeURIComponent(accountKey)} ${encodeURIComponent(boundChatgptAccountId)}` + : '/openai-reset' + return resetResultPayload( + accountKey, + code, + `## Reset credit result\n\nAccount: **${result.target.label}** (\`${accountKey}\`)\n\nThe redemption outcome is unknown (\`${code}\`).\n\nRetry with \`${retryCommand}\`. A retry within five minutes reuses the same request and credit identifiers; this does not prove the server did nothing.`, + { + retryGuidance: result.retrySafety, + chatgptAccountId: boundChatgptAccountId, + }, + ) + } + const meanings: Record = { + nothing_to_reset: + 'The server found no exhausted quota window to reset. No reset was confirmed. A new attempt starts fresh and must pass the current preconditions.', + no_credit: + 'The server found no usable reset credit. No reset was confirmed. A new attempt starts fresh and must pass the current preconditions.', + } + return resetResultPayload( + accountKey, + code, + `## Reset credit result\n\nAccount: **${result.target.label}** (\`${accountKey}\`)\n\nCode: \`${code}\`\n\n${meanings[code] ?? 'The server returned a no-op result. No reset was confirmed.'}`, + ) +} + +async function executeResetCommand( + args: string, + ctx: CommandContext, +): Promise { + const missingDeps = [ + ctx.resolveResetTarget ? undefined : 'resolveResetTarget', + ctx.fetchImpl ? undefined : 'fetchImpl', + ctx.now ? undefined : 'now', + ctx.randomUUID ? undefined : 'randomUUID', + ctx.refreshResetTargetQuota ? undefined : 'refreshResetTargetQuota', + ].filter((name): name is string => name !== undefined) + if (missingDeps.length > 0) { + log.warn('reset command dependencies unwired', { missingDeps }) + return { + command: OPENAI_RESET_COMMAND_NAME, + text: '## Reset credit\n\nUnavailable: reset command runtime dependencies are not wired.', + knobs: {}, + } + } + const resetCtx = ctx as ResetCommandContext + + const tokens = args.trim().split(/\s+/).filter(Boolean) + const action = tokens[0] + if (!action || action === 'refresh') { + const storage = await ctx.loadAccounts(ctx.accountStoragePath) + const accountKeys = [ + 'main', + ...(storage?.accounts ?? []) + .filter( + (account) => account.enabled !== false && account.type === 'oauth', + ) + .map((account) => account.id), + ] + log.debug('reset accounts stage requested', { accountKeys }) + const accounts = await Promise.all( + accountKeys.map((accountKey) => + buildResetPreviewRow(accountKey, resetCtx), + ), + ) + log.debug('reset accounts stage built', { + rows: accounts.map((row) => ({ + accountKey: row.accountKey, + eligible: row.eligible, + reason: row.reason, + usedPercent: row.usedPercent, + availableCount: row.availableCount, + applicableAvailableCount: row.applicableAvailableCount, + })), + }) + return { + command: OPENAI_RESET_COMMAND_NAME, + text: renderResetAccountList(accounts), + knobs: { stage: 'accounts', accounts }, + } + } + + if (action === 'select') { + const accountKey = decodeResetArg(tokens[1]) + if ( + !accountKey || + !isSafeResetAccountKey(accountKey) || + tokens.length !== 2 + ) { + return resetResultPayload( + '', + 'invalid_command', + 'Usage: `/openai-reset select `', + ) + } + const preview = await buildResetPreviewRow(accountKey, resetCtx) + if (!preview.eligible) { + return resetResultPayload( + accountKey, + 'not_eligible', + renderResetConfirm(preview), + ) + } + return { + command: OPENAI_RESET_COMMAND_NAME, + text: renderResetConfirm(preview), + knobs: { stage: 'confirm', preview }, + } + } + + if (action === 'confirm' || action === 'retry') { + const accountKey = decodeResetArg(tokens[1]) + const expectedChatgptAccountId = decodeResetArg(tokens[2]) + if ( + !accountKey || + !isSafeResetAccountKey(accountKey) || + !expectedChatgptAccountId || + tokens.length !== 3 + ) { + return resetResultPayload( + accountKey ?? '', + 'invalid_command', + `Usage: \`/openai-reset ${action} \``, + ) + } + log.info('reset redemption decision', { accountKey, action }) + log.debug('reset redemption identity binding', { + accountKey, + expectedChatgptAccountId, + }) + try { + const result = await runResetCreditRedemption( + { + configPath: ctx.accountStoragePath, + mutateAccountsFn: mutateAccounts, + loadAccountsFn: ctx.loadAccounts, + now: resetCtx.now, + randomUUID: resetCtx.randomUUID, + fetchImpl: resetCtx.fetchImpl, + resolveTarget: resetCtx.resolveResetTarget, + fetchUsage: (target) => + whamUsageFn({ + accessToken: target.accessToken, + fetchImpl: resetCtx.fetchImpl, + now: resetCtx.now, + accountId: target.chatgptAccountId, + }), + hasActiveRateLimitMark: (key) => ctx.quotaManager.isRateLimited(key), + }, + { + accountKey, + expectedChatgptAccountId, + retry: action === 'retry', + }, + ) + log.info('reset redemption outcome', { + accountKey, + code: result.outcome.kind, + }) + return renderResetCoordinatorResult( + result, + resetCtx, + expectedChatgptAccountId, + ) + } catch (error) { + const payload = resetErrorPayload( + accountKey, + error, + expectedChatgptAccountId, + ) + log.info('reset redemption outcome', { + accountKey, + code: payload.knobs.code, + }) + return payload + } + } + + return resetResultPayload( + '', + 'invalid_command', + 'Usage: `/openai-reset` | `/openai-reset select ` | `/openai-reset confirm ` | `/openai-reset retry ` | `/openai-reset refresh`', + ) +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -940,6 +1511,8 @@ export async function buildDialogPayload( return executeLoggingCommand(args, ctx) case 'openai-cachekeep': return executeCachekeepCommand(args, ctx) + case 'openai-reset': + return executeResetCommand(args, ctx) default: throw new Error(`unhandled command: ${command}`) } diff --git a/packages/opencode/src/core/accounts.ts b/packages/opencode/src/core/accounts.ts index 35fa5d6..5015168 100644 --- a/packages/opencode/src/core/accounts.ts +++ b/packages/opencode/src/core/accounts.ts @@ -86,6 +86,7 @@ export interface OAuthQuotaSnapshot { primary?: AccountQuotaWindow secondary?: AccountQuotaWindow resetCreditsAvailable?: number + resetCreditsApplicable?: number } // --------------------------------------------------------------------------- @@ -173,6 +174,29 @@ export type KillswitchConfig = { accounts?: Record } +export interface ResetInFlight { + redeemRequestId: string + creditId: string + startedAt: number +} + +export interface ResetLastOutcome { + code: string + at: number + previousOutcome?: { + code: string + at: number + } +} + +export interface ResetAccountState { + inFlight?: ResetInFlight | Record + lastOutcome?: ResetLastOutcome + cooldownUntil?: number +} + +export type ResetStateByAccount = Record + export type AccountStorage = { version: 1 main?: { @@ -207,6 +231,7 @@ export type AccountStorage = { mainQuotaToken?: string mainLastQuotaApiError?: AccountOperationError } + reset?: ResetStateByAccount dump?: { enabled?: boolean } @@ -325,6 +350,16 @@ function isRecord(value: unknown): value is Record { return value != null && typeof value === 'object' && !Array.isArray(value) } +const UNSAFE_RESET_ACCOUNT_KEYS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]) + +export function isSafeResetAccountKey(accountKey: string): boolean { + return accountKey.length > 0 && !UNSAFE_RESET_ACCOUNT_KEYS.has(accountKey) +} + function isAccountRemovedDuringRefreshError( error: unknown, ): error is AccountRemovedDuringRefreshError { @@ -402,12 +437,14 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] { } } - const resetCreditsAvailable = - typeof value.resetCreditsAvailable === 'number' - ? value.resetCreditsAvailable - : Number.NaN - if (Number.isFinite(resetCreditsAvailable) && resetCreditsAvailable >= 0) { - quota.resetCreditsAvailable = resetCreditsAvailable + for (const key of [ + 'resetCreditsAvailable', + 'resetCreditsApplicable', + ] as const) { + const credits = typeof value[key] === 'number' ? value[key] : Number.NaN + if (Number.isFinite(credits) && credits >= 0) { + quota[key] = credits + } } return Object.keys(quota).length ? quota : undefined @@ -450,6 +487,54 @@ function normalizeAccount(value: unknown): FallbackAccount | null { } } +function normalizeResetState(value: unknown): ResetStateByAccount | undefined { + if (!isRecord(value)) return undefined + + const normalized = Object.create(null) as ResetStateByAccount + for (const [accountId, candidate] of Object.entries(value)) { + if (!isSafeResetAccountKey(accountId) || !isRecord(candidate)) continue + + const state: ResetAccountState = {} + if (isRecord(candidate.inFlight)) { + state.inFlight = { ...candidate.inFlight } + } + if ( + isRecord(candidate.lastOutcome) && + typeof candidate.lastOutcome.code === 'string' && + candidate.lastOutcome.code.length > 0 && + typeof candidate.lastOutcome.at === 'number' && + Number.isFinite(candidate.lastOutcome.at) + ) { + state.lastOutcome = { + code: candidate.lastOutcome.code, + at: candidate.lastOutcome.at, + ...(isRecord(candidate.lastOutcome.previousOutcome) && + typeof candidate.lastOutcome.previousOutcome.code === 'string' && + candidate.lastOutcome.previousOutcome.code.length > 0 && + typeof candidate.lastOutcome.previousOutcome.at === 'number' && + Number.isFinite(candidate.lastOutcome.previousOutcome.at) + ? { + previousOutcome: { + code: candidate.lastOutcome.previousOutcome.code, + at: candidate.lastOutcome.previousOutcome.at, + }, + } + : {}), + } + } + if ( + typeof candidate.cooldownUntil === 'number' && + Number.isFinite(candidate.cooldownUntil) + ) { + state.cooldownUntil = candidate.cooldownUntil + } + + if (Object.keys(state).length > 0) normalized[accountId] = state + } + + return Object.keys(normalized).length > 0 ? normalized : undefined +} + function normalizeStorage(value: unknown): AccountStorage | null { if (!isRecord(value) || !Array.isArray(value.accounts)) return null return { @@ -461,6 +546,7 @@ function normalizeStorage(value: unknown): AccountStorage | null { : undefined, refresh: isRecord(value.refresh) ? value.refresh : undefined, quota: isRecord(value.quota) ? value.quota : undefined, + reset: normalizeResetState(value.reset), dump: isRecord(value.dump) ? value.dump : undefined, costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined, killswitch: isRecord(value.killswitch) ? value.killswitch : undefined, @@ -755,6 +841,7 @@ function configFromStorage(storage: AccountStorage): Record { fallbackOn: storage.fallbackOn, refresh, quota, + reset: storage.reset, dump: storage.dump, costZeroing: storage.costZeroing, killswitch: storage.killswitch, diff --git a/packages/opencode/src/core/refresh-all-quota.ts b/packages/opencode/src/core/refresh-all-quota.ts index c483dd7..26c690a 100644 --- a/packages/opencode/src/core/refresh-all-quota.ts +++ b/packages/opencode/src/core/refresh-all-quota.ts @@ -58,6 +58,10 @@ export interface RefreshAllQuotaDeps { logger?: QuotaLogger } +export interface RefreshAllQuotaOptions { + accountKey?: string +} + export interface RefreshAllQuotaResult { account: string ok: boolean @@ -66,6 +70,7 @@ export interface RefreshAllQuotaResult { export async function refreshAllQuota( deps: RefreshAllQuotaDeps, + options: RefreshAllQuotaOptions = {}, ): Promise { const whamFn = deps.whamFn if (!whamFn) throw new Error('whamFn is required for refreshAllQuota') @@ -84,78 +89,96 @@ export async function refreshAllQuota( else logger.warn('quota refresh failed', payload) } - // --- MAIN --- - try { - let auth = await deps.getAuth() - if (auth.type === 'oauth') { - if (!auth.access || (auth.expires ?? 0) < deps.now()) { - const tokens = await deps.codexRefreshFn({ - refreshToken: auth.refresh ?? '', - fetchImpl: deps.fetchImpl, - now: deps.now, - }) - await deps.client.auth.set({ - path: { id: 'openai' }, - body: { - type: 'oauth', - access: tokens.access, - refresh: tokens.refresh, - expires: tokens.expires, - }, - }) - auth = { ...auth, access: tokens.access, expires: tokens.expires } - } + const storage = await deps.loadAccounts(deps.configPath) - if (auth.access) { - if (deps.respectBackoff && deps.quotaManager.isBackedOff()) { - recordOutcome({ account: 'main', ok: true }) - } else { - const snap = await whamFn({ - accessToken: auth.access, + if (!options.accountKey || options.accountKey === 'main') { + // --- MAIN --- + try { + let auth = await deps.getAuth() + if (auth.type === 'oauth') { + if (!auth.access || (auth.expires ?? 0) < deps.now()) { + const tokens = await deps.codexRefreshFn({ + refreshToken: auth.refresh ?? '', fetchImpl: deps.fetchImpl, now: deps.now, - accountId: deps.storageMainAccountId, - accountKey: 'main', }) - deps.quotaManager.setMain( - auth.access, - { - quota: snap, - refreshAfter: deps.now() + 5 * 60 * 1000, - checkedAt: deps.now(), + await deps.client.auth.set({ + path: { id: 'openai' }, + body: { + type: 'oauth', + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, }, - undefined, - true, - ) - recordOutcome({ account: 'main', ok: true }) + }) + auth = { ...auth, access: tokens.access, expires: tokens.expires } + } + + if (auth.access) { + if (deps.respectBackoff && deps.quotaManager.isBackedOff()) { + recordOutcome({ account: 'main', ok: true }) + } else { + const snap = await whamFn({ + accessToken: auth.access, + fetchImpl: deps.fetchImpl, + now: deps.now, + accountId: storage?.mainAccountId, + accountKey: 'main', + }) + deps.quotaManager.setMain( + auth.access, + { + quota: snap, + refreshAfter: deps.now() + 5 * 60 * 1000, + checkedAt: deps.now(), + }, + undefined, + true, + ) + recordOutcome({ account: 'main', ok: true }) + } + } else { + recordOutcome({ + account: 'main', + ok: false, + error: 'no access token', + }) } } else { recordOutcome({ account: 'main', ok: false, - error: 'no access token', + error: 'auth type is not oauth', }) } - } else { + } catch (e) { recordOutcome({ account: 'main', ok: false, - error: 'auth type is not oauth', + error: errorMessage(e), }) } - } catch (e) { - recordOutcome({ - account: 'main', - ok: false, - error: errorMessage(e), - }) } // --- FALLBACKS --- - const storage = await deps.loadAccounts(deps.configPath) if (storage) { for (const acct of storage.accounts) { - if (acct.enabled === false || !deps.isOAuthAccountFn(acct)) continue + if ( + options.accountKey && + (options.accountKey === 'main' || acct.id !== options.accountKey) + ) { + continue + } + if (acct.enabled === false || !deps.isOAuthAccountFn(acct)) { + if (options.accountKey) { + recordOutcome({ + account: acct.id, + ok: false, + error: 'account is not an enabled OAuth fallback', + }) + } + continue + } try { if ( @@ -213,6 +236,18 @@ export async function refreshAllQuota( } } + if ( + options.accountKey && + options.accountKey !== 'main' && + !results.some((result) => result.account === options.accountKey) + ) { + results.push({ + account: options.accountKey, + ok: false, + error: 'account not found', + }) + } + // Refresh sidebar after all fetches const freshStorage = await deps.loadAccounts(deps.configPath) await deps.writeSidebarState(deps.quotaManager, freshStorage) diff --git a/packages/opencode/src/core/reset-credits.ts b/packages/opencode/src/core/reset-credits.ts new file mode 100644 index 0000000..5365e32 --- /dev/null +++ b/packages/opencode/src/core/reset-credits.ts @@ -0,0 +1,807 @@ +import { + type AccountStorage, + isSafeResetAccountKey, + type loadAccounts, + type mutateAccounts, + type OAuthQuotaSnapshot, + type ResetAccountState, + type ResetInFlight, +} from './accounts.ts' + +const RESET_CREDITS_PATH = '/backend-api/wham/rate-limit-reset-credits' +const RESET_CREDITS_URL = `https://chatgpt.com${RESET_CREDITS_PATH}` +const CONSUME_RESET_CREDIT_PATH = `${RESET_CREDITS_PATH}/consume` +const CONSUME_RESET_CREDIT_URL = `https://chatgpt.com${CONSUME_RESET_CREDIT_PATH}` + +const RESET_IN_FLIGHT_TTL_MS = 5 * 60_000 +// This timeout must remain below the in-flight TTL so an uncertain request is +// persisted long enough for every immediate retry to reuse its identity. +const RESET_CONSUME_TIMEOUT_MS = 60_000 +// The list GET is a read-only lookup upstream of the mutating call, so it gets +// a tighter bound; a hung inventory fetch must not stall redemption indefinitely. +const RESET_LIST_TIMEOUT_MS = 15_000 +const RESET_COOLDOWN_MS = 60_000 + +export interface ResetCredit { + id: string + status: string + grantedAt?: string + expiresAt: string + resetType?: string + isSupportedByPlan: boolean +} + +export interface ResetCreditList { + credits: ResetCredit[] + availableCount?: number +} + +export type ResetConsumeKind = + | 'reset' + | 'already_redeemed' + | 'nothing_to_reset' + | 'no_credit' + | 'http_error' + | 'ambiguous' + +export interface ResetConsumeOutcome { + kind: ResetConsumeKind + raw: unknown + status?: number +} + +export type ResetPrecondition = + | { ok: true } + | { + ok: false + reason: 'not exhausted' | 'no applicable credits' + } + +export interface ResetStateDeps { + configPath: string + mutateAccountsFn: typeof mutateAccounts + loadAccountsFn: typeof loadAccounts + now: () => number + randomUUID: () => string +} + +export interface ResetResolvedTarget { + accountKey: string + label: string + accessToken: string + chatgptAccountId?: string +} + +export interface RunResetCreditDeps extends ResetStateDeps { + fetchImpl: typeof fetch + resolveTarget(accountKey: string): Promise + fetchUsage(target: ResetResolvedTarget): Promise + hasActiveRateLimitMark(accountKey: string): boolean +} + +export interface RunResetCreditInput { + accountKey: string + expectedChatgptAccountId: string + retry: boolean +} + +export type ResetRedemptionErrorKind = + | 'invalid_account_key' + | 'identity_mismatch' + | 'cooldown_active' + | 'expired_unreconciled' + | 'retry_without_inflight' + | 'not_exhausted' + | 'no_applicable_credits' + | 'no_eligible_credit' + +export class ResetRedemptionError extends Error { + readonly kind: ResetRedemptionErrorKind + readonly cooldownUntil?: number + + constructor( + kind: ResetRedemptionErrorKind, + message: string, + cooldownUntil?: number, + ) { + super(message) + this.name = 'ResetRedemptionError' + this.kind = kind + this.cooldownUntil = cooldownUntil + } +} + +export interface ResetLocalAmbiguousOutcome { + kind: 'ambiguous_local' + raw: { reason: 'corrupt_in_flight' } +} + +export type ResetRedemptionOutcome = + | ResetConsumeOutcome + | ResetLocalAmbiguousOutcome + +export type ResetSelectedCredit = ResetCredit | { id: string } + +export interface RunResetCreditResult { + target: ResetResolvedTarget + selectedCredit: ResetSelectedCredit | undefined + beforeState: ResetAccountState | undefined + outcome: ResetRedemptionOutcome + retrySafety: string + finalizeStateWriteFailed?: boolean +} + +interface ResetClaim { + inFlight: ResetInFlight + selectedCredit: ResetSelectedCredit +} + +type ResetStateDecision = + | { + kind: 'fresh' + beforeState: ResetAccountState | undefined + } + | { + kind: 'claim' + beforeState: ResetAccountState | undefined + claim: ResetClaim + } + | { + kind: 'expired_unreconciled' + beforeState: ResetAccountState | undefined + claim: ResetClaim + } + | { + kind: 'cooldown' + beforeState: ResetAccountState | undefined + cooldownUntil: number + } + | { + kind: 'corrupt' + beforeState: ResetAccountState | undefined + } + +export type ResetCreditErrorKind = 'http_error' | 'invalid_response' + +export class ResetCreditError extends Error { + readonly kind: ResetCreditErrorKind + readonly status?: number + + constructor(kind: ResetCreditErrorKind, message: string, status?: number) { + super(message) + this.name = 'ResetCreditError' + this.kind = kind + this.status = status + } +} + +function whamHeaders( + token: string, + targetPath: string, + accountId?: string, +): Record { + return { + authorization: `Bearer ${token}`, + ...(accountId ? { 'chatgpt-account-id': accountId } : {}), + 'oai-client-platform': 'web', + 'oai-client-version': '0', + 'x-openai-target-path': targetPath, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function cloneResetState( + state: ResetAccountState | undefined, +): ResetAccountState | undefined { + if (!state) return undefined + return { + ...(state.inFlight ? { inFlight: { ...state.inFlight } } : {}), + ...(state.lastOutcome ? { lastOutcome: { ...state.lastOutcome } } : {}), + ...(state.cooldownUntil !== undefined + ? { cooldownUntil: state.cooldownUntil } + : {}), + } +} + +function validInFlight(value: unknown): ResetInFlight | undefined { + if ( + !isRecord(value) || + typeof value.redeemRequestId !== 'string' || + value.redeemRequestId.length === 0 || + typeof value.creditId !== 'string' || + value.creditId.length === 0 || + typeof value.startedAt !== 'number' || + !Number.isFinite(value.startedAt) + ) { + return undefined + } + return { + redeemRequestId: value.redeemRequestId, + creditId: value.creditId, + startedAt: value.startedAt, + } +} + +function hasInFlightField(state: ResetAccountState | undefined): boolean { + return Boolean(state && Object.hasOwn(state, 'inFlight')) +} + +function isYoungerThanInFlightTtl(inFlight: ResetInFlight, now: number) { + return now - inFlight.startedAt < RESET_IN_FLIGHT_TTL_MS +} + +function resetStateForAccount( + current: AccountStorage, + accountKey: string, +): ResetAccountState | undefined { + if (!isSafeResetAccountKey(accountKey)) return undefined + return current.reset?.[accountKey] +} + +function resetStateMap(current: AccountStorage) { + if (!current.reset) { + current.reset = Object.create(null) as NonNullable + } + return current.reset +} + +function resolveCorruptState( + current: AccountStorage, + accountKey: string, + now: number, +): void { + if (!isSafeResetAccountKey(accountKey)) return + const reset = resetStateMap(current) + const state = reset[accountKey] ?? {} + const previousOutcome = state.lastOutcome + delete state.inFlight + state.lastOutcome = { + code: 'ambiguous_local', + at: now, + ...(previousOutcome + ? { + previousOutcome: { + code: previousOutcome.code, + at: previousOutcome.at, + }, + } + : {}), + } + reset[accountKey] = state +} + +function inspectResetState( + state: ResetAccountState | undefined, + now: number, +): ResetStateDecision { + const beforeState = cloneResetState(state) + const inFlight = validInFlight(state?.inFlight) + if (hasInFlightField(state) && !inFlight) { + return { kind: 'corrupt', beforeState } + } + if (state?.cooldownUntil !== undefined && state.cooldownUntil > now) { + return { + kind: 'cooldown', + beforeState, + cooldownUntil: state.cooldownUntil, + } + } + if (inFlight && isYoungerThanInFlightTtl(inFlight, now)) { + return { + kind: 'claim', + beforeState, + claim: { + inFlight, + selectedCredit: { id: inFlight.creditId }, + }, + } + } + if (inFlight) { + return { + kind: 'expired_unreconciled', + beforeState, + claim: { + inFlight, + selectedCredit: { id: inFlight.creditId }, + }, + } + } + return { kind: 'fresh', beforeState } +} + +async function inspectResetAttempt( + deps: ResetStateDeps, + accountKey: string, +): Promise { + if (!isSafeResetAccountKey(accountKey)) { + return { kind: 'fresh', beforeState: undefined } + } + const current = await deps.loadAccountsFn(deps.configPath) + return inspectResetState( + current ? resetStateForAccount(current, accountKey) : undefined, + deps.now(), + ) +} + +async function resolveCorruptResetAttempt( + deps: ResetStateDeps, + accountKey: string, +): Promise { + let decision: ResetStateDecision | undefined + await deps.mutateAccountsFn((current) => { + const now = deps.now() + const state = resetStateForAccount(current, accountKey) + decision = inspectResetState(state, now) + if (decision.kind === 'corrupt') { + resolveCorruptState(current, accountKey, now) + } + return current + }, deps.configPath) + if (!decision) throw new Error('corrupt reset state mutation did not run') + return decision +} + +export async function claimResetAttempt( + deps: ResetStateDeps, + accountKey: string, + credits: readonly ResetCredit[], +): Promise { + if (!isSafeResetAccountKey(accountKey)) { + return { kind: 'fresh', beforeState: undefined } + } + let decision: ResetStateDecision | undefined + await deps.mutateAccountsFn((current) => { + const now = deps.now() + const state = resetStateForAccount(current, accountKey) + const beforeState = cloneResetState(state) + const inFlight = validInFlight(state?.inFlight) + if (hasInFlightField(state) && !inFlight) { + resolveCorruptState(current, accountKey, now) + decision = { kind: 'corrupt', beforeState } + return current + } + if (state?.cooldownUntil !== undefined && state.cooldownUntil > now) { + decision = { + kind: 'cooldown', + beforeState, + cooldownUntil: state.cooldownUntil, + } + return current + } + if (inFlight && isYoungerThanInFlightTtl(inFlight, now)) { + decision = { + kind: 'claim', + beforeState, + claim: { + inFlight, + selectedCredit: { id: inFlight.creditId }, + }, + } + return current + } + if (inFlight) { + decision = { + kind: 'expired_unreconciled', + beforeState, + claim: { + inFlight, + selectedCredit: { id: inFlight.creditId }, + }, + } + return current + } + + const selectedCredit = selectCreditToSpend(credits) + if (!selectedCredit) { + decision = { kind: 'fresh', beforeState } + return current + } + const claimedInFlight = { + redeemRequestId: deps.randomUUID(), + creditId: selectedCredit.id, + startedAt: now, + } + const reset = resetStateMap(current) + reset[accountKey] = { + ...state, + inFlight: claimedInFlight, + } + decision = { + kind: 'claim', + beforeState, + claim: { inFlight: claimedInFlight, selectedCredit }, + } + return current + }, deps.configPath) + if (!decision) throw new Error('reset claim mutation did not run') + return decision +} + +export async function finalizeResetAttempt( + deps: ResetStateDeps, + accountKey: string, + completing: ResetInFlight, + outcome: ResetConsumeOutcome, +): Promise { + if (!isSafeResetAccountKey(accountKey)) return + if (!isTerminalConsumeKind(outcome.kind)) return + await deps.mutateAccountsFn((current) => { + const state = resetStateForAccount(current, accountKey) + const persisted = validInFlight(state?.inFlight) + if ( + !state || + !persisted || + persisted.redeemRequestId !== completing.redeemRequestId || + persisted.creditId !== completing.creditId + ) { + return current + } + const now = deps.now() + delete state.inFlight + state.lastOutcome = { code: outcome.kind, at: now } + if (outcome.kind === 'reset' || outcome.kind === 'already_redeemed') { + state.cooldownUntil = now + RESET_COOLDOWN_MS + } else { + delete state.cooldownUntil + } + return current + }, deps.configPath) +} + +export function resetWindowIsExhausted( + window: { usedPercent: number; resetsAt?: string } | undefined, + now: number, +): boolean { + if (!window || window.usedPercent < 100) return false + if (!window.resetsAt) return true + const resetsAt = Date.parse(window.resetsAt) + return !Number.isFinite(resetsAt) || resetsAt > now +} + +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 } +} + +function optionalString( + record: Record, + key: string, +): string | undefined | null { + const value = record[key] + if (value === undefined || value === null) return undefined + return typeof value === 'string' ? value : null +} + +function parseCredit(value: unknown): ResetCredit | undefined { + if (!isRecord(value)) return undefined + + const grantedAt = optionalString(value, 'granted_at') + const resetType = optionalString(value, 'reset_type') + if ( + typeof value.id !== 'string' || + typeof value.status !== 'string' || + typeof value.expires_at !== 'string' || + Number.isNaN(Date.parse(value.expires_at)) || + typeof value.is_supported_by_plan !== 'boolean' || + grantedAt === null || + resetType === null + ) { + return undefined + } + + return { + id: value.id, + status: value.status, + ...(grantedAt === undefined ? {} : { grantedAt }), + expiresAt: value.expires_at, + ...(resetType === undefined ? {} : { resetType }), + isSupportedByPlan: value.is_supported_by_plan, + } +} + +export async function listResetCredits( + fetchImpl: typeof fetch, + token: string, + accountId?: string, +): Promise { + let response: Response + try { + response = await fetchImpl(RESET_CREDITS_URL, { + method: 'GET', + headers: whamHeaders(token, RESET_CREDITS_PATH, accountId), + signal: AbortSignal.timeout(RESET_LIST_TIMEOUT_MS), + }) + } catch { + throw new ResetCreditError('http_error', 'reset credit list request failed') + } + + if (!response.ok) { + throw new ResetCreditError( + 'http_error', + `reset credit list request failed: ${response.status}`, + response.status, + ) + } + + let raw: unknown + try { + raw = await response.json() + } catch { + throw new ResetCreditError( + 'invalid_response', + 'reset credit list response was not valid JSON', + response.status, + ) + } + + if (!isRecord(raw)) { + throw new ResetCreditError( + 'invalid_response', + 'reset credit list response was not an object', + response.status, + ) + } + + const wireCredits = Array.isArray(raw.credits) ? raw.credits : [] + + return { + credits: wireCredits.flatMap((value) => { + const parsed = parseCredit(value) + return parsed === undefined ? [] : [parsed] + }), + availableCount: + typeof raw.available_count === 'number' && + Number.isFinite(raw.available_count) + ? raw.available_count + : undefined, + } +} + +export function selectCreditToSpend( + credits: readonly ResetCredit[], +): ResetCredit | undefined { + return [...credits] + .filter( + (credit) => + credit.status === 'available' && + credit.isSupportedByPlan && + credit.resetType === 'codex_rate_limits', + ) + .sort( + (left, right) => Date.parse(left.expiresAt) - Date.parse(right.expiresAt), + )[0] +} + +function isTerminalConsumeKind(value: unknown): value is ResetConsumeKind { + return ( + value === 'reset' || + value === 'already_redeemed' || + value === 'nothing_to_reset' || + value === 'no_credit' + ) +} + +export async function consumeResetCredit( + fetchImpl: typeof fetch, + token: string, + accountId: string | undefined, + creditId: string, + redeemRequestId: string, +): Promise { + let response: Response + try { + response = await fetchImpl(CONSUME_RESET_CREDIT_URL, { + method: 'POST', + headers: { + ...whamHeaders(token, CONSUME_RESET_CREDIT_PATH, accountId), + 'content-type': 'application/json', + }, + body: JSON.stringify({ + redeem_request_id: redeemRequestId, + credit_id: creditId, + }), + signal: AbortSignal.timeout(RESET_CONSUME_TIMEOUT_MS), + }) + } catch (error) { + return { kind: 'ambiguous', raw: error } + } + + let body: string + try { + body = await response.text() + } catch (error) { + return response.ok + ? { kind: 'ambiguous', raw: error } + : { kind: 'http_error', raw: error, status: response.status } + } + + let raw: unknown = body + let parsed = false + try { + raw = JSON.parse(body) + parsed = true + } catch {} + + if (!response.ok) { + return { kind: 'http_error', raw, status: response.status } + } + + if (!parsed || !isRecord(raw) || !isTerminalConsumeKind(raw.code)) { + return { kind: 'ambiguous', raw } + } + + return { kind: raw.code, raw } +} + +function throwForStateDecision(decision: ResetStateDecision): never { + if (decision.kind === 'cooldown') { + throw new ResetRedemptionError( + 'cooldown_active', + 'reset credit redemption is cooling down', + decision.cooldownUntil, + ) + } + throw new Error(`unexpected reset state decision: ${decision.kind}`) +} + +function throwExpiredUnreconciled(): never { + throw new ResetRedemptionError( + 'expired_unreconciled', + 'the previous reset attempt outcome is unknown', + ) +} + +function localAmbiguousResult( + target: ResetResolvedTarget, + beforeState: ResetAccountState | undefined, +): RunResetCreditResult { + return { + target, + selectedCredit: undefined, + beforeState, + outcome: { + kind: 'ambiguous_local', + raw: { reason: 'corrupt_in_flight' }, + }, + retrySafety: + 'No request was sent because the saved redemption identity was incomplete.', + } +} + +function retrySafetyFor(outcome: ResetConsumeOutcome): string { + if (outcome.kind === 'reset' || outcome.kind === 'already_redeemed') { + return 'The server returned a terminal result; cooldown blocks an immediate new redemption.' + } + if (outcome.kind === 'nothing_to_reset' || outcome.kind === 'no_credit') { + return 'The server returned a terminal no-op. No cooldown was set; a new attempt starts fresh and remains gated by fresh preconditions.' + } + return 'The outcome is uncertain. A retry within five minutes reuses the same request and credit identifiers; this does not prove the server did nothing.' +} + +export async function runResetCreditRedemption( + deps: RunResetCreditDeps, + input: RunResetCreditInput, +): Promise { + if (!isSafeResetAccountKey(input.accountKey)) { + throw new ResetRedemptionError( + 'invalid_account_key', + 'the reset account key is reserved', + ) + } + const target = await deps.resolveTarget(input.accountKey) + if (target.chatgptAccountId !== input.expectedChatgptAccountId) { + throw new ResetRedemptionError( + 'identity_mismatch', + 'the resolved ChatGPT account identity changed before redemption', + ) + } + const wireAccountId = + target.accountKey === 'main' ? undefined : target.chatgptAccountId + + let initial = await inspectResetAttempt(deps, input.accountKey) + if (initial.kind === 'corrupt') { + initial = await resolveCorruptResetAttempt(deps, input.accountKey) + if (initial.kind === 'corrupt') { + return localAmbiguousResult(target, initial.beforeState) + } + } + if (initial.kind === 'cooldown') throwForStateDecision(initial) + + let claim: ResetClaim + if (initial.kind === 'claim') { + claim = initial.claim + } else if (initial.kind === 'expired_unreconciled') { + if (!input.retry) throwExpiredUnreconciled() + claim = initial.claim + } else { + if (input.retry) { + throw new ResetRedemptionError( + 'retry_without_inflight', + 'there is no active reset credit redemption to retry', + ) + } + const [quota, credits] = await Promise.all([ + deps.fetchUsage(target), + listResetCredits(deps.fetchImpl, target.accessToken, wireAccountId), + ]) + 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, + ) + } + if (!selectCreditToSpend(credits.credits)) { + throw new ResetRedemptionError( + 'no_eligible_credit', + 'no eligible reset credit was returned', + ) + } + + const claimed = await claimResetAttempt( + deps, + input.accountKey, + credits.credits, + ) + if (claimed.kind === 'corrupt') { + return localAmbiguousResult(target, claimed.beforeState) + } + if (claimed.kind === 'cooldown') throwForStateDecision(claimed) + if (claimed.kind === 'expired_unreconciled') { + throwExpiredUnreconciled() + } + if (claimed.kind !== 'claim') { + throw new ResetRedemptionError( + 'no_eligible_credit', + 'no eligible reset credit was available while claiming', + ) + } + claim = claimed.claim + } + + const outcome = await consumeResetCredit( + deps.fetchImpl, + target.accessToken, + wireAccountId, + claim.inFlight.creditId, + claim.inFlight.redeemRequestId, + ) + let finalizeStateWriteFailed = false + try { + await finalizeResetAttempt(deps, input.accountKey, claim.inFlight, outcome) + } catch { + finalizeStateWriteFailed = isTerminalConsumeKind(outcome.kind) + } + return { + target, + selectedCredit: claim.selectedCredit, + beforeState: initial.beforeState, + outcome, + retrySafety: finalizeStateWriteFailed + ? 'The server returned a terminal result, but the state write failed. A retry within five minutes reuses the same request and credit identifiers.' + : retrySafetyFor(outcome), + ...(finalizeStateWriteFailed ? { finalizeStateWriteFailed: true } : {}), + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index bf5e249..72ee7ae 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -19,7 +19,9 @@ import { OPENAI_KILLSWITCH_COMMAND_NAME, OPENAI_LOGGING_COMMAND_NAME, OPENAI_QUOTA_COMMAND_NAME, + OPENAI_RESET_COMMAND_NAME, OPENAI_ROUTING_COMMAND_NAME, + type ResetTargetIdentity, } from './commands' import { getConfigDir, getConfigPath, getSettings } from './config' import { @@ -169,6 +171,173 @@ export class AuthPersistError extends Error { } } +export type ResetTargetResolutionErrorKind = + | 'unknown_account' + | 'disabled_account' + | 'non_oauth_account' + | 'token_unavailable' + +export class ResetTargetResolutionError extends Error { + readonly code: ResetTargetResolutionErrorKind + + constructor(code: ResetTargetResolutionErrorKind, message: string) { + super(message) + this.name = 'ResetTargetResolutionError' + this.code = code + } +} + +interface ResetTargetResolverDeps { + getAuth: () => Promise<{ + type: string + access?: string + refresh?: string + expires?: number + }> + refreshMainWithLease: () => Promise<{ + access: string + refresh: string + expires: number + }> + refreshFallbackAccount: ( + account: OAuthAccount, + storage: AccountStorage, + ) => Promise + loadAccounts: typeof loadAccounts + accountStoragePath: string + now: () => number +} + +function resetTargetNeedsRefresh( + access: string | undefined, + expires: number | undefined, + storage: AccountStorage | null, + now: number, +) { + const refreshBeforeExpiryMs = + (storage?.refresh?.refreshBeforeExpiryMinutes ?? 240) * 60_000 + return !access || !expires || expires - now <= refreshBeforeExpiryMs +} + +export function createResetTargetResolver(deps: ResetTargetResolverDeps) { + return async (accountKey: string): Promise => { + if (accountKey === 'main') { + const storage = await deps.loadAccounts(deps.accountStoragePath) + let auth = await deps.getAuth() + if (auth.type !== 'oauth') { + throw new ResetTargetResolutionError( + 'non_oauth_account', + 'Main OpenAI account is not authenticated with OAuth.', + ) + } + if ( + resetTargetNeedsRefresh(auth.access, auth.expires, storage, deps.now()) + ) { + auth = { type: 'oauth', ...(await deps.refreshMainWithLease()) } + } + if (!auth.access) { + throw new ResetTargetResolutionError( + 'token_unavailable', + 'Main OpenAI account has no usable access token.', + ) + } + // The access token's claims reflect the account authenticated right + // now; the persisted mainAccountId lags a re-login until the next + // storage write. Prefer the live identity, falling back to storage for + // token shapes that carry no account claim. + const claims = parseJwtClaims(auth.access) + const liveAccountId = claims + ? extractAccountIdFromClaims(claims) + : undefined + const freshStorage = await deps.loadAccounts(deps.accountStoragePath) + return { + accountKey, + label: 'Main account', + accessToken: auth.access, + chatgptAccountId: liveAccountId ?? freshStorage?.mainAccountId, + } + } + + const storage = await deps.loadAccounts(deps.accountStoragePath) + const account = storage?.accounts.find( + (candidate) => candidate.id === accountKey, + ) + if (!storage || !account) { + throw new ResetTargetResolutionError( + 'unknown_account', + `Fallback account ${accountKey} was not found.`, + ) + } + if (account.enabled === false) { + throw new ResetTargetResolutionError( + 'disabled_account', + `Fallback account ${accountKey} is disabled.`, + ) + } + if (!isOAuthAccount(account)) { + throw new ResetTargetResolutionError( + 'non_oauth_account', + `Fallback account ${accountKey} is not an OAuth account.`, + ) + } + + let resolved = account + if ( + resetTargetNeedsRefresh( + resolved.access, + resolved.expires, + storage, + deps.now(), + ) + ) { + resolved = await deps.refreshFallbackAccount(resolved, storage) + } + if (!resolved.access) { + throw new ResetTargetResolutionError( + 'token_unavailable', + `Fallback account ${accountKey} has no usable access token.`, + ) + } + + const freshStorage = await deps.loadAccounts(deps.accountStoragePath) + const freshAccount = freshStorage?.accounts.find( + (candidate) => candidate.id === accountKey, + ) + if (!freshStorage || !freshAccount) { + throw new ResetTargetResolutionError( + 'unknown_account', + `Fallback account ${accountKey} was not found.`, + ) + } + if (freshAccount.enabled === false) { + throw new ResetTargetResolutionError( + 'disabled_account', + `Fallback account ${accountKey} is disabled.`, + ) + } + if (!isOAuthAccount(freshAccount)) { + throw new ResetTargetResolutionError( + 'non_oauth_account', + `Fallback account ${accountKey} is not an OAuth account.`, + ) + } + return { + accountKey, + label: resolved.label ?? accountKey, + accessToken: resolved.access, + chatgptAccountId: freshAccount.accountId, + } + } +} + +export function buildResetRedemptionDeps() { + return { + fetchImpl: fetch, + now: Date.now, + randomUUID: () => crypto.randomUUID(), + } +} + function isAuthPersistError(error: unknown): error is AuthPersistError { return error instanceof AuthPersistError } @@ -441,24 +610,26 @@ export function findCachekeepFallbackAccount( } // wham is the only source that reports reset-credit counts; header/WS pushes -// never carry the field. An incoming push that omits it inherits the last -// known count for the same account so the sidebar does not flicker it away -// on every per-turn header/WS update — but an explicit incoming value -// (including 0) always wins over a stale cached one. +// never carry the fields. An incoming push that omits them inherits the last +// known counts for the same account so the sidebar and the reset dialog do +// not lose them on every per-turn header/WS update — but an explicit incoming +// value (including 0) always wins over a stale cached one. export function mergePushedQuotaMetadata( incoming: OAuthQuotaSnapshot, previous: OAuthQuotaSnapshot | undefined, ): OAuthQuotaSnapshot { - if ( - incoming.resetCreditsAvailable !== undefined || - previous?.resetCreditsAvailable === undefined - ) { - return incoming - } - return { - ...incoming, - resetCreditsAvailable: previous.resetCreditsAvailable, + if (!previous) return incoming + const merged: OAuthQuotaSnapshot = { ...incoming } + for (const key of [ + 'resetCreditsAvailable', + 'resetCreditsApplicable', + ] as const) { + const carried = previous[key] + if (merged[key] === undefined && carried !== undefined) { + merged[key] = carried + } } + return merged } export function buildSidebarMachineState( @@ -1408,6 +1579,16 @@ export async function CodexAuthPlugin( quotaManager, loadAccounts, client: input.client as CommandContext['client'], + resolveResetTarget: createResetTargetResolver({ + getAuth, + refreshMainWithLease, + refreshFallbackAccount: (account, currentStorage) => + fallbackManager.refreshAccount(account, currentStorage), + loadAccounts, + accountStoragePath: getConfigPath(), + now: Date.now, + }), + ...buildResetRedemptionDeps(), cacheKeepManager, setCacheKeepEnabled: (enabled) => { cacheKeepEnabled = enabled @@ -1438,6 +1619,34 @@ export async function CodexAuthPlugin( isOAuthAccountFn: isOAuthAccount, whamFn: whamUsageFn, }), + refreshResetTargetQuota: async (accountKey) => { + const results = await refreshAllQuota( + { + getAuth, + codexRefreshFn, + fallbackManager, + quotaManager, + loadAccounts, + writeSidebarState: writeMachineSidebarState, + client: input.client as CommandContext['client'], + fetchImpl: fetch, + now: Date.now, + configPath: getConfigPath(), + storageMainAccountId: storage?.mainAccountId, + isOAuthAccountFn: isOAuthAccount, + whamFn: whamUsageFn, + respectBackoff: false, + }, + { accountKey }, + ) + return ( + results.find((result) => result.account === accountKey) ?? { + account: accountKey, + ok: false, + error: 'targeted quota refresh returned no result', + } + ) + }, } let rpcServer: RpcServerHandle | null = null @@ -2360,6 +2569,10 @@ export async function CodexAuthPlugin( description: 'Keep Codex prompt cache alive during idle by shadow-replaying the last request.', }, + [OPENAI_RESET_COMMAND_NAME]: { + template: OPENAI_RESET_COMMAND_NAME, + description: 'Spend one reset credit on an exhausted Codex account.', + }, } }, 'command.execute.before': async (input: { diff --git a/packages/opencode/src/quota-normalize.ts b/packages/opencode/src/quota-normalize.ts index 2411548..4cbc6a4 100644 --- a/packages/opencode/src/quota-normalize.ts +++ b/packages/opencode/src/quota-normalize.ts @@ -201,6 +201,7 @@ interface WhamUsageResponse { rate_limit: WhamRateLimits rate_limit_reset_credits?: { available_count?: number + applicable_available_count?: number } | null } @@ -249,5 +250,11 @@ export function normalizeWham(json: WhamUsageResponse): OAuthQuotaSnapshot { if (resetCreditsAvailable !== undefined) { snapshot.resetCreditsAvailable = resetCreditsAvailable } + const resetCreditsApplicable = nonNegativeFinite( + json.rate_limit_reset_credits?.applicable_available_count, + ) + if (resetCreditsApplicable !== undefined) { + snapshot.resetCreditsApplicable = resetCreditsApplicable + } return snapshot } diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index 9ae19df..4303d8c 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -6,6 +6,7 @@ export type CommandModalName = | 'openai-dump' | 'openai-logging' | 'openai-cachekeep' + | 'openai-reset' export interface OpenDialogPayload { command: CommandModalName diff --git a/packages/opencode/src/rpc/rpc-client.ts b/packages/opencode/src/rpc/rpc-client.ts index acef9ad..566697b 100644 --- a/packages/opencode/src/rpc/rpc-client.ts +++ b/packages/opencode/src/rpc/rpc-client.ts @@ -6,21 +6,24 @@ export interface RpcClient { lastReceivedId: number, sessionId?: string, ) => Promise - apply: (request: ApplyRequest) => Promise + apply: (request: ApplyRequest, timeoutMs?: number) => Promise } +export const DEFAULT_RPC_TIMEOUT_MS = 2_000 + async function call( dir: string, expectedPid: number | undefined, onSelected: ((entry: PortFileEntry | null) => void) | undefined, method: string, params: Record, + timeoutMs = DEFAULT_RPC_TIMEOUT_MS, ): Promise { const entry = await discoverPortFile(dir, expectedPid) onSelected?.(entry) if (!entry) return null const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 2_000) + const timer = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, { method: 'POST', @@ -62,7 +65,7 @@ export function createRpcClient( ) return out?.messages ?? [] }, - async apply(request) { + async apply(request, timeoutMs) { const out = await call( dir, expectedPid, @@ -71,6 +74,7 @@ export function createRpcClient( { ...request, }, + timeoutMs, ) return out ?? { text: 'apply failed', knobs: {} } }, diff --git a/packages/opencode/src/rpc/rpc-server.ts b/packages/opencode/src/rpc/rpc-server.ts index b546916..ee40603 100644 --- a/packages/opencode/src/rpc/rpc-server.ts +++ b/packages/opencode/src/rpc/rpc-server.ts @@ -55,7 +55,12 @@ export async function startRpcServer( options: RpcServerOptions, ): Promise { const token = randomBytes(32).toString('hex') - const timeoutMs = options.timeoutMs ?? 2000 + // Safety net, not a per-command SLA: it must outlast the slowest legitimate + // apply (a reset redemption is bounded by a 60s consume call), so the socket + // is never destroyed out from under a handler that is still working. Fast + // commands respond in milliseconds regardless; per-command deadlines belong + // to the client, which passes its own timeout. + const timeoutMs = options.timeoutMs ?? 90_000 const server = createServer((req, res) => { req.setTimeout(timeoutMs, () => { req.socket.destroy() diff --git a/packages/opencode/src/tests/accounts-store.test.ts b/packages/opencode/src/tests/accounts-store.test.ts index 9835cea..6398368 100644 --- a/packages/opencode/src/tests/accounts-store.test.ts +++ b/packages/opencode/src/tests/accounts-store.test.ts @@ -255,6 +255,7 @@ describe('accounts store', () => { windowMinutes: 10_080, }, resetCreditsAvailable: 4, + resetCreditsApplicable: 3, }, }) @@ -271,6 +272,7 @@ describe('accounts store', () => { const quota = (loaded?.accounts[0] as OAuthAccount | undefined)?.quota expect(quota?.primary?.windowMinutes).toBe(10_080) expect(quota?.resetCreditsAvailable).toBe(4) + expect(quota?.resetCreditsApplicable).toBe(3) }) it('loads an older quota snapshot without dynamic metadata', async () => { @@ -300,6 +302,185 @@ describe('accounts store', () => { expect(quota?.resetCreditsAvailable).toBeUndefined() }) + it('drops missing and malformed reset state while retaining storage version', async () => { + const { loadAccounts } = await import('../core/accounts.ts') + + for (const reset of [undefined, null, [], 'invalid']) { + const config: Record = { version: 1, accounts: [] } + if (reset !== undefined) config.reset = reset + writeFileSync(cfgPath, `${JSON.stringify(config)}\n`) + + const loaded = await loadAccounts(cfgPath) + expect(loaded?.version).toBe(1) + expect(loaded?.reset).toBeUndefined() + } + }) + + it('normalizes reset state per account without discarding valid siblings', async () => { + const { loadAccounts } = await import('../core/accounts.ts') + writeFileSync( + cfgPath, + `${JSON.stringify({ + version: 1, + accounts: [], + reset: { + main: { + inFlight: { + redeemRequestId: 'redeem-main', + creditId: 'credit-main', + startedAt: 100, + }, + lastOutcome: { code: 'completed', at: 200 }, + cooldownUntil: 300, + }, + 'fallback-a': { + inFlight: { redeemRequestId: 'partial', startedAt: 400 }, + lastOutcome: { code: 'failed', at: 500 }, + cooldownUntil: 'later', + }, + 'fallback-b': { + inFlight: { creditId: 'credit-b', startedAt: 600 }, + cooldownUntil: 700, + }, + 'fallback-c': { + inFlight: { + redeemRequestId: 'redeem-c', + creditId: 'credit-c', + startedAt: 'now', + }, + lastOutcome: { code: 'failed', at: 800 }, + }, + garbage: 'invalid', + empty: { + inFlight: { + redeemRequestId: '', + creditId: 'credit-empty', + startedAt: 600, + }, + lastOutcome: { code: '', at: 700 }, + cooldownUntil: Number.NaN, + }, + '': { + lastOutcome: { code: 'completed', at: 800 }, + }, + }, + })}\n`, + ) + + const loaded = await loadAccounts(cfgPath) + expect(loaded).toMatchObject({ + version: 1, + reset: { + main: { + inFlight: { + redeemRequestId: 'redeem-main', + creditId: 'credit-main', + startedAt: 100, + }, + lastOutcome: { code: 'completed', at: 200 }, + cooldownUntil: 300, + }, + 'fallback-a': { + inFlight: { redeemRequestId: 'partial', startedAt: 400 }, + lastOutcome: { code: 'failed', at: 500 }, + }, + 'fallback-b': { + inFlight: { creditId: 'credit-b', startedAt: 600 }, + cooldownUntil: 700, + }, + 'fallback-c': { + inFlight: { + redeemRequestId: 'redeem-c', + creditId: 'credit-c', + startedAt: 'now', + }, + lastOutcome: { code: 'failed', at: 800 }, + }, + empty: { + inFlight: { + redeemRequestId: '', + creditId: 'credit-empty', + startedAt: 600, + }, + }, + }, + }) + expect(Object.keys(loaded?.reset ?? {}).sort()).toEqual([ + 'empty', + 'fallback-a', + 'fallback-b', + 'fallback-c', + 'main', + ]) + }) + + it('drops prototype-sensitive reset account keys without polluting lookups', async () => { + const { loadAccounts } = await import('../core/accounts.ts') + writeFileSync( + cfgPath, + '{"version":1,"accounts":[],"reset":{"__proto__":{"cooldownUntil":999},"constructor":{"cooldownUntil":999},"prototype":{"cooldownUntil":999},"safe":{"cooldownUntil":123}}}\n', + ) + + const loaded = await loadAccounts(cfgPath) + + expect(loaded?.reset).toEqual({ safe: { cooldownUntil: 123 } }) + expect( + Object.getOwnPropertyDescriptor(loaded?.reset ?? {}, '__proto__'), + ).toBeUndefined() + expect(Object.getPrototypeOf(loaded?.reset ?? {})).toBeNull() + expect(loaded?.reset?.constructor).toBeUndefined() + expect(({} as Record).cooldownUntil).toBeUndefined() + }) + + it('mutateAccounts persists independent reset states and unknown config keys', async () => { + const { loadAccounts, mutateAccounts } = await import('../core/accounts.ts') + writeFileSync( + cfgPath, + `${JSON.stringify({ + version: 1, + accounts: [], + futureConfig: { retained: true }, + })}\n`, + ) + + await mutateAccounts((current) => { + current.reset = { + main: { + inFlight: { + redeemRequestId: 'redeem-main', + creditId: 'credit-main', + startedAt: 100, + }, + cooldownUntil: 200, + }, + 'fallback-a': { + lastOutcome: { code: 'completed', at: 300 }, + cooldownUntil: 400, + }, + } + return current + }, cfgPath) + + const loaded = await loadAccounts(cfgPath) + expect(loaded?.reset).toEqual({ + main: { + inFlight: { + redeemRequestId: 'redeem-main', + creditId: 'credit-main', + startedAt: 100, + }, + cooldownUntil: 200, + }, + 'fallback-a': { + lastOutcome: { code: 'completed', at: 300 }, + cooldownUntil: 400, + }, + }) + expect(JSON.parse(readFileSync(cfgPath, 'utf8')).futureConfig).toEqual({ + retained: true, + }) + }) + it('saveAccounts waits for the file lock and merges with the latest on-disk accounts', async () => { const { loadAccounts, saveAccounts } = await import('../core/accounts.ts') diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 514900f..b45aab2 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -1,5 +1,9 @@ import { describe, expect, mock, test } from 'bun:test' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import { flushForTest, setLogLevel } from '../logger.js' import type { OpenDialogPayload } from '../rpc/protocol.js' import { buildAccountDialogRows, @@ -86,6 +90,187 @@ describe('command dialogs', () => { } } + type ResetSelectOption = { + title: string + value: string + description?: string + disabled?: boolean + } + + type ResetSelectProps = { + title: string + options: ResetSelectOption[] + onSelect: (option: ResetSelectOption) => void + } + + type ResetConfirmProps = { + title: string + message: string + onConfirm: () => void + onCancel: () => void + } + + function makeResetDialogHarness() { + let capturedRenderer: (() => unknown) | null = null + let selectProps: ResetSelectProps | null = null + let confirmProps: ResetConfirmProps | null = null + const clearCount = { value: 0 } + const replaceCount = { value: 0 } + + const api = { + ui: { + dialog: { + setSize: () => {}, + replace: (fn: () => unknown) => { + capturedRenderer = fn + replaceCount.value += 1 + }, + clear: () => { + clearCount.value += 1 + }, + }, + toast: () => {}, + DialogSelect: ((props: ResetSelectProps) => { + selectProps = props + confirmProps = null + return null + }) as unknown as TuiPluginApi['ui']['DialogSelect'], + DialogConfirm: ((props: ResetConfirmProps) => { + confirmProps = props + selectProps = null + return null + }) as unknown as TuiPluginApi['ui']['DialogConfirm'], + }, + } as unknown as TuiPluginApi + + return { + api, + renderDialog: () => capturedRenderer?.(), + select: (value: string) => { + const option = selectProps?.options.find( + (candidate) => candidate.value === value, + ) + if (!option || option.disabled) return false + selectProps?.onSelect(option) + return true + }, + confirm: () => confirmProps?.onConfirm(), + cancel: () => confirmProps?.onCancel(), + getSelectProps: () => selectProps, + getConfirmProps: () => confirmProps, + renderedStrings: () => + JSON.stringify({ + select: selectProps + ? { + title: selectProps.title, + options: selectProps.options, + } + : null, + confirm: confirmProps + ? { title: confirmProps.title, message: confirmProps.message } + : null, + }), + clearCount, + replaceCount, + } + } + + function resetAccountsPayload(): OpenDialogPayload { + return { + command: 'openai-reset', + text: 'Reset account list', + knobs: { + stage: 'accounts', + accessToken: 'must-not-render-token', + redeemRequestId: 'must-not-render-uuid', + accounts: [ + { + accountKey: 'fallback/a b', + label: 'Fallback A', + chatgptAccountId: 'chatgpt/fallback a', + usedPercent: 100, + availableCount: 3, + applicableAvailableCount: 2, + eligible: true, + selectedCreditId: 'credit-1', + selectedCreditExpiresAt: '2026-08-01T00:00:00.000Z', + }, + { + accountKey: 'healthy', + label: 'Healthy', + chatgptAccountId: 'chatgpt-healthy', + usedPercent: 42, + availableCount: 2, + applicableAvailableCount: 2, + eligible: false, + reason: 'not exhausted', + }, + { + accountKey: 'no-credits', + label: 'No credits', + chatgptAccountId: 'chatgpt-no-credits', + usedPercent: 100, + availableCount: 4, + applicableAvailableCount: 0, + eligible: false, + reason: 'no applicable credits', + }, + ], + }, + } + } + + function resetConfirmPayload(): OpenDialogPayload { + return { + command: 'openai-reset', + text: [ + 'Account: Fallback A', + 'Current quota: 100% used', + 'Spend 1 of 2 reset credits', + 'Credit expires: 2026-08-01T00:00:00.000Z', + 'Quota resets: 2026-07-18T00:00:00.000Z', + ].join('\n'), + knobs: { + stage: 'confirm', + accessToken: 'must-not-render-token', + redeemRequestId: 'must-not-render-uuid', + preview: { + accountKey: 'fallback/a b', + label: 'Fallback A', + chatgptAccountId: 'chatgpt/fallback a', + usedPercent: 100, + applicableAvailableCount: 2, + eligible: true, + selectedCreditExpiresAt: '2026-08-01T00:00:00.000Z', + resetTime: '2026-07-18T00:00:00.000Z', + }, + }, + } + } + + function resetResultPayload( + code: + | 'reset' + | 'ambiguous' + | 'http_error' + | 'expired_unreconciled' = 'reset', + ): OpenDialogPayload { + return { + command: 'openai-reset', + text: `VERBATIM RESULT: ${code}`, + knobs: { + stage: 'result', + accountKey: 'fallback/a b', + code, + retryGuidance: + code === 'reset' ? undefined : 'same request and credit identifiers', + chatgptAccountId: code === 'reset' ? undefined : 'chatgpt/fallback a', + accessToken: 'must-not-render-token', + redeemRequestId: 'must-not-render-uuid', + }, + } + } + test('cachekeep dialog "clear_window" applies "window clear" (not the literal option value)', async () => { const { api, renderDialog, getOnSelect } = makeCachekeepDialogHarness() const apply = mock(async () => ({ text: 'window cleared', knobs: {} })) @@ -148,6 +333,401 @@ describe('command dialogs', () => { expect(replaceCount.value).toBeGreaterThan(replacesBeforeSelect) }) + test('reset dialog routes initial, select, confirm, retry, and refresh stages through fresh replacement payloads', async () => { + const harness = makeResetDialogHarness() + const apply = mock(async (_command: string, args: string) => { + if (args.startsWith('select ')) { + return { + text: resetConfirmPayload().text, + knobs: resetConfirmPayload().knobs, + } + } + if (args.startsWith('confirm ')) { + return { + text: resetResultPayload('ambiguous').text, + knobs: resetResultPayload('ambiguous').knobs, + } + } + return { + text: resetAccountsPayload().text, + knobs: resetAccountsPayload().knobs, + } + }) + + openCommandDialog(harness.api, resetAccountsPayload(), apply) + harness.renderDialog() + expect(apply).not.toHaveBeenCalled() + + expect(harness.select('account:fallback/a b')).toBe(true) + await Promise.resolve() + expect(apply).toHaveBeenLastCalledWith( + 'openai-reset', + `select ${encodeURIComponent('fallback/a b')}`, + ) + harness.renderDialog() + + harness.confirm() + await Promise.resolve() + expect(apply).toHaveBeenLastCalledWith( + 'openai-reset', + `confirm ${encodeURIComponent('fallback/a b')} ${encodeURIComponent('chatgpt/fallback a')}`, + ) + harness.renderDialog() + + expect(harness.select('retry')).toBe(true) + await Promise.resolve() + expect(apply).toHaveBeenLastCalledWith( + 'openai-reset', + `retry ${encodeURIComponent('fallback/a b')} ${encodeURIComponent('chatgpt/fallback a')}`, + ) + harness.renderDialog() + + expect(harness.select('refresh')).toBe(true) + await Promise.resolve() + expect(apply).toHaveBeenLastCalledWith('openai-reset', 'refresh') + expect(harness.clearCount.value).toBe(0) + expect(harness.replaceCount.value).toBeGreaterThanOrEqual(5) + }) + + test('reset dialog ignores an older apply completion after a newer apply renders', async () => { + const harness = makeResetDialogHarness() + let resolveFirst!: (value: { + text: string + knobs: Record + }) => void + let resolveSecond!: (value: { + text: string + knobs: Record + }) => void + const first = new Promise<{ text: string; knobs: Record }>( + (resolve) => { + resolveFirst = resolve + }, + ) + const second = new Promise<{ + text: string + knobs: Record + }>((resolve) => { + resolveSecond = resolve + }) + let calls = 0 + const apply = mock(async () => { + calls += 1 + return calls === 1 ? first : second + }) + openCommandDialog(harness.api, resetAccountsPayload(), apply) + harness.renderDialog() + + expect(harness.select('account:fallback/a b')).toBe(true) + expect(harness.select('account:fallback/a b')).toBe(true) + resolveSecond({ + text: 'SECOND RESULT', + knobs: { stage: 'result', code: 'reset' }, + }) + await Bun.sleep(0) + harness.renderDialog() + expect(harness.renderedStrings()).toContain('SECOND RESULT') + + resolveFirst({ + text: 'STALE FIRST RESULT', + knobs: { stage: 'result', code: 'reset' }, + }) + await Bun.sleep(0) + harness.renderDialog() + expect(harness.renderedStrings()).toContain('SECOND RESULT') + expect(harness.renderedStrings()).not.toContain('STALE FIRST RESULT') + }) + + test('reset dialog ignores an apply completion after close', async () => { + const harness = makeResetDialogHarness() + let resolveApply!: (value: { + text: string + knobs: Record + }) => void + const pending = new Promise<{ + text: string + knobs: Record + }>((resolve) => { + resolveApply = resolve + }) + const apply = mock(async () => pending) + openCommandDialog(harness.api, resetAccountsPayload(), apply) + harness.renderDialog() + + expect(harness.select('account:fallback/a b')).toBe(true) + expect(harness.select('close')).toBe(true) + const replacesAfterClose = harness.replaceCount.value + resolveApply({ + text: 'LATE RESULT', + knobs: { stage: 'result', code: 'reset' }, + }) + await Bun.sleep(0) + + expect(harness.clearCount.value).toBe(1) + expect(harness.replaceCount.value).toBe(replacesAfterClose) + }) + + test('reset account rows lead with the verdict and keep details in the description', () => { + const harness = makeResetDialogHarness() + const payload = resetAccountsPayload() + + openCommandDialog( + harness.api, + payload, + mock(async () => ({ text: '', knobs: {} })), + ) + harness.renderDialog() + + const options = harness.getSelectProps()?.options ?? [] + expect( + options.find((option) => option.value === 'account:fallback/a b'), + ).toMatchObject({ + title: 'Fallback A — eligible', + description: '100% · 2/3 · exp 2026-08-01', + }) + expect( + options.find((option) => option.value === 'account:healthy'), + ).toMatchObject({ + title: 'Healthy — not exhausted', + description: '42% · 2/2', + }) + expect( + options.find((option) => option.value === 'account:no-credits'), + ).toMatchObject({ + title: 'No credits — no applicable credits', + description: '100% · 0/4', + }) + }) + + test('reset account dialog keeps ineligible rows selectable and renders a non-confirm result', async () => { + const harness = makeResetDialogHarness() + const apply = mock(async () => ({ + text: 'Cannot reset: **not exhausted**', + knobs: { + stage: 'result', + accountKey: 'healthy', + code: 'not_eligible', + }, + })) + openCommandDialog(harness.api, resetAccountsPayload(), apply) + harness.renderDialog() + + const options = harness.getSelectProps()?.options ?? [] + expect( + options.find((option) => option.value === 'account:healthy'), + ).toMatchObject({ + title: expect.stringContaining('not exhausted'), + }) + expect( + options.find((option) => option.value === 'account:healthy')?.disabled, + ).not.toBe(true) + expect( + options.find((option) => option.value === 'account:no-credits'), + ).toMatchObject({ + title: expect.stringContaining('no applicable credits'), + }) + expect( + options.find((option) => option.value === 'account:no-credits')?.disabled, + ).not.toBe(true) + + expect(harness.select('account:healthy')).toBe(true) + await Promise.resolve() + expect(apply).toHaveBeenCalledWith('openai-reset', 'select healthy') + harness.renderDialog() + expect(harness.getConfirmProps()).toBeNull() + expect( + harness.getSelectProps()?.options.map((option) => option.value), + ).toEqual(['result', 'refresh', 'close']) + expect( + harness.getSelectProps()?.options.map((option) => option.value), + ).not.toContain('retry') + }) + + test('reset dialog renders a generic result when apply rejects', async () => { + const harness = makeResetDialogHarness() + const logDir = mkdtempSync(join(tmpdir(), 'oai-reset-dialog-reject-')) + const logFile = join(logDir, 'test.log') + const savedLogFile = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = logFile + setLogLevel('warn') + const apply = mock(async () => { + throw new Error('/private/plugin/path: secret internal failure') + }) + try { + openCommandDialog(harness.api, resetAccountsPayload(), apply) + harness.renderDialog() + + expect(harness.select('account:fallback/a b')).toBe(true) + await Promise.resolve() + await Promise.resolve() + harness.renderDialog() + await flushForTest() + + const rendered = harness.renderedStrings() + const logged = existsSync(logFile) ? readFileSync(logFile, 'utf8') : '' + expect(rendered).toContain('Command failed') + expect(rendered).toContain('plugin log has details') + expect(rendered).not.toContain('/private/plugin/path') + expect(logged).toContain('WARN [rpc-tui] reset dialog apply rejected') + expect(logged).toContain('/private/plugin/path: secret internal failure') + expect( + harness.getSelectProps()?.options.map((option) => option.value), + ).toContain('close') + expect(harness.clearCount.value).toBe(0) + } finally { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = savedLogFile + setLogLevel(undefined) + rmSync(logDir, { recursive: true, force: true }) + } + }) + + test('reset dialog renders an honest result when apply returns no stage', async () => { + const harness = makeResetDialogHarness() + const apply = mock(async () => ({ text: 'apply failed', knobs: {} })) + openCommandDialog(harness.api, resetAccountsPayload(), apply) + harness.renderDialog() + + expect(harness.select('account:fallback/a b')).toBe(true) + await Promise.resolve() + harness.renderDialog() + + expect(harness.getSelectProps()?.title).toBe('OpenAI reset result') + expect(harness.renderedStrings()).toContain( + 'The reset request is still completing or returned no result. Choose Refresh to check the current state — do NOT re-confirm.', + ) + }) + + test('reset account selection does not collide with dialog action names', async () => { + const harness = makeResetDialogHarness() + const payload = resetAccountsPayload() + const accounts = payload.knobs.accounts as Array> + const firstAccount = accounts[0] + if (!firstAccount) throw new Error('reset account fixture is empty') + firstAccount.accountKey = 'refresh' + const apply = mock(async () => ({ + text: resetConfirmPayload().text, + knobs: resetConfirmPayload().knobs, + })) + openCommandDialog(harness.api, payload, apply) + harness.renderDialog() + const accountOption = harness + .getSelectProps() + ?.options.find((option) => option.title.includes('Fallback A')) + + expect(accountOption).toBeDefined() + if (!accountOption) throw new Error('reset account option was not rendered') + expect(harness.select(accountOption.value)).toBe(true) + await Promise.resolve() + + expect(apply).toHaveBeenCalledWith('openai-reset', 'select refresh') + }) + + test('reset confirmation shows binding details and destructive copy with Reset and Cancel actions', async () => { + const harness = makeResetDialogHarness() + const apply = mock(async () => ({ + text: resetAccountsPayload().text, + knobs: resetAccountsPayload().knobs, + })) + openCommandDialog(harness.api, resetConfirmPayload(), apply) + harness.renderDialog() + + const confirm = harness.getConfirmProps() + expect(confirm?.title).toContain('Reset') + expect(confirm?.message).toContain('Fallback A') + expect(confirm?.message).toContain('100% used') + expect(confirm?.message).toContain('Spend 1 of 2 reset credits') + expect(confirm?.message).toContain('2026-08-01T00:00:00.000Z') + expect(confirm?.message).toContain('2026-07-18T00:00:00.000Z') + expect(confirm?.message).toContain('SPENDS 1 of 2 reset credits') + expect(confirm?.message).toContain('irreversible') + expect(confirm?.message).toContain('Reset') + expect(confirm?.message).toContain('Cancel') + expect(confirm?.message).toContain( + 'Enter = Cancel (host default). Press Tab then Enter to Reset.', + ) + + harness.cancel() + await Promise.resolve() + expect(apply).toHaveBeenLastCalledWith('openai-reset', 'refresh') + }) + + test('reset result renders payload text verbatim and exposes Retry only for bound retry-safe outcomes', async () => { + for (const code of [ + 'ambiguous', + 'http_error', + 'expired_unreconciled', + ] as const) { + const harness = makeResetDialogHarness() + const apply = mock(async () => ({ + text: resetAccountsPayload().text, + knobs: resetAccountsPayload().knobs, + })) + const payload = resetResultPayload(code) + openCommandDialog(harness.api, payload, apply) + harness.renderDialog() + + const options = harness.getSelectProps()?.options ?? [] + expect(options[0]).toMatchObject({ + title: payload.text, + }) + expect(options[0]?.disabled).not.toBe(true) + expect(options.map((option) => option.value)).toContain('retry') + expect(harness.select('retry')).toBe(true) + await Promise.resolve() + expect(apply).toHaveBeenLastCalledWith( + 'openai-reset', + `retry ${encodeURIComponent('fallback/a b')} ${encodeURIComponent('chatgpt/fallback a')}`, + ) + } + + const terminalHarness = makeResetDialogHarness() + openCommandDialog( + terminalHarness.api, + resetResultPayload('reset'), + mock(async () => ({ text: '', knobs: {} })), + ) + terminalHarness.renderDialog() + expect( + terminalHarness.getSelectProps()?.options.map((option) => option.value), + ).not.toContain('retry') + }) + + test('reset result text is visible but inert when selected', async () => { + const harness = makeResetDialogHarness() + const apply = mock(async () => ({ text: '', knobs: {} })) + const payload = resetResultPayload('reset') + openCommandDialog(harness.api, payload, apply) + harness.renderDialog() + + const resultOption = harness + .getSelectProps() + ?.options.find((option) => option.value === 'result') + expect(resultOption).toMatchObject({ title: payload.text }) + expect(resultOption?.disabled).not.toBe(true) + expect(harness.select('result')).toBe(true) + await Promise.resolve() + expect(apply).not.toHaveBeenCalled() + }) + + test('reset dialog never renders token or redemption UUID knob values', () => { + for (const payload of [ + resetAccountsPayload(), + resetConfirmPayload(), + resetResultPayload('ambiguous'), + ]) { + const harness = makeResetDialogHarness() + openCommandDialog( + harness.api, + payload, + mock(async () => ({ text: '', knobs: {} })), + ) + harness.renderDialog() + const rendered = harness.renderedStrings() + expect(rendered).not.toContain('must-not-render-token') + expect(rendered).not.toContain('must-not-render-uuid') + } + }) + test('account dialog marks the current session routing entry as active', () => { const rows = buildAccountDialogRows( { diff --git a/packages/opencode/src/tests/command-registration.test.ts b/packages/opencode/src/tests/command-registration.test.ts index 4f70ab4..9522210 100644 --- a/packages/opencode/src/tests/command-registration.test.ts +++ b/packages/opencode/src/tests/command-registration.test.ts @@ -68,6 +68,8 @@ describe('command-registration: config hook stays in sync with MODAL_COMMANDS', }) it('registered openai-* command keys exactly equal MODAL_COMMANDS (bidirectional)', async () => { + expect(MODAL_COMMANDS).toContain('openai-reset') + const hooks = await CodexAuthPlugin(createMockPluginInput(), { experimentalWebSockets: false, }) diff --git a/packages/opencode/src/tests/commands.test.ts b/packages/opencode/src/tests/commands.test.ts index 949767b..8bafc88 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CommandContext } from '../commands' // Static import for tests that don't need mocking. -import { buildDialogPayload } from '../commands' +import { buildDialogPayload, renderResetCoordinatorResult } from '../commands' // Snapshot the REAL oauth module exports at load time (before any mock.module // runs). bun's mock.module leaks process-wide and mock.restore() does NOT undo // it, so without restoring here the beginAccountLogin stub below would poison @@ -24,9 +24,20 @@ import * as oauthLiveNamespace from '../core/oauth' const oauthRealExports = { ...oauthLiveNamespace } -import type { AccountQuotaWindow, OAuthQuotaSnapshot } from '../core/accounts' -import { loadAccounts, type OAuthAccount, saveAccounts } from '../core/accounts' +import type { + AccountQuotaWindow, + AccountStorage, + OAuthQuotaSnapshot, +} from '../core/accounts' +import { + loadAccounts, + mutateAccounts, + type OAuthAccount, + saveAccounts, +} from '../core/accounts' import { QuotaManager } from '../core/quota-manager' +import { runResetCreditRedemption } from '../core/reset-credits' +import { buildResetRedemptionDeps, createResetTargetResolver } from '../index' import { createLogger, flushForTest, setLogLevel } from '../logger' import { resetNotificationsForTest } from '../rpc/notifications' import { FLOOR_AUTH_FILE, FLOOR_STATE_FILE } from './setup-env.ts' @@ -46,6 +57,15 @@ function makeAccount( } as OAuthAccount } +// Unsigned JWT carrying a single claim — parseJwtClaims only base64url-decodes +// the payload segment and never verifies the signature. +function jwtWithAccountId(accountId: string): string { + const payload = Buffer.from( + JSON.stringify({ chatgpt_account_id: accountId }), + ).toString('base64url') + return `test-header.${payload}.test-signature` +} + function makeClient(): CommandContext['client'] { return { auth: { @@ -54,6 +74,207 @@ function makeClient(): CommandContext['client'] { } as unknown as CommandContext['client'] } +function fetchStub( + implementation: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, +): typeof globalThis.fetch { + return Object.assign(implementation, { + preconnect: ( + ..._args: Parameters + ) => {}, + }) +} + +type ResetWireFixture = { + usedPercent: Record + applicableCount: Record + availableCount: Record + outcome: 'reset' | 'already_redeemed' | 'nothing_to_reset' | 'no_credit' + postStatus?: number + throwOnPost?: boolean + freshAfterPost?: boolean + applicableAfterPost?: number + calls: Array<{ method: string; accountId: string; url: string }> + targetRefreshes: string[] + sidebarRefreshes: number +} + +function resetFixture( + overrides: Partial = {}, +): ResetWireFixture { + return { + usedPercent: { + 'chatgpt-main': 100, + 'chatgpt-fallback-a': 100, + }, + applicableCount: { + 'chatgpt-main': 2, + 'chatgpt-fallback-a': 2, + }, + availableCount: { + 'chatgpt-main': 2, + 'chatgpt-fallback-a': 2, + }, + outcome: 'reset', + calls: [], + targetRefreshes: [], + sidebarRefreshes: 0, + ...overrides, + } +} + +function resetCreditResponse( + accountId: string, + fixture: ResetWireFixture, +): Response { + const count = fixture.applicableCount[accountId] ?? 0 + const credits = Array.from({ length: count }, (_, index) => ({ + id: `credit-${accountId}-${index + 1}`, + status: 'available', + expires_at: `2026-08-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`, + reset_type: 'codex_rate_limits', + is_supported_by_plan: true, + })) + return Response.json({ + credits, + available_count: fixture.availableCount[accountId] ?? count, + }) +} + +function makeResetWire(fixture: ResetWireFixture): typeof globalThis.fetch { + return fetchStub(async (input, init) => { + const url = input.toString() + const method = init?.method ?? 'GET' + const headers = new Headers(init?.headers) + const accountId = headers.get('chatgpt-account-id') || 'chatgpt-main' + fixture.calls.push({ method, accountId, url }) + if (method === 'POST') { + if (fixture.throwOnPost) throw new Error('connection lost') + if (fixture.postStatus) { + return Response.json( + { error: 'upstream failed' }, + { status: fixture.postStatus }, + ) + } + if (fixture.freshAfterPost) fixture.usedPercent[accountId] = 0 + if (fixture.applicableAfterPost !== undefined) { + fixture.applicableCount[accountId] = fixture.applicableAfterPost + fixture.availableCount[accountId] = fixture.applicableAfterPost + } + return Response.json({ code: fixture.outcome }) + } + if (url.endsWith('/wham/usage')) { + return Response.json({ + rate_limit: { + primary_window: { + used_percent: fixture.usedPercent[accountId] ?? 0, + limit_window_seconds: 18_000, + reset_at: '2026-07-18T00:00:00.000Z', + }, + }, + rate_limit_reset_credits: { + available_count: fixture.availableCount[accountId] ?? 0, + applicable_available_count: fixture.applicableCount[accountId] ?? 0, + }, + }) + } + return resetCreditResponse(accountId, fixture) + }) +} + +function resetQuotaSnapshot( + usedPercent: number, + availableCount: number, + applicableCount: number, +): OAuthQuotaSnapshot { + return { + primary: { + usedPercent, + remainingPercent: 100 - usedPercent, + checkedAt: Date.parse('2026-07-17T12:00:00.000Z'), + }, + resetCreditsAvailable: availableCount, + resetCreditsApplicable: applicableCount, + } +} + +async function makeResetCommandHarness( + configPath: string, + now: number, + fixture: ResetWireFixture, +) { + const quotaManager = new QuotaManager({ + storage: (await loadAccounts(configPath)) ?? { version: 1, accounts: [] }, + now: () => now, + }) + const resolveResetTarget = createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access: 'main-token', + refresh: 'main-refresh', + expires: now + 6 * 60 * 60_000, + }), + refreshMainWithLease: async () => ({ + access: 'refreshed-main-token', + refresh: 'main-refresh', + expires: now + 6 * 60 * 60_000, + }), + refreshFallbackAccount: async (account) => account, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + const ctx: CommandContext = { + accountStoragePath: configPath, + quotaManager, + loadAccounts, + client: makeClient(), + resolveResetTarget, + fetchImpl: makeResetWire(fixture), + now: () => now, + randomUUID: () => 'reset-request-id', + refreshResetTargetQuota: async (accountKey) => { + fixture.sidebarRefreshes += 1 + fixture.targetRefreshes.push(accountKey) + const storage = await loadAccounts(configPath) + const fallback = storage?.accounts.find( + (account) => account.id === accountKey && account.type === 'oauth', + ) as OAuthAccount | undefined + const accountId = + accountKey === 'main' ? 'chatgpt-main' : fallback?.accountId + const quota = resetQuotaSnapshot( + fixture.usedPercent[accountId ?? ''] ?? 0, + fixture.availableCount[accountId ?? ''] ?? 0, + fixture.applicableCount[accountId ?? ''] ?? 0, + ) + if (quota.primary) { + quota.primary = { + ...quota.primary, + resetsAt: '2026-07-18T00:00:00.000Z', + } + } + if (accountKey === 'main') { + quotaManager.setMain('main-token', { + quota, + checkedAt: now, + refreshAfter: now + 300_000, + }) + } else { + quotaManager.setFallback( + accountKey, + { quota, checkedAt: now, refreshAfter: now + 300_000 }, + fallback?.access, + ) + } + return { account: accountKey, ok: true } + }, + refreshSidebar: async () => {}, + } + return { ctx, quotaManager } +} + describe('commands', () => { let tmpDir: string let configPath: string @@ -916,6 +1137,1268 @@ describe('commands', () => { // No ⚠ lines (no refresh happened) expect(payload.text).not.toContain('⚠') }) + + test('reset coordinator uses the freshly resolved identity for main and the selected fallback only', async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + mainAccountId: 'chatgpt-main', + accounts: [ + makeAccount('fallback-a', { + access: 'fresh-fallback-a-token', + accountId: 'chatgpt-fallback-a', + expires: now + 5 * 60 * 60_000, + }), + makeAccount('fallback-b', { + access: 'fresh-fallback-b-token', + accountId: 'chatgpt-fallback-b', + expires: now + 5 * 60 * 60_000, + }), + ], + }, + configPath, + ) + + const refreshMainWithLease = mock(async () => ({ + access: 'unexpected-refreshed-main-token', + refresh: 'fresh-main-refresh', + expires: now + 6 * 60 * 60_000, + })) + const refreshFallbackAccount = mock( + async (account: OAuthAccount) => account, + ) + const resolveTarget = createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access: 'fresh-main-token', + refresh: 'fresh-main-refresh', + expires: now + 5 * 60 * 60_000, + }), + refreshMainWithLease, + refreshFallbackAccount, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + + const requests: Array<{ + method: string + headers: Record + }> = [] + const fetchImpl = fetchStub(async (_input, init) => { + const method = init?.method ?? 'GET' + requests.push({ + method, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + }) + if (method === 'POST') return Response.json({ code: 'reset' }) + return Response.json({ + credits: [ + { + id: 'credit-1', + status: 'available', + expires_at: '2026-08-01T00:00:00.000Z', + reset_type: 'codex_rate_limits', + is_supported_by_plan: true, + }, + ], + available_count: 1, + }) + }) + const deps = { + configPath, + mutateAccountsFn: mutateAccounts, + loadAccountsFn: loadAccounts, + now: () => now, + randomUUID: () => 'reset-request-id', + fetchImpl, + resolveTarget, + fetchUsage: async () => ({ + primary: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: now, + }, + resetCreditsApplicable: 1, + }), + hasActiveRateLimitMark: () => false, + } + + await expect( + runResetCreditRedemption(deps, { + accountKey: 'main', + expectedChatgptAccountId: 'stale-chatgpt-main', + retry: false, + }), + ).rejects.toMatchObject({ kind: 'identity_mismatch' }) + expect(requests).toHaveLength(0) + + await runResetCreditRedemption(deps, { + accountKey: 'main', + expectedChatgptAccountId: 'chatgpt-main', + retry: false, + }) + await runResetCreditRedemption(deps, { + accountKey: 'fallback-a', + expectedChatgptAccountId: 'chatgpt-fallback-a', + retry: false, + }) + + const consumeRequests = requests.filter( + (request) => request.method === 'POST', + ) + expect(consumeRequests).toHaveLength(2) + const [mainConsume, fallbackConsume] = consumeRequests + expect(mainConsume?.headers.authorization).toBe('Bearer fresh-main-token') + expect(mainConsume?.headers['chatgpt-account-id']).toBeUndefined() + expect(fallbackConsume?.headers.authorization).toBe( + 'Bearer fresh-fallback-a-token', + ) + expect(fallbackConsume?.headers['chatgpt-account-id']).toBe( + 'chatgpt-fallback-a', + ) + expect(Object.values(fallbackConsume?.headers ?? {})).not.toContain( + 'Bearer fresh-fallback-b-token', + ) + expect(Object.values(fallbackConsume?.headers ?? {})).not.toContain( + 'chatgpt-fallback-b', + ) + expect(refreshMainWithLease).toHaveBeenCalledTimes(0) + expect(refreshFallbackAccount).toHaveBeenCalledTimes(0) + }) + + test('production reset redemption dependencies generate a UUID', () => { + const deps = buildResetRedemptionDeps() + + expect(deps.randomUUID()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ) + }) + + test('opening reset modal reuses all tokens outside the refresh horizon', async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [ + makeAccount('fallback-a', { + expires: now + 5 * 60 * 60_000, + accountId: 'chatgpt-fallback-a', + }), + makeAccount('fallback-b', { + expires: now + 6 * 60 * 60_000, + accountId: 'chatgpt-fallback-b', + }), + ], + }, + configPath, + ) + const refreshMainWithLease = mock(async () => ({ + access: 'refreshed-main', + refresh: 'refresh-main', + expires: now + 6 * 60 * 60_000, + })) + const refreshFallbackAccount = mock( + async (account: OAuthAccount) => account, + ) + const resolveResetTarget = createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access: 'main-token', + refresh: 'main-refresh', + expires: now + 5 * 60 * 60_000, + }), + refreshMainWithLease, + refreshFallbackAccount, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + const ctx: CommandContext = { + accountStoragePath: configPath, + quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), + loadAccounts, + client: makeClient(), + resolveResetTarget, + fetchImpl: fetchStub(async () => Response.json({})), + now: () => now, + randomUUID: () => 'uuid', + refreshResetTargetQuota: async (accountKey) => ({ + account: accountKey, + ok: true, + }), + } + + const payload = await buildDialogPayload('openai-reset', '', ctx) + + expect(payload.command).toBe('openai-reset') + expect(payload.knobs.accounts).toHaveLength(3) + expect(refreshMainWithLease).toHaveBeenCalledTimes(0) + expect(refreshFallbackAccount).toHaveBeenCalledTimes(0) + }) + + test('reset command reports unavailable when runtime dependencies are not wired', async () => { + const ctx: CommandContext = { + accountStoragePath: configPath, + quotaManager: new QuotaManager({ storage: { version: 1, accounts: [] } }), + loadAccounts, + client: makeClient(), + } + + const payload = await buildDialogPayload('openai-reset', '', ctx) + + expect(payload.command).toBe('openai-reset') + expect(payload.text).toContain('Unavailable') + expect(payload.knobs).toEqual({}) + }) + + test('reset identity resolver returns tagged displayable target errors', async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + const resolver = () => + createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access: 'main-token', + refresh: 'main-refresh', + expires: now + 5 * 60 * 60_000, + }), + refreshMainWithLease: async () => ({ + access: 'main-token', + refresh: 'main-refresh', + expires: now + 5 * 60 * 60_000, + }), + refreshFallbackAccount: async (account) => account, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + }, + configPath, + ) + await expect(resolver()('missing')).rejects.toMatchObject({ + code: 'unknown_account', + message: expect.stringContaining('not found'), + }) + + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [makeAccount('disabled', { enabled: false })], + }, + configPath, + ) + await expect(resolver()('disabled')).rejects.toMatchObject({ + code: 'disabled_account', + message: expect.stringContaining('disabled'), + }) + + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [ + { + id: 'api-account', + type: 'api', + baseURL: 'https://api.openai.com/v1', + }, + ], + }, + configPath, + ) + await expect(resolver()('api-account')).rejects.toMatchObject({ + code: 'non_oauth_account', + message: expect.stringContaining('not an OAuth account'), + }) + + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [makeAccount('tokenless', { access: '', expires: 0 })], + }, + configPath, + ) + await expect(resolver()('tokenless')).rejects.toMatchObject({ + code: 'token_unavailable', + message: expect.stringContaining('no usable access token'), + }) + }) + + test('reset identity resolver derives the main account id from the live access token JWT', async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + mainAccountId: 'old-account', + accounts: [], + }, + configPath, + ) + const resolveTarget = createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access: jwtWithAccountId('new-account'), + refresh: 'main-refresh', + expires: now + 5 * 60 * 60_000, + }), + refreshMainWithLease: async () => ({ + access: 'unused-main-token', + refresh: 'unused-main-refresh', + expires: now + 6 * 60 * 60_000, + }), + refreshFallbackAccount: async (account) => account, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + + const target = await resolveTarget('main') + expect(target.chatgptAccountId).toBe('new-account') + }) + + test('reset identity resolver falls back to persisted main account id when the token carries no claims', async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + mainAccountId: 'old-account', + accounts: [], + }, + configPath, + ) + const resolveTarget = createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access: 'opaque-token-without-jwt-shape', + refresh: 'main-refresh', + expires: now + 5 * 60 * 60_000, + }), + refreshMainWithLease: async () => ({ + access: 'unused-main-token', + refresh: 'unused-main-refresh', + expires: now + 6 * 60 * 60_000, + }), + refreshFallbackAccount: async (account) => account, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + + const target = await resolveTarget('main') + expect(target.chatgptAccountId).toBe('old-account') + }) + + for (const mutation of ['removed', 'disabled'] as const) { + test(`reset identity resolver rejects a fallback ${mutation} before its fresh snapshot`, async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + const account = makeAccount('fallback-a', { + accountId: 'chatgpt-fallback-a', + expires: now + 60_000, + }) + const initial: AccountStorage = { version: 1, accounts: [account] } + const fresh: AccountStorage = { + version: 1, + accounts: + mutation === 'removed' ? [] : [{ ...account, enabled: false }], + } + let loadCount = 0 + const loadAccountsFn: typeof loadAccounts = async () => { + loadCount += 1 + return loadCount === 1 ? initial : fresh + } + const refreshFallbackAccount = mock(async (candidate: OAuthAccount) => ({ + ...candidate, + access: 'refreshed-fallback-token', + expires: now + 6 * 60 * 60_000, + })) + const resolveTarget = createResetTargetResolver({ + getAuth: async () => ({ type: 'oauth' }), + refreshMainWithLease: async () => ({ + access: 'unused-main-token', + refresh: 'unused-main-refresh', + expires: now + 6 * 60 * 60_000, + }), + refreshFallbackAccount, + loadAccounts: loadAccountsFn, + accountStoragePath: configPath, + now: () => now, + }) + + await expect(resolveTarget('fallback-a')).rejects.toMatchObject({ + code: mutation === 'removed' ? 'unknown_account' : 'disabled_account', + }) + expect(loadCount).toBe(2) + expect(refreshFallbackAccount).toHaveBeenCalledTimes(1) + }) + } + + for (const tokenCase of ['missing', 'expired', 'near-expiry'] as const) { + test(`reset identity resolver refreshes only its ${tokenCase} target exactly once`, async () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + const access = tokenCase === 'missing' ? '' : 'stale-token' + const expires = + tokenCase === 'expired' + ? now - 1 + : tokenCase === 'near-expiry' + ? now + 60_000 + : now + 5 * 60 * 60_000 + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [ + makeAccount('fallback-a', { + access, + expires, + accountId: 'chatgpt-fallback-a', + }), + ], + }, + configPath, + ) + const refreshMainWithLease = mock(async () => ({ + access: 'refreshed-main-token', + refresh: 'refreshed-main-refresh', + expires: now + 6 * 60 * 60_000, + })) + const refreshFallbackAccount = mock(async (account: OAuthAccount) => ({ + ...account, + access: 'refreshed-fallback-token', + expires: now + 6 * 60 * 60_000, + })) + const resolveTarget = createResetTargetResolver({ + getAuth: async () => ({ + type: 'oauth', + access, + refresh: 'main-refresh', + expires, + }), + refreshMainWithLease, + refreshFallbackAccount, + loadAccounts, + accountStoragePath: configPath, + now: () => now, + }) + + await resolveTarget('main') + expect(refreshMainWithLease).toHaveBeenCalledTimes(1) + expect(refreshFallbackAccount).toHaveBeenCalledTimes(0) + + await resolveTarget('fallback-a') + expect(refreshMainWithLease).toHaveBeenCalledTimes(1) + expect(refreshFallbackAccount).toHaveBeenCalledTimes(1) + }) + } + + describe('reset command safety flow', () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + + async function saveResetAccounts( + accounts: OAuthAccount[] = [ + makeAccount('fallback-a', { + accountId: 'chatgpt-fallback-a', + expires: now + 6 * 60 * 60_000, + }), + ], + ) { + await saveAccounts( + { + version: 1, + main: { type: 'opencode', provider: 'openai' }, + mainAccountId: 'chatgpt-main', + accounts, + }, + configPath, + ) + } + + test('empty args builds visible account rows with advisory quota and exact credit data', async () => { + await saveResetAccounts([ + makeAccount('fallback-a', { + label: 'Fallback A', + accountId: 'chatgpt-fallback-a', + expires: now + 6 * 60 * 60_000, + }), + makeAccount('healthy', { + accountId: 'chatgpt-healthy', + expires: now + 6 * 60 * 60_000, + }), + makeAccount('no-credits', { + accountId: 'chatgpt-no-credits', + expires: now + 6 * 60 * 60_000, + }), + makeAccount('disabled', { + accountId: 'chatgpt-disabled', + enabled: false, + }), + ]) + const fixture = resetFixture({ + usedPercent: { + 'chatgpt-main': 100, + 'chatgpt-fallback-a': 100, + 'chatgpt-healthy': 42, + 'chatgpt-no-credits': 100, + }, + applicableCount: { + 'chatgpt-main': 2, + 'chatgpt-fallback-a': 3, + 'chatgpt-healthy': 1, + 'chatgpt-no-credits': 0, + }, + availableCount: { + 'chatgpt-main': 4, + 'chatgpt-fallback-a': 5, + 'chatgpt-healthy': 1, + 'chatgpt-no-credits': 2, + }, + }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload('openai-reset', '', ctx) + const rows = payload.knobs.accounts as Array> + + expect(payload.knobs.stage).toBe('accounts') + expect(rows.map((row) => row.accountKey)).toEqual([ + 'main', + 'fallback-a', + 'healthy', + 'no-credits', + ]) + expect(rows[1]).toMatchObject({ + accountKey: 'fallback-a', + label: 'Fallback A', + usedPercent: 100, + availableCount: 5, + applicableAvailableCount: 3, + eligible: true, + selectedCreditId: 'credit-chatgpt-fallback-a-1', + selectedCreditExpiresAt: '2026-08-01T00:00:00.000Z', + }) + expect(rows.find((row) => row.accountKey === 'healthy')).toMatchObject({ + eligible: false, + reason: 'not exhausted', + }) + expect(rows.find((row) => row.accountKey === 'no-credits')).toMatchObject( + { + eligible: false, + reason: 'no applicable credits', + }, + ) + expect(payload.text).toContain('not exhausted') + expect(payload.text).toContain('no applicable credits') + }) + + test('exhausted main preview is eligible when usage reports three applicable credits', async () => { + await saveResetAccounts([]) + const fixture = resetFixture({ + usedPercent: { 'chatgpt-main': 100 }, + applicableCount: { 'chatgpt-main': 3 }, + availableCount: { 'chatgpt-main': 3 }, + }) + 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: 3, + applicableAvailableCount: 3, + eligible: true, + }), + ) + }) + + test('account preview keeps per-account failures visible and requires a stable identity for action', async () => { + await saveResetAccounts([ + makeAccount('broken', { + accountId: 'chatgpt-broken', + expires: now + 6 * 60 * 60_000, + }), + makeAccount('missing-identity', { + accountId: undefined, + expires: now + 6 * 60 * 60_000, + }), + ]) + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + const resolveTarget = ctx.resolveResetTarget + if (!resolveTarget) throw new Error('reset target resolver is not wired') + ctx.resolveResetTarget = async (accountKey) => { + if (accountKey === 'broken') + throw new Error('quota endpoint unavailable') + return resolveTarget(accountKey) + } + + const payload = await buildDialogPayload('openai-reset', '', ctx) + const rows = payload.knobs.accounts as Array> + + expect(rows.find((row) => row.accountKey === 'broken')).toMatchObject({ + eligible: false, + reason: 'quota endpoint unavailable', + }) + expect( + rows.find((row) => row.accountKey === 'missing-identity'), + ).toMatchObject({ + eligible: false, + reason: 'stable ChatGPT account identity unavailable', + }) + expect(payload.text).toContain('quota endpoint unavailable') + expect(payload.text).toContain( + 'stable ChatGPT account identity unavailable', + ) + }) + + test('select decodes the account key and returns a fresh token-free confirmation preview', async () => { + const accountKey = 'fallback/a b' + const accountId = 'chatgpt/fallback a' + await saveResetAccounts([ + makeAccount(accountKey, { + label: 'Encoded fallback', + accountId, + expires: now + 6 * 60 * 60_000, + }), + ]) + const fixture = resetFixture({ + usedPercent: { [accountId]: 100 }, + applicableCount: { [accountId]: 2 }, + availableCount: { [accountId]: 4 }, + }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + `select ${encodeURIComponent(accountKey)}`, + ctx, + ) + + expect(payload.knobs.stage).toBe('confirm') + expect(payload.knobs.preview).toMatchObject({ + accountKey, + chatgptAccountId: accountId, + }) + expect(payload.text).toContain('Encoded fallback') + expect(payload.text).toContain('100%') + expect(payload.text).toContain('Spend 1 of 2') + 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('select returns an informational result instead of confirmation for an ineligible account', async () => { + await saveResetAccounts() + const fixture = resetFixture({ + usedPercent: { 'chatgpt-fallback-a': 42 }, + applicableCount: { 'chatgpt-fallback-a': 3 }, + availableCount: { 'chatgpt-fallback-a': 3 }, + }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'select fallback-a', + ctx, + ) + + expect(payload.knobs).toMatchObject({ + stage: 'result', + code: 'not_eligible', + accountKey: 'fallback-a', + }) + expect(payload.knobs).not.toHaveProperty('preview') + expect(payload.text).toContain('Cannot reset:') + expect(payload.text).toContain('not exhausted') + }) + + test('select refuses prototype-sensitive decoded account keys before resolution', async () => { + await saveResetAccounts() + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + const resolveResetTarget = mock(ctx.resolveResetTarget!) + ctx.resolveResetTarget = resolveResetTarget + + const payload = await buildDialogPayload( + 'openai-reset', + `select ${encodeURIComponent('__proto__')}`, + ctx, + ) + + expect(payload.knobs).toMatchObject({ + stage: 'result', + code: 'invalid_command', + }) + expect(payload.text).toContain('Usage:') + expect(resolveResetTarget).not.toHaveBeenCalled() + }) + + test('preview reset time prefers a still-live exhausted window over a stale exhausted window', async () => { + await saveResetAccounts() + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + const liveReset = '2026-07-18T00:00:00.000Z' + ctx.fetchImpl = fetchStub(async (input) => { + if (input.toString().endsWith('/wham/usage')) { + return Response.json({ + rate_limit: { + primary_window: { + used_percent: 100, + reset_at: '2026-07-17T11:00:00.000Z', + }, + secondary_window: { + used_percent: 100, + reset_at: liveReset, + }, + }, + rate_limit_reset_credits: { + available_count: 2, + applicable_available_count: 2, + }, + }) + } + return resetCreditResponse('chatgpt-fallback-a', fixture) + }) + + const payload = await buildDialogPayload( + 'openai-reset', + 'select fallback-a', + ctx, + ) + + expect(payload.knobs.preview).toMatchObject({ resetTime: liveReset }) + }) + + for (const mutation of [ + 'removed fallback', + 'disabled fallback', + 'changed fallback account id', + 'changed main account id', + ] as const) { + test(`confirm refuses a ${mutation} before list or consume`, async () => { + await saveResetAccounts() + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + const accountKey = + mutation === 'changed main account id' ? 'main' : 'fallback-a' + const expectedId = + accountKey === 'main' ? 'chatgpt-main' : 'chatgpt-fallback-a' + + await buildDialogPayload( + 'openai-reset', + `select ${encodeURIComponent(accountKey)}`, + ctx, + ) + const callsBeforeConfirm = fixture.calls.length + await mutateAccounts((storage) => { + if (mutation === 'removed fallback') storage.accounts = [] + if (mutation === 'disabled fallback') { + const first = storage.accounts[0] + if (first) first.enabled = false + } + if (mutation === 'changed fallback account id') { + const first = storage.accounts[0] + if (first?.type === 'oauth') { + first.accountId = 'chatgpt-fallback-replacement' + } + } + if (mutation === 'changed main account id') { + storage.mainAccountId = 'chatgpt-main-replacement' + } + return storage + }, configPath) + + const payload = await buildDialogPayload( + 'openai-reset', + `confirm ${encodeURIComponent(accountKey)} ${encodeURIComponent(expectedId)}`, + ctx, + ) + + const expectedCode = + mutation === 'removed fallback' + ? 'unknown_account' + : mutation === 'disabled fallback' + ? 'disabled_account' + : 'identity_mismatch' + expect(payload.knobs).toMatchObject({ + stage: 'result', + code: expectedCode, + }) + expect(payload.text).toContain( + expectedCode === 'identity_mismatch' + ? 'account identity changed' + : 'Account unavailable', + ) + expect(fixture.calls).toHaveLength(callsBeforeConfirm) + }) + } + + for (const { code, expected } of [ + { + code: 'non_oauth_account', + expected: 'not authenticated with OAuth', + }, + { code: 'token_unavailable', expected: 'token is unavailable' }, + ] as const) { + test(`confirm renders truthful ${code} copy`, async () => { + await saveResetAccounts() + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + ctx.resolveResetTarget = async () => { + throw Object.assign(new Error('internal resolver detail'), { code }) + } + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs.code).toBe(code) + expect(payload.text).toContain(expected) + expect(payload.text).not.toContain('identity changed') + }) + } + + test('confirm rechecks a stale exhausted preview and refuses when the fresh quota is healthy', async () => { + await saveResetAccounts() + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + await buildDialogPayload('openai-reset', 'select fallback-a', ctx) + expect( + (await loadAccounts(configPath))?.reset?.['fallback-a'], + ).toBeUndefined() + fixture.usedPercent['chatgpt-fallback-a'] = 20 + const postsBefore = fixture.calls.filter( + (call) => call.method === 'POST', + ).length + const listsBefore = fixture.calls.filter((call) => + call.url.endsWith('/rate_limit_reset_credits'), + ).length + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs).toMatchObject({ + stage: 'result', + code: 'not_exhausted', + }) + expect(payload.text).toContain('not exhausted') + expect( + fixture.calls.filter((call) => call.method === 'POST'), + ).toHaveLength(postsBefore) + expect( + fixture.calls.filter((call) => + call.url.endsWith('/rate_limit_reset_credits'), + ), + ).toHaveLength(listsBefore) + expect( + (await loadAccounts(configPath))?.reset?.['fallback-a'], + ).toBeUndefined() + }) + + test('confirm refuses a near-limit 99.9% snapshot', async () => { + await saveResetAccounts() + const fixture = resetFixture({ + usedPercent: { 'chatgpt-fallback-a': 99.9 }, + }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs.code).toBe('not_exhausted') + expect(fixture.calls.some((call) => call.method === 'POST')).toBe(false) + }) + + test('confirm reports cooldown before POST and tells the user quota is being rechecked', async () => { + await saveResetAccounts() + await mutateAccounts((storage) => { + storage.reset = { + 'fallback-a': { cooldownUntil: now + 30_000 }, + } + return storage + }, configPath) + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs.code).toBe('cooldown_active') + expect(payload.text).toContain('just reset — re-checking quota') + expect(fixture.calls).toHaveLength(0) + }) + + test('confirm refuses an expired unreconciled attempt with bound retry guidance and no wire call', async () => { + await saveResetAccounts() + await mutateAccounts((storage) => { + storage.reset = { + 'fallback-a': { + inFlight: { + redeemRequestId: 'expired-request', + creditId: 'expired-credit', + startedAt: now - 5 * 60_000 - 1, + }, + }, + } + return storage + }, configPath) + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs).toMatchObject({ + stage: 'result', + code: 'expired_unreconciled', + accountKey: 'fallback-a', + chatgptAccountId: 'chatgpt-fallback-a', + retryGuidance: expect.any(String), + }) + expect(payload.text).toContain('previous attempt outcome is unknown') + expect(payload.text).toContain('retry replays the same identifiers') + expect(fixture.calls).toHaveLength(0) + }) + + for (const outcome of ['reset', 'already_redeemed'] as const) { + test(`${outcome} runs targeted post-verification and reports a fresh window only from the refreshed snapshot`, async () => { + await saveResetAccounts() + const fixture = resetFixture({ + outcome, + freshAfterPost: true, + applicableAfterPost: 0, + }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs).toMatchObject({ + stage: 'result', + code: outcome, + verifiedFresh: true, + remainingCredits: 0, + }) + expect(payload.knobs).not.toHaveProperty('chatgptAccountId') + expect(payload.text).toContain('window fresh') + expect(fixture.targetRefreshes).toEqual(['fallback-a']) + expect(fixture.sidebarRefreshes).toBe(1) + }) + } + + test('post-verification refresh failure is logged and surfaced without claiming a fresh window', async () => { + await saveResetAccounts() + const fixture = resetFixture({ outcome: 'reset' }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + ctx.refreshResetTargetQuota = async () => { + throw new Error('targeted refresh unavailable') + } + const logDir = mkdtempSync(join(tmpdir(), 'oai-reset-post-verify-')) + const logFile = join(logDir, 'test.log') + const savedLogFile = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + const savedLogLevel = process.env.OPENCODE_OPENAI_AUTH_LOG_LEVEL + try { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = logFile + setLogLevel('info') + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + await flushForTest() + const logged = readFileSync(logFile, 'utf8') + + expect(payload.text).toContain('window not yet refreshed') + expect(payload.text).toContain('quota re-check failed — see log') + expect(logged).toContain('reset quota re-check failed') + expect(logged).toContain('fallback-a') + expect(logged).toContain('targeted refresh unavailable') + } finally { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = savedLogFile + if (savedLogLevel !== undefined) { + process.env.OPENCODE_OPENAI_AUTH_LOG_LEVEL = savedLogLevel + } + setLogLevel(undefined) + rmSync(logDir, { recursive: true, force: true }) + } + }) + + test('generic reset failures hide internal details from the dialog and log them', async () => { + await saveResetAccounts() + const fixture = resetFixture() + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + ctx.resolveResetTarget = async () => { + throw new Error('/private/plugin/path: sensitive internal detail') + } + const logDir = mkdtempSync(join(tmpdir(), 'oai-reset-generic-error-')) + const logFile = join(logDir, 'test.log') + const savedLogFile = process.env.OPENCODE_OPENAI_AUTH_LOG_FILE + try { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = logFile + setLogLevel('info') + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + await flushForTest() + const logged = readFileSync(logFile, 'utf8') + + expect(payload.text).toContain( + 'internal command failure — see plugin log', + ) + expect(payload.text).not.toContain('/private/plugin/path') + expect(logged).toContain('/private/plugin/path') + } finally { + process.env.OPENCODE_OPENAI_AUTH_LOG_FILE = savedLogFile + setLogLevel(undefined) + rmSync(logDir, { recursive: true, force: true }) + } + }) + + test('ambiguous local renderer preserves no-request guidance without success or retry guarantees', async () => { + const result = { + target: { + accountKey: 'fallback-a', + label: 'Fallback A', + accessToken: 'secret-token', + chatgptAccountId: 'chatgpt-fallback-a', + }, + selectedCredit: undefined, + beforeState: undefined, + outcome: { + kind: 'ambiguous_local' as const, + raw: { reason: 'corrupt_in_flight' as const }, + }, + retrySafety: + 'No request was sent because the saved redemption identity was incomplete.', + } + + const payload = await renderResetCoordinatorResult( + result, + {} as Parameters[1], + ) + + expect(payload.text).toContain('No request was sent') + expect(payload.text.toLowerCase()).not.toContain('success') + expect(payload.text).not.toContain('retry is free') + expect(payload.text).not.toContain('guaranteed') + expect(payload.text).not.toContain('secret-token') + }) + + test('retry-safe renderer carries the preview-bound identity instead of rebuilding it from the result target', async () => { + const result = { + target: { + accountKey: 'fallback/a b', + label: 'Fallback A', + accessToken: 'secret-token', + chatgptAccountId: 'resolved-target-id', + }, + selectedCredit: { id: 'credit-1' }, + beforeState: undefined, + outcome: { + kind: 'ambiguous' as const, + raw: new Error('connection lost'), + }, + retrySafety: + 'The outcome is uncertain. A retry reuses the same request and credit identifiers.', + } + + const payload = await renderResetCoordinatorResult( + result, + {} as Parameters[1], + 'preview-bound/id', + ) + + expect(payload.knobs).toMatchObject({ + accountKey: 'fallback/a b', + chatgptAccountId: 'preview-bound/id', + }) + expect(payload.text).toContain(encodeURIComponent('preview-bound/id')) + expect(payload.text).not.toContain('resolved-target-id') + expect(payload.text).not.toContain('secret-token') + }) + + test('terminal result with a failed finalize write keeps the known outcome and offers identifier reuse', async () => { + const result = { + target: { + accountKey: 'fallback-a', + label: 'Fallback A', + accessToken: 'secret-token', + chatgptAccountId: 'chatgpt-fallback-a', + }, + selectedCredit: { id: 'credit-1' }, + beforeState: undefined, + outcome: { kind: 'reset' as const, raw: { code: 'reset' } }, + retrySafety: 'same identifiers', + finalizeStateWriteFailed: true, + } + + const payload = await renderResetCoordinatorResult( + result, + {} as Parameters[1], + 'chatgpt-fallback-a', + ) + + expect(payload.knobs).toMatchObject({ + code: 'reset', + stateWriteFailed: true, + chatgptAccountId: 'chatgpt-fallback-a', + }) + expect(payload.text).toContain('outcome recorded as `reset`') + expect(payload.text).toContain('state write failed') + expect(payload.text).toContain('same request and credit identifiers') + }) + + test('reset does not call an exhausted post-refresh snapshot fresh', async () => { + await saveResetAccounts() + const fixture = resetFixture({ outcome: 'reset' }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs).toMatchObject({ + code: 'reset', + verifiedFresh: false, + }) + expect(payload.text).toContain('window not yet refreshed') + expect(payload.text).not.toContain('window fresh') + }) + + for (const outcome of ['nothing_to_reset', 'no_credit'] as const) { + test(`${outcome} preserves the exact no-op code without claiming success`, async () => { + await saveResetAccounts() + const fixture = resetFixture({ outcome }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs.code).toBe(outcome) + expect(payload.knobs).not.toHaveProperty('chatgptAccountId') + expect(payload.text).toContain(outcome) + expect(payload.text.toLowerCase()).not.toContain('success') + expect(payload.text).toContain( + 'A new attempt starts fresh and must pass the current preconditions.', + ) + expect(fixture.targetRefreshes).toHaveLength(0) + }) + } + + for (const outcome of ['ambiguous', 'http_error'] as const) { + test(`${outcome} reports an unknown outcome with bounded retry guidance and no post-verification`, async () => { + await saveResetAccounts() + const fixture = resetFixture( + outcome === 'ambiguous' ? { throwOnPost: true } : { postStatus: 503 }, + ) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.knobs.code).toBe(outcome) + expect(payload.knobs).toMatchObject({ + accountKey: 'fallback-a', + chatgptAccountId: 'chatgpt-fallback-a', + }) + expect(payload.text).toContain('outcome is unknown') + expect(payload.text).toContain('same request and credit identifiers') + expect(payload.text).not.toContain('retry is free') + expect(payload.text).not.toContain('guaranteed') + expect(fixture.targetRefreshes).toHaveLength(0) + }) + } + + test('retry decodes the bound identity and reuses the persisted in-flight request', async () => { + await saveResetAccounts() + const firstFixture = resetFixture({ throwOnPost: true }) + const harness = await makeResetCommandHarness( + configPath, + now, + firstFixture, + ) + await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + harness.ctx, + ) + const state = (await loadAccounts(configPath))?.reset?.['fallback-a'] + expect(state?.inFlight?.redeemRequestId).toBe('reset-request-id') + firstFixture.throwOnPost = false + firstFixture.outcome = 'nothing_to_reset' + + const payload = await buildDialogPayload( + 'openai-reset', + `retry ${encodeURIComponent('fallback-a')} ${encodeURIComponent('chatgpt-fallback-a')}`, + harness.ctx, + ) + + expect(payload.knobs.code).toBe('nothing_to_reset') + const postBodies = firstFixture.calls.filter( + (call) => call.method === 'POST', + ) + expect(postBodies).toHaveLength(2) + }) + + test('result text remains useful without a connected TUI', async () => { + await saveResetAccounts() + const fixture = resetFixture({ outcome: 'no_credit' }) + const { ctx } = await makeResetCommandHarness(configPath, now, fixture) + ctx.notify = undefined + + const payload = await buildDialogPayload( + 'openai-reset', + 'confirm fallback-a chatgpt-fallback-a', + ctx, + ) + + expect(payload.text.length).toBeGreaterThan(60) + expect(payload.text).toContain('fallback-a') + expect(payload.text).toContain('no_credit') + }) + }) }) // ----------------------------------------------------------------------- diff --git a/packages/opencode/src/tests/quota-normalize.test.ts b/packages/opencode/src/tests/quota-normalize.test.ts index 9faace6..ba871ed 100644 --- a/packages/opencode/src/tests/quota-normalize.test.ts +++ b/packages/opencode/src/tests/quota-normalize.test.ts @@ -185,12 +185,16 @@ describe('quota normalize → QuotaSnapshot', () => { }, secondary_window: null, }, - rate_limit_reset_credits: { available_count: 4 }, + rate_limit_reset_credits: { + available_count: 4, + applicable_available_count: 3, + }, } as Parameters[0]) expect(snapshot.primary?.windowMinutes).toBe(10_080) expect(snapshot.secondary).toBeUndefined() expect(snapshot.resetCreditsAvailable).toBe(4) + expect(snapshot.resetCreditsApplicable).toBe(3) }) it('omits invalid window lengths and reset-credit counts', () => { diff --git a/packages/opencode/src/tests/quota-push.test.ts b/packages/opencode/src/tests/quota-push.test.ts index 602a5b0..ebedca1 100644 --- a/packages/opencode/src/tests/quota-push.test.ts +++ b/packages/opencode/src/tests/quota-push.test.ts @@ -370,6 +370,65 @@ describe('QuotaManager push', () => { ).toBe(0) }) + it('preserves applicable reset credits when a per-turn push omits them', () => { + const previous: OAuthQuotaSnapshot = { + primary: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: 1, + windowMinutes: 10_080, + }, + resetCreditsAvailable: 4, + resetCreditsApplicable: 3, + } + const incoming: OAuthQuotaSnapshot = { + primary: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 2, + windowMinutes: 10_080, + }, + } + + const merged = mergePushedQuotaMetadata(incoming, previous) + expect(merged.resetCreditsAvailable).toBe(4) + expect(merged.resetCreditsApplicable).toBe(3) + + // An explicit incoming count (including 0) wins over the cached one. + const explicit = mergePushedQuotaMetadata( + { ...incoming, resetCreditsApplicable: 0 }, + previous, + ) + expect(explicit.resetCreditsApplicable).toBe(0) + expect(explicit.resetCreditsAvailable).toBe(4) + }) + + it('round-trips resetCreditsApplicable from wham normalization through the main quota cache', async () => { + const { QuotaManager } = await import('../core/quota-manager.ts') + const { normalizeWham } = await import('../quota-normalize.ts') + const snapshot = normalizeWham({ + rate_limit: { + primary_window: { + used_percent: 100, + limit_window_seconds: 18_000, + reset_at: 1_784_809_904, + }, + secondary_window: null, + }, + rate_limit_reset_credits: { + available_count: 4, + applicable_available_count: 3, + }, + } as Parameters[0]) + + const qm: QuotaManager = new QuotaManager({ storage: null }) + qm.setMain('main-token', { quota: snapshot, refreshAfter: 2, checkedAt: 1 }) + + const cached = qm.peekMainForPolicy()?.quota + expect(cached?.resetCreditsApplicable).toBe(3) + expect(cached?.resetCreditsAvailable).toBe(4) + }) + it('publishes each account reset-credit count independently of the active account', async () => { const { QuotaManager } = await import('../core/quota-manager.ts') const qm: QuotaManager = new QuotaManager({ storage: null }) diff --git a/packages/opencode/src/tests/refresh-all-quota.test.ts b/packages/opencode/src/tests/refresh-all-quota.test.ts index ca550b3..bedee0d 100644 --- a/packages/opencode/src/tests/refresh-all-quota.test.ts +++ b/packages/opencode/src/tests/refresh-all-quota.test.ts @@ -97,6 +97,177 @@ function makeDeps(opts: MakeDepsOptions = {}): RefreshAllQuotaDeps { } describe('refreshAllQuota', () => { + test('main refresh reads the current persisted account identity at call time', async () => { + const deps = makeDeps() + const whamFn = mock(async () => makeQuotaSnapshot(30)) + deps.whamFn = whamFn + deps.loadAccounts = mock(async () => ({ + version: 1 as const, + accounts: [], + mainAccountId: 'chatgpt-fresh-main', + })) + + await refreshAllQuota(deps, { accountKey: 'main' }) + + expect(whamFn).toHaveBeenCalledWith( + expect.objectContaining({ accountId: 'chatgpt-fresh-main' }), + ) + }) + + test('accountKey main refreshes only main and clears only its healthy rate-limit mark', async () => { + const deps = makeDeps() + const setMain = mock(deps.quotaManager.setMain.bind(deps.quotaManager)) + const setFallback = mock( + deps.quotaManager.setFallback.bind(deps.quotaManager), + ) + deps.quotaManager.setMain = setMain + deps.quotaManager.setFallback = setFallback + const future = Date.now() + 60_000 + deps.quotaManager.markRateLimited('main', future) + deps.quotaManager.markRateLimited('fb-1', future) + + const results = await refreshAllQuota(deps, { accountKey: 'main' }) + + expect(results).toEqual([{ account: 'main', ok: true }]) + expect(deps.getAuth).toHaveBeenCalledTimes(1) + expect(deps.whamFn).toHaveBeenCalledTimes(1) + expect(deps.whamFn).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'access-main', + accountId: 'chatgpt-main', + }), + ) + expect(deps.quotaManager.getMain()?.quota?.primary?.usedPercent).toBe(30) + expect(setMain).toHaveBeenCalledTimes(1) + expect(setFallback).not.toHaveBeenCalled() + expect(deps.fallbackManager.refreshAccount).not.toHaveBeenCalled() + expect(deps.quotaManager.getFallback('fb-1')).toBeNull() + expect(deps.quotaManager.isRateLimited('main')).toBe(false) + expect(deps.quotaManager.isRateLimited('fb-1')).toBe(true) + expect(deps.writeSidebarState).toHaveBeenCalledTimes(1) + }) + + test('accountKey fallback-a refreshes only that OAuth fallback and clears only its healthy rate-limit mark', async () => { + const deps = makeDeps({ + accounts: [ + { + id: 'fallback-a', + type: 'oauth' as const, + access: 'access-fallback-a', + refresh: 'refresh-fallback-a', + expires: Date.now() + 3600_000, + enabled: true, + accountId: 'chatgpt-fallback-a', + }, + { + id: 'fallback-b', + type: 'oauth' as const, + access: 'access-fallback-b', + refresh: 'refresh-fallback-b', + expires: Date.now() + 3600_000, + enabled: true, + accountId: 'chatgpt-fallback-b', + }, + ], + }) + const setMain = mock(deps.quotaManager.setMain.bind(deps.quotaManager)) + const setFallback = mock( + deps.quotaManager.setFallback.bind(deps.quotaManager), + ) + deps.quotaManager.setMain = setMain + deps.quotaManager.setFallback = setFallback + const future = Date.now() + 60_000 + deps.quotaManager.markRateLimited('fallback-a', future) + deps.quotaManager.markRateLimited('fallback-b', future) + + const results = await refreshAllQuota(deps, { accountKey: 'fallback-a' }) + + expect(results).toEqual([{ account: 'fallback-a', ok: true }]) + expect(deps.getAuth).not.toHaveBeenCalled() + expect(deps.client.auth.set).not.toHaveBeenCalled() + expect(deps.fallbackManager.refreshAccount).toHaveBeenCalledTimes(1) + expect(deps.fallbackManager.refreshAccount).toHaveBeenCalledWith( + expect.objectContaining({ id: 'fallback-a' }), + expect.anything(), + ) + expect(deps.whamFn).toHaveBeenCalledTimes(1) + expect(deps.whamFn).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'access-fallback-a', + accountId: 'chatgpt-fallback-a', + }), + ) + expect(deps.quotaManager.getMain()).toBeNull() + expect(setMain).not.toHaveBeenCalled() + expect(setFallback).toHaveBeenCalledTimes(1) + expect( + deps.quotaManager.getFallback('fallback-a')?.quota?.primary?.usedPercent, + ).toBe(30) + expect(deps.quotaManager.getFallback('fallback-b')).toBeNull() + expect(deps.quotaManager.isRateLimited('fallback-a')).toBe(false) + expect(deps.quotaManager.isRateLimited('fallback-b')).toBe(true) + expect(deps.writeSidebarState).toHaveBeenCalledTimes(1) + }) + + test.each([ + { + label: 'unknown', + accountKey: 'missing', + selected: undefined, + }, + { + label: 'disabled', + accountKey: 'selected-disabled', + selected: { + id: 'selected-disabled', + type: 'oauth' as const, + access: 'access-disabled', + refresh: 'refresh-disabled', + expires: Date.now() + 3600_000, + enabled: false, + }, + }, + { + label: 'non-OAuth', + accountKey: 'selected-api', + selected: { + id: 'selected-api', + type: 'api' as const, + apiKey: 'sk-selected', + baseURL: 'https://example.test', + enabled: true, + }, + }, + ])( + '$label fallback accountKey fails without refreshing another identity', + async ({ accountKey, selected }) => { + const other: FallbackAccount = { + id: 'other', + type: 'oauth' as const, + access: 'access-other', + refresh: 'refresh-other', + expires: Date.now() + 3600_000, + enabled: true, + } + const deps = makeDeps({ + accounts: selected ? [selected, other] : [other], + }) + + const results = await refreshAllQuota(deps, { accountKey }) + + expect(results).toHaveLength(1) + expect(results[0]?.account).toBe(accountKey) + expect(results[0]?.ok).toBe(false) + expect(deps.getAuth).not.toHaveBeenCalled() + expect(deps.client.auth.set).not.toHaveBeenCalled() + expect(deps.fallbackManager.refreshAccount).not.toHaveBeenCalled() + expect(deps.whamFn).not.toHaveBeenCalled() + expect(deps.quotaManager.getMain()).toBeNull() + expect(deps.quotaManager.getFallback('other')).toBeNull() + expect(deps.writeSidebarState).toHaveBeenCalledTimes(1) + }, + ) + test('main + 2 fallbacks all succeed → setMain + setFallback called with snapshots', async () => { const deps = makeDeps() const results = await refreshAllQuota(deps) diff --git a/packages/opencode/src/tests/reset-credits.test.ts b/packages/opencode/src/tests/reset-credits.test.ts new file mode 100644 index 0000000..4674d58 --- /dev/null +++ b/packages/opencode/src/tests/reset-credits.test.ts @@ -0,0 +1,1303 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + type AccountStorage, + loadAccounts, + mutateAccounts, + type OAuthQuotaSnapshot, + type ResetInFlight, +} from '../core/accounts.ts' +import { + claimResetAttempt, + consumeResetCredit, + evaluateResetPrecondition, + listResetCredits, + type ResetConsumeKind, + type ResetCredit, + type ResetCreditList, + ResetRedemptionError, + type RunResetCreditDeps, + runResetCreditRedemption, + selectCreditToSpend, +} from '../core/reset-credits.ts' + +function fetchStub( + implementation: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise, +): typeof globalThis.fetch { + return Object.assign(implementation, { + preconnect: ( + ..._args: Parameters + ) => {}, + }) +} + +function credit(overrides: Partial = {}): ResetCredit { + return { + id: 'credit-1', + status: 'available', + expiresAt: '2026-08-01T00:00:00.000Z', + resetType: 'codex_rate_limits', + isSupportedByPlan: true, + ...overrides, + } +} + +describe('reset credits list', () => { + it('selects the eligible credit with the earliest expiry', () => { + const late = credit({ + id: 'late', + expiresAt: '2026-08-02T00:00:00.000Z', + }) + const eligible = credit({ + id: 'eligible', + expiresAt: '2026-08-01T00:00:00.000Z', + }) + const credits = [late, eligible] + + expect(selectCreditToSpend(credits)).toBe(eligible) + expect(credits).toEqual([late, eligible]) + }) + + it('returns undefined when no credit is eligible', () => { + const redeemed = credit({ id: 'redeemed', status: 'redeemed' }) + const unsupported = credit({ + id: 'unsupported', + isSupportedByPlan: false, + }) + const wrongType = credit({ id: 'wrong-type', resetType: 'other' }) + + expect( + selectCreditToSpend([redeemed, unsupported, wrongType]), + ).toBeUndefined() + expect(selectCreditToSpend([])).toBeUndefined() + }) + + it('normalizes missing arrays and counts', async () => { + const result = await listResetCredits( + fetchStub(async () => Response.json({})), + 'target-token', + ) + + expect(result).toEqual({ + credits: [], + availableCount: undefined, + }) + }) + + it('parses the live credit-list shape and tolerates nullable optional fields', async () => { + const result = await listResetCredits( + fetchStub(async () => + Response.json({ + credits: [ + { + id: 'credit-1', + status: 'available', + granted_at: '2026-07-01T00:00:00.000Z', + expires_at: '2026-08-01T00:00:00.000Z', + reset_type: 'codex_rate_limits', + is_supported_by_plan: true, + redeem_started_at: null, + redeemed_at: null, + }, + { + id: 'credit-2', + status: 'available', + granted_at: '2026-07-02T00:00:00.000Z', + expires_at: '2026-08-02T00:00:00.000Z', + reset_type: 'codex_rate_limits', + is_supported_by_plan: true, + redeem_started_at: null, + redeemed_at: null, + }, + { + id: 'credit-3', + status: 'available', + granted_at: null, + expires_at: '2026-08-03T00:00:00.000Z', + reset_type: null, + is_supported_by_plan: true, + redeem_started_at: null, + redeemed_at: null, + }, + ], + available_count: 3, + total_earned_count: 0, + }), + ), + 'target-token', + ) + + expect(result).toEqual({ + credits: [ + { + id: 'credit-1', + status: 'available', + grantedAt: '2026-07-01T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + resetType: 'codex_rate_limits', + isSupportedByPlan: true, + }, + { + id: 'credit-2', + status: 'available', + grantedAt: '2026-07-02T00:00:00.000Z', + expiresAt: '2026-08-02T00:00:00.000Z', + resetType: 'codex_rate_limits', + isSupportedByPlan: true, + }, + { + id: 'credit-3', + status: 'available', + expiresAt: '2026-08-03T00:00:00.000Z', + isSupportedByPlan: true, + }, + ], + availableCount: 3, + }) + }) + + it('ignores the obsolete nested credit-list key', async () => { + const result = await listResetCredits( + fetchStub(async () => + Response.json({ + rate_limit_reset_credits: [ + { + id: 'obsolete-shape', + status: 'available', + expires_at: '2026-08-01T00:00:00.000Z', + reset_type: 'codex_rate_limits', + is_supported_by_plan: true, + }, + ], + available_count: 1, + applicable_available_count: 1, + }), + ), + 'target-token', + ) + + expect(result.credits).toEqual([]) + expect(result.availableCount).toBe(1) + }) + + it('uses target authorization and omits the account header for main', async () => { + let capturedUrl: string | undefined + let capturedInit: RequestInit | undefined + const fetchImpl = fetchStub(async (input, init) => { + capturedUrl = input.toString() + capturedInit = init + return Response.json({}) + }) + + await listResetCredits(fetchImpl, 'target-token') + + const headers = new Headers(capturedInit?.headers) + expect(capturedUrl).toBe( + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', + ) + expect(headers.get('authorization')).toBe('Bearer target-token') + expect(headers.has('chatgpt-account-id')).toBe(false) + expect(headers.get('oai-client-platform')).toBe('web') + expect(headers.get('oai-client-version')).toBe('0') + expect(headers.get('x-openai-target-path')).toBe( + '/backend-api/wham/rate-limit-reset-credits', + ) + }) + + it('includes the fallback account header when supplied', async () => { + let capturedInit: RequestInit | undefined + const fetchImpl = fetchStub(async (_input, init) => { + capturedInit = init + return Response.json({}) + }) + + await listResetCredits(fetchImpl, 'fallback-token', 'account-1') + + expect(new Headers(capturedInit?.headers).get('chatgpt-account-id')).toBe( + 'account-1', + ) + }) + + it('does not send an empty account header', async () => { + let capturedInit: RequestInit | undefined + const fetchImpl = fetchStub(async (_input, init) => { + capturedInit = init + return Response.json({}) + }) + + await listResetCredits(fetchImpl, 'target-token', '') + + expect(new Headers(capturedInit?.headers).has('chatgpt-account-id')).toBe( + false, + ) + }) + + it('aborts a hanging list fetch after 15 seconds and maps it to http_error', async () => { + jest.useFakeTimers() + try { + let capturedSignal: AbortSignal | null | undefined + const fetchImpl = fetchStub( + async (_input, init) => + await new Promise((_resolve, reject) => { + capturedSignal = init?.signal + capturedSignal?.addEventListener('abort', () => { + reject(capturedSignal?.reason) + }) + }), + ) + + const pending = listResetCredits(fetchImpl, 'target-token') + + expect(capturedSignal).toBeInstanceOf(AbortSignal) + jest.advanceTimersByTime(14_999) + await Promise.resolve() + expect(capturedSignal?.aborted).toBe(false) + + jest.advanceTimersByTime(1) + await expect(pending).rejects.toMatchObject({ + name: 'ResetCreditError', + kind: 'http_error', + }) + expect(capturedSignal?.aborted).toBe(true) + } finally { + jest.useRealTimers() + } + }) +}) + +describe('reset credit consumption', () => { + it('sends the exact request identity and retains the reset response', async () => { + let capturedUrl: string | undefined + let capturedInit: RequestInit | undefined + const raw = { code: 'reset', reset_at: '2026-07-17T00:00:00.000Z' } + const fetchImpl = fetchStub(async (input, init) => { + capturedUrl = input.toString() + capturedInit = init + return Response.json(raw) + }) + + const outcome = await consumeResetCredit( + fetchImpl, + 'target-token', + 'account-1', + 'credit-1', + 'redeem-request-1', + ) + + expect(capturedUrl).toBe( + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', + ) + expect(capturedInit?.method).toBe('POST') + expect(JSON.parse(String(capturedInit?.body))).toEqual({ + redeem_request_id: 'redeem-request-1', + credit_id: 'credit-1', + }) + const headers = new Headers(capturedInit?.headers) + expect(headers.get('authorization')).toBe('Bearer target-token') + expect(headers.get('chatgpt-account-id')).toBe('account-1') + expect(headers.get('content-type')).toBe('application/json') + expect(headers.get('x-openai-target-path')).toBe( + '/backend-api/wham/rate-limit-reset-credits/consume', + ) + expect(capturedInit?.signal).toBeInstanceOf(AbortSignal) + expect(capturedInit?.signal?.aborted).toBe(false) + expect(outcome).toEqual({ kind: 'reset', raw }) + }) + + for (const code of [ + 'reset', + 'already_redeemed', + 'nothing_to_reset', + 'no_credit', + ] as const satisfies readonly ResetConsumeKind[]) { + it(`maps ${code} as a terminal outcome and retains raw`, async () => { + const raw = { code, detail: `${code}-detail` } + + const outcome = await consumeResetCredit( + fetchStub(async () => Response.json(raw)), + 'target-token', + undefined, + 'credit-1', + 'redeem-request-1', + ) + + expect(outcome).toEqual({ kind: code, raw }) + }) + } + + it('maps non-2xx responses to http_error with status and raw', async () => { + const raw = { error: 'unavailable' } + + const outcome = await consumeResetCredit( + fetchStub(async () => Response.json(raw, { status: 503 })), + 'target-token', + undefined, + 'credit-1', + 'redeem-request-1', + ) + + expect(outcome).toEqual({ kind: 'http_error', raw, status: 503 }) + }) + + it('maps a rejected fetch to ambiguous and retains the error', async () => { + const error = new TypeError('connection lost') + + const outcome = await consumeResetCredit( + fetchStub(async () => { + throw error + }), + 'target-token', + undefined, + 'credit-1', + 'redeem-request-1', + ) + + expect(outcome).toEqual({ kind: 'ambiguous', raw: error }) + }) + + it('maps malformed JSON on 200 to ambiguous and retains the raw body', async () => { + const outcome = await consumeResetCredit( + fetchStub(async () => new Response('{not-json', { status: 200 })), + 'target-token', + undefined, + 'credit-1', + 'redeem-request-1', + ) + + expect(outcome).toEqual({ kind: 'ambiguous', raw: '{not-json' }) + }) + + it('maps an unknown success code to ambiguous and retains raw', async () => { + const raw = { code: 'future_server_code', detail: 'unknown' } + + const outcome = await consumeResetCredit( + fetchStub(async () => Response.json(raw)), + 'target-token', + undefined, + 'credit-1', + 'redeem-request-1', + ) + + expect(outcome).toEqual({ kind: 'ambiguous', raw }) + }) + + it('aborts a hanging fetch after 60 seconds and maps it to ambiguous', async () => { + jest.useFakeTimers() + try { + let capturedSignal: AbortSignal | null | undefined + const fetchImpl = fetchStub( + async (_input, init) => + await new Promise((_resolve, reject) => { + capturedSignal = init?.signal + capturedSignal?.addEventListener('abort', () => { + reject(capturedSignal?.reason) + }) + }), + ) + + let settled = false + const pending = consumeResetCredit( + fetchImpl, + 'target-token', + undefined, + 'credit-1', + 'redeem-request-1', + ).then((outcome) => { + settled = true + return outcome + }) + + expect(capturedSignal).toBeInstanceOf(AbortSignal) + jest.advanceTimersByTime(59_999) + await Promise.resolve() + expect(capturedSignal?.aborted).toBe(false) + expect(settled).toBe(false) + + jest.advanceTimersByTime(1) + const outcome = await pending + + expect(capturedSignal?.aborted).toBe(true) + expect(outcome.kind).toBe('ambiguous') + expect(outcome.raw).toBe(capturedSignal?.reason) + } finally { + jest.useRealTimers() + } + }) +}) + +function quotaWindow( + usedPercent: number, + resetsAt: string | undefined = '2026-07-18T00:00:00.000Z', +) { + return { + usedPercent, + remainingPercent: 100 - usedPercent, + resetsAt, + checkedAt: Date.parse('2026-07-17T00:00:00.000Z'), + } +} + +describe('reset redemption precondition', () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + + it('accepts an exhausted live window with an applicable credit', () => { + expect( + evaluateResetPrecondition({ primary: quotaWindow(100) }, false, 1, now), + ).toEqual({ ok: true }) + }) + + it('refuses healthy quota', () => { + expect( + evaluateResetPrecondition({ primary: quotaWindow(20) }, false, 1, now), + ).toEqual({ ok: false, reason: 'not exhausted' }) + }) + + it('refuses exhausted quota without applicable credits', () => { + expect( + evaluateResetPrecondition({ primary: quotaWindow(100) }, false, 0, now), + ).toEqual({ ok: false, reason: 'no applicable credits' }) + }) + + it('treats a 100%-used expired window as stale rather than exhausted', () => { + expect( + evaluateResetPrecondition( + { + primary: quotaWindow(100, '2026-07-17T11:59:59.999Z'), + }, + false, + 1, + now, + ), + ).toEqual({ ok: false, reason: 'not exhausted' }) + }) + + it('accepts an exhausted live secondary window when primary is healthy', () => { + expect( + evaluateResetPrecondition( + { + primary: quotaWindow(20), + secondary: quotaWindow(100), + }, + false, + 1, + now, + ), + ).toEqual({ ok: true }) + }) + + it('treats an exhausted but expired secondary window as stale', () => { + expect( + evaluateResetPrecondition( + { + primary: quotaWindow(20), + secondary: quotaWindow(100, '2026-07-17T11:59:59.999Z'), + }, + false, + 1, + now, + ), + ).toEqual({ ok: false, reason: 'not exhausted' }) + }) + + it('preserves exhaustion for absent or unparseable reset times', () => { + expect( + 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', + }) + }) +}) + +interface Deferred { + promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +let redemptionDir: string +let redemptionConfigPath: string + +beforeEach(() => { + redemptionDir = mkdtempSync(join(tmpdir(), 'oai-reset-redemption-')) + redemptionConfigPath = join(redemptionDir, 'openai-auth.json') + writeFileSync( + redemptionConfigPath, + JSON.stringify({ + version: 1, + main: { type: 'opencode', provider: 'openai' }, + accounts: [], + }), + ) +}) + +afterEach(() => { + jest.useRealTimers() + rmSync(redemptionDir, { recursive: true, force: true }) +}) + +function creditList(...credits: ResetCredit[]): ResetCreditList { + return { + credits, + availableCount: credits.length, + } +} + +function listResponse(list = creditList(credit())): Response { + return Response.json({ + credits: list.credits.map((item) => ({ + id: item.id, + status: item.status, + granted_at: item.grantedAt, + expires_at: item.expiresAt, + reset_type: item.resetType, + is_supported_by_plan: item.isSupportedByPlan, + })), + available_count: list.availableCount, + }) +} + +function requestBody(init?: RequestInit): { + redeem_request_id: string + credit_id: string +} { + return JSON.parse(String(init?.body)) +} + +function redemptionDeps( + options: { + fetchImpl?: typeof fetch + fetchUsage?: (target: { + accountKey: string + label: string + accessToken: string + chatgptAccountId?: string + }) => Promise + now?: () => number + randomUUID?: () => string + hasActiveRateLimitMark?: (accountKey: string) => boolean + } = {}, +): RunResetCreditDeps { + return { + configPath: redemptionConfigPath, + mutateAccountsFn: mutateAccounts, + loadAccountsFn: loadAccounts, + now: options.now ?? (() => Date.parse('2026-07-17T12:00:00.000Z')), + randomUUID: options.randomUUID ?? (() => 'uuid-new'), + fetchImpl: + options.fetchImpl ?? + fetchStub(async (input) => + input.toString().endsWith('/consume') + ? Response.json({ code: 'reset' }) + : listResponse(), + ), + resolveTarget: async (accountKey) => ({ + accountKey, + label: accountKey === 'main' ? 'Main' : accountKey, + accessToken: `${accountKey}-token`, + chatgptAccountId: `${accountKey}-account-id`, + }), + fetchUsage: + options.fetchUsage ?? + (async () => ({ + primary: quotaWindow(100), + resetCreditsApplicable: 1, + })), + hasActiveRateLimitMark: options.hasActiveRateLimitMark ?? (() => false), + } +} + +function redemptionInput(accountKey = 'main', retry = false) { + return { + accountKey, + expectedChatgptAccountId: `${accountKey}-account-id`, + retry, + } +} + +async function seedResetState( + accountKey: string, + state: NonNullable[string], +): Promise { + await mutateAccounts((current) => { + current.reset ??= {} + current.reset[accountKey] = state + return current + }, redemptionConfigPath) +} + +async function persistedResetState(accountKey = 'main') { + return (await loadAccounts(redemptionConfigPath))?.reset?.[accountKey] +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempts = 0; attempts < 1000; attempts += 1) { + if (predicate()) return + await Bun.sleep(1) + } + throw new Error('condition was not reached') +} + +describe('atomic reset credit redemption', () => { + it('binds main identity without sending it as a wire header', async () => { + const requestHeaders: Headers[] = [] + const deps = redemptionDeps({ + fetchImpl: fetchStub(async (input, init) => { + requestHeaders.push(new Headers(init?.headers)) + return input.toString().endsWith('/consume') + ? Response.json({ code: 'reset' }) + : listResponse() + }), + }) + deps.resolveTarget = async () => ({ + accountKey: 'main', + label: 'Main account', + accessToken: 'main-token', + chatgptAccountId: 'chatgpt-main', + }) + + await runResetCreditRedemption(deps, { + accountKey: 'main', + expectedChatgptAccountId: 'chatgpt-main', + retry: false, + }) + + expect(requestHeaders).toHaveLength(2) + for (const headers of requestHeaders) { + expect(headers.get('authorization')).toBe('Bearer main-token') + expect(headers.has('chatgpt-account-id')).toBe(false) + } + }) + + it('reuses one request and credit across genuinely overlapping confirms', async () => { + const usageGate = deferred() + const listGate = deferred() + const pendingPosts: Deferred[] = [] + const postBodies: ReturnType[] = [] + let usageCalls = 0 + let listCalls = 0 + let uuidCount = 0 + const fetchImpl = fetchStub(async (input, init) => { + if (!input.toString().endsWith('/consume')) { + listCalls += 1 + await listGate.promise + return listResponse() + } + postBodies.push(requestBody(init)) + const pending = deferred() + pendingPosts.push(pending) + return pending.promise + }) + const deps = redemptionDeps({ + fetchImpl, + fetchUsage: async () => { + usageCalls += 1 + await usageGate.promise + return { + primary: quotaWindow(100), + resetCreditsApplicable: 1, + } + }, + randomUUID: () => `uuid-${++uuidCount}`, + }) + + const first = runResetCreditRedemption(deps, redemptionInput()) + const second = runResetCreditRedemption(deps, redemptionInput()) + await waitFor(() => usageCalls === 2 && listCalls === 2) + usageGate.resolve() + listGate.resolve() + await waitFor(() => postBodies.length === 2) + + expect(pendingPosts).toHaveLength(2) + expect(new Set(postBodies.map((body) => body.redeem_request_id))).toEqual( + new Set(['uuid-1']), + ) + expect(new Set(postBodies.map((body) => body.credit_id))).toEqual( + new Set(['credit-1']), + ) + expect(uuidCount).toBe(1) + + for (const pending of pendingPosts) { + pending.resolve(Response.json({ code: 'reset' })) + } + await Promise.all([first, second]) + }) + + it('atomically reuses one claim across two direct claimants', async () => { + let uuidCount = 0 + const deps = redemptionDeps({ + randomUUID: () => `direct-uuid-${++uuidCount}`, + }) + + const [first, second] = await Promise.all([ + claimResetAttempt(deps, 'main', [credit()]), + claimResetAttempt(deps, 'main', [credit()]), + ]) + + expect(first.kind).toBe('claim') + expect(second.kind).toBe('claim') + if (first.kind !== 'claim' || second.kind !== 'claim') { + throw new Error('both direct claimants must return a persisted claim') + } + expect(first.claim.inFlight).toEqual(second.claim.inFlight) + expect(first.claim.inFlight.redeemRequestId).toBe('direct-uuid-1') + expect(uuidCount).toBe(1) + }) + + // Server-side dedup of a replayed (redeemRequestId, creditId) pair is + // external behavior a unit suite cannot exercise; the live check is tracked + // as verification debt in ARCHITECTURE.md. The replay tests pin the client + // half of the contract instead — the exact persisted identity is re-sent, + // which is what any server-side dedup keys on. + it('replays both IDs from a younger-than-TTL in-flight attempt', async () => { + const inFlight: ResetInFlight = { + redeemRequestId: 'persisted-request', + creditId: 'persisted-credit', + startedAt: Date.parse('2026-07-17T11:58:00.000Z'), + } + await seedResetState('main', { inFlight }) + const postBodies: ReturnType[] = [] + let usageCalls = 0 + const deps = redemptionDeps({ + fetchUsage: async () => { + usageCalls += 1 + return { primary: quotaWindow(100) } + }, + fetchImpl: fetchStub(async (input, init) => { + expect(input.toString().endsWith('/consume')).toBe(true) + postBodies.push(requestBody(init)) + return Response.json({ code: 'already_redeemed' }) + }), + }) + + await runResetCreditRedemption(deps, redemptionInput()) + + expect(postBodies).toEqual([ + { + redeem_request_id: 'persisted-request', + credit_id: 'persisted-credit', + }, + ]) + expect(usageCalls).toBe(0) + }) + + it('replays valid in-flight state even when fresh eligibility would fail', async () => { + const inFlight: ResetInFlight = { + redeemRequestId: 'rule-3a-request', + creditId: 'rule-3a-credit', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + } + await seedResetState('main', { inFlight }) + const postBodies: ReturnType[] = [] + let usageCalls = 0 + const deps = redemptionDeps({ + fetchUsage: async () => { + usageCalls += 1 + return { primary: quotaWindow(10) } + }, + fetchImpl: fetchStub(async (input, init) => { + expect(input.toString().endsWith('/consume')).toBe(true) + postBodies.push(requestBody(init)) + return Response.json({ code: 'already_redeemed' }) + }), + }) + + await runResetCreditRedemption(deps, redemptionInput()) + + expect(postBodies).toEqual([ + { + redeem_request_id: 'rule-3a-request', + credit_id: 'rule-3a-credit', + }, + ]) + expect(usageCalls).toBe(0) + }) + + it('refuses confirm for an expired unreconciled pair without a wire call', async () => { + const inFlight: ResetInFlight = { + redeemRequestId: 'expired-request', + creditId: 'expired-credit', + startedAt: Date.parse('2026-07-17T11:54:59.999Z'), + } + await seedResetState('main', { inFlight }) + let wireCalls = 0 + const deps = redemptionDeps({ + fetchImpl: fetchStub(async () => { + wireCalls += 1 + return Response.json({ code: 'reset' }) + }), + }) + + await expect( + runResetCreditRedemption(deps, redemptionInput()), + ).rejects.toMatchObject({ kind: 'expired_unreconciled' }) + expect(wireCalls).toBe(0) + expect(await persistedResetState()).toEqual({ inFlight }) + }) + + it('retries an expired unreconciled pair with its exact persisted identifiers', async () => { + const inFlight: ResetInFlight = { + redeemRequestId: 'expired-request', + creditId: 'expired-credit', + startedAt: Date.parse('2026-07-17T11:54:59.999Z'), + } + await seedResetState('main', { inFlight }) + const postBodies: ReturnType[] = [] + const deps = redemptionDeps({ + fetchImpl: fetchStub(async (input, init) => { + expect(input.toString().endsWith('/consume')).toBe(true) + postBodies.push(requestBody(init)) + return Response.json({ code: 'already_redeemed' }) + }), + }) + + await runResetCreditRedemption(deps, redemptionInput('main', true)) + + expect(postBodies).toEqual([ + { + redeem_request_id: 'expired-request', + credit_id: 'expired-credit', + }, + ]) + }) + + it('reopens the fresh path after an expired retry reconciles to no_credit', async () => { + await seedResetState('main', { + inFlight: { + redeemRequestId: 'expired-request', + creditId: 'expired-credit', + startedAt: Date.parse('2026-07-17T11:54:59.999Z'), + }, + }) + const postBodies: ReturnType[] = [] + const deps = redemptionDeps({ + randomUUID: () => 'fresh-request', + fetchImpl: fetchStub(async (input, init) => { + if (!input.toString().endsWith('/consume')) return listResponse() + postBodies.push(requestBody(init)) + return Response.json({ + code: postBodies.length === 1 ? 'no_credit' : 'reset', + }) + }), + }) + + await runResetCreditRedemption(deps, redemptionInput('main', true)) + expect((await persistedResetState())?.inFlight).toBeUndefined() + await runResetCreditRedemption(deps, redemptionInput()) + + expect(postBodies).toEqual([ + { + redeem_request_id: 'expired-request', + credit_id: 'expired-credit', + }, + { redeem_request_id: 'fresh-request', credit_id: 'credit-1' }, + ]) + }) + + it('blocks an active cooldown without a consume request', async () => { + await seedResetState('main', { + cooldownUntil: Date.parse('2026-07-17T12:00:30.000Z'), + }) + let fetchCalls = 0 + const deps = redemptionDeps({ + fetchImpl: fetchStub(async () => { + fetchCalls += 1 + return Response.json({ code: 'reset' }) + }), + }) + + await expect( + runResetCreditRedemption(deps, redemptionInput()), + ).rejects.toMatchObject({ + name: 'ResetRedemptionError', + kind: 'cooldown_active', + }) + expect(fetchCalls).toBe(0) + }) + + it('uses a read-only inspection before the atomic claim and finalize writes', async () => { + let mutationCount = 0 + const deps = redemptionDeps() + deps.mutateAccountsFn = async (mutator, configPath) => { + mutationCount += 1 + return mutateAccounts(mutator, configPath) + } + + await runResetCreditRedemption(deps, redemptionInput()) + + expect(mutationCount).toBe(2) + }) + + for (const code of [ + 'reset', + 'already_redeemed', + 'nothing_to_reset', + 'no_credit', + ] as const) { + it(`${code} clears in-flight state and applies only a spend cooldown`, async () => { + await seedResetState('main', { + inFlight: { + redeemRequestId: `${code}-request`, + creditId: `${code}-credit`, + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + }, + }) + const deps = redemptionDeps({ + fetchImpl: fetchStub(async () => Response.json({ code })), + }) + + const result = await runResetCreditRedemption(deps, redemptionInput()) + + const expected = { + lastOutcome: { + code, + at: Date.parse('2026-07-17T12:00:00.000Z'), + }, + ...(code === 'reset' || code === 'already_redeemed' + ? { cooldownUntil: Date.parse('2026-07-17T12:01:00.000Z') } + : {}), + } + expect(await persistedResetState()).toEqual(expected) + if (code === 'nothing_to_reset' || code === 'no_credit') { + expect(result.retrySafety).toContain('No cooldown was set') + expect(result.retrySafety).toContain('gated by fresh preconditions') + expect(result.retrySafety).not.toContain('cooldown blocks') + } + }) + } + + it('preserves the exact in-flight object after an ambiguous outcome', async () => { + const inFlight: ResetInFlight = { + redeemRequestId: 'ambiguous-request', + creditId: 'ambiguous-credit', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + } + await seedResetState('main', { inFlight }) + const deps = redemptionDeps({ + fetchImpl: fetchStub(async () => new Response('{bad-json')), + }) + + await runResetCreditRedemption(deps, redemptionInput()) + + expect(await persistedResetState()).toEqual({ inFlight }) + }) + + it('preserves the exact in-flight object and skips cooldown on http_error', async () => { + const inFlight: ResetInFlight = { + redeemRequestId: 'http-request', + creditId: 'http-credit', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + } + await seedResetState('main', { inFlight }) + const deps = redemptionDeps({ + fetchImpl: fetchStub(async () => + Response.json({ error: 'down' }, { status: 503 }), + ), + }) + + await runResetCreditRedemption(deps, redemptionInput()) + + expect(await persistedResetState()).toEqual({ inFlight }) + }) + + it('times out a hanging consume without allowing a second UUID before TTL', async () => { + jest.useFakeTimers() + let current: AccountStorage = { version: 1, accounts: [] } + const mutateAccountsFn: typeof mutateAccounts = async (mutator) => { + current = mutator(current) ?? current + return current + } + let uuidCount = 0 + const postBodies: ReturnType[] = [] + let postCount = 0 + const fetchImpl = fetchStub(async (input, init) => { + if (!input.toString().endsWith('/consume')) return listResponse() + postBodies.push(requestBody(init)) + postCount += 1 + if (postCount === 2) return Response.json({ code: 'already_redeemed' }) + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(init.signal?.reason), + ) + }) + }) + const deps = redemptionDeps({ + fetchImpl, + randomUUID: () => `hang-uuid-${++uuidCount}`, + }) + deps.mutateAccountsFn = mutateAccountsFn + deps.loadAccountsFn = async () => current + + const first = runResetCreditRedemption(deps, redemptionInput()) + for (let turns = 0; turns < 20 && postCount === 0; turns += 1) { + await Promise.resolve() + } + expect(postCount).toBe(1) + jest.advanceTimersByTime(60_000) + const firstResult = await first + expect(firstResult.outcome.kind).toBe('ambiguous') + + await runResetCreditRedemption(deps, redemptionInput()) + + expect(uuidCount).toBe(1) + expect(postBodies).toHaveLength(2) + expect(new Set(postBodies.map((body) => body.redeem_request_id))).toEqual( + new Set(['hang-uuid-1']), + ) + }) + + it('resolves a corrupt in-flight record locally without list or consume', async () => { + let current = { + version: 1, + accounts: [], + reset: { + main: { + inFlight: { + redeemRequestId: 'present', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + }, + }, + }, + } as unknown as AccountStorage + const mutateAccountsFn: typeof mutateAccounts = async (mutator) => { + current = mutator(current) ?? current + return current + } + let wireCalls = 0 + const deps = { + ...redemptionDeps(), + mutateAccountsFn, + loadAccountsFn: async () => current, + fetchImpl: fetchStub(async () => { + wireCalls += 1 + return Response.json({ code: 'reset' }) + }), + } + + const result = await runResetCreditRedemption(deps, redemptionInput()) + + expect(wireCalls).toBe(0) + expect(result.outcome.kind).toBe('ambiguous_local') + expect(current.reset?.main).toEqual({ + lastOutcome: { + code: 'ambiguous_local', + at: Date.parse('2026-07-17T12:00:00.000Z'), + }, + }) + }) + + it('round-trips a partial in-flight record and preserves the previous outcome when resolving it locally', async () => { + writeFileSync( + redemptionConfigPath, + JSON.stringify({ + version: 1, + accounts: [], + reset: { + main: { + inFlight: { + redeemRequestId: 'partial-request', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + }, + lastOutcome: { code: 'reset', at: 123 }, + }, + }, + }), + ) + expect( + (await loadAccounts(redemptionConfigPath))?.reset?.main?.inFlight, + ).toEqual({ + redeemRequestId: 'partial-request', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + }) + let wireCalls = 0 + const result = await runResetCreditRedemption( + redemptionDeps({ + fetchImpl: fetchStub(async () => { + wireCalls += 1 + return Response.json({ code: 'reset' }) + }), + }), + redemptionInput(), + ) + + expect(wireCalls).toBe(0) + expect(result.outcome.kind).toBe('ambiguous_local') + expect(await persistedResetState()).toEqual({ + lastOutcome: { + code: 'ambiguous_local', + at: Date.parse('2026-07-17T12:00:00.000Z'), + previousOutcome: { code: 'reset', at: 123 }, + }, + }) + }) + + it('returns a known terminal outcome when the finalize state write fails', async () => { + const deps = redemptionDeps() + deps.mutateAccountsFn = async (mutator, configPath) => { + const state = (await loadAccounts(configPath))?.reset?.main + if (state?.inFlight) throw new Error('finalize write failed') + return mutateAccounts(mutator, configPath) + } + + const result = await runResetCreditRedemption(deps, redemptionInput()) + + expect(result.outcome.kind).toBe('reset') + expect(result.finalizeStateWriteFailed).toBe(true) + expect((await persistedResetState())?.inFlight).toMatchObject({ + redeemRequestId: 'uuid-new', + creditId: 'credit-1', + }) + }) + + it('refuses prototype-sensitive account keys without mutating reset state', async () => { + let mutationCount = 0 + const deps = redemptionDeps() + deps.mutateAccountsFn = async (mutator, configPath) => { + mutationCount += 1 + return mutateAccounts(mutator, configPath) + } + + const decision = await claimResetAttempt(deps, '__proto__', [credit()]) + + expect(decision.kind).toBe('fresh') + expect(mutationCount).toBe(0) + expect((await loadAccounts(redemptionConfigPath))?.reset).toBeUndefined() + }) + + it('observes persisted in-flight state from a new coordinator invocation', async () => { + const firstBodies: ReturnType[] = [] + await runResetCreditRedemption( + redemptionDeps({ + randomUUID: () => 'persisted-across-invocations', + fetchImpl: fetchStub(async (input, init) => { + if (!input.toString().endsWith('/consume')) return listResponse() + firstBodies.push(requestBody(init)) + return new Response('{ambiguous') + }), + }), + redemptionInput(), + ) + + const secondBodies: ReturnType[] = [] + await runResetCreditRedemption( + redemptionDeps({ + randomUUID: () => 'must-not-be-used', + fetchImpl: fetchStub(async (_input, init) => { + secondBodies.push(requestBody(init)) + return Response.json({ code: 'already_redeemed' }) + }), + }), + redemptionInput('main', true), + ) + + expect(firstBodies).toEqual(secondBodies) + expect(secondBodies[0]?.redeem_request_id).toBe( + 'persisted-across-invocations', + ) + }) + + it('isolates cooldown and transitions by exact account key', async () => { + const mainState = { + inFlight: { + redeemRequestId: 'main-request', + creditId: 'main-credit', + startedAt: Date.parse('2026-07-17T11:59:00.000Z'), + }, + cooldownUntil: Date.parse('2026-07-17T12:00:30.000Z'), + } + await seedResetState('main', mainState) + + await runResetCreditRedemption( + redemptionDeps({ randomUUID: () => 'fallback-request' }), + redemptionInput('fallback-a'), + ) + + expect(await persistedResetState('main')).toEqual(mainState) + expect(await persistedResetState('fallback-a')).toEqual({ + lastOutcome: { + code: 'reset', + at: Date.parse('2026-07-17T12:00:00.000Z'), + }, + cooldownUntil: Date.parse('2026-07-17T12:01:00.000Z'), + }) + }) + + it('rejects retry without valid in-flight state before any wire call', async () => { + let wireCalls = 0 + const deps = redemptionDeps({ + fetchImpl: fetchStub(async () => { + wireCalls += 1 + return Response.json({ code: 'reset' }) + }), + }) + + const rejection = runResetCreditRedemption( + deps, + redemptionInput('main', true), + ) + await expect(rejection).rejects.toBeInstanceOf(ResetRedemptionError) + await expect(rejection).rejects.toMatchObject({ + kind: 'retry_without_inflight', + }) + expect(wireCalls).toBe(0) + }) + + it('replays the winner when a second confirm loses the claim race', async () => { + const pendingPosts: Deferred[] = [] + const postBodies: ReturnType[] = [] + let uuidCount = 0 + const deps = redemptionDeps({ + randomUUID: () => `race-uuid-${++uuidCount}`, + fetchImpl: fetchStub(async (input, init) => { + if (!input.toString().endsWith('/consume')) return listResponse() + postBodies.push(requestBody(init)) + const pending = deferred() + pendingPosts.push(pending) + return pending.promise + }), + }) + + const winner = runResetCreditRedemption(deps, redemptionInput()) + const loser = runResetCreditRedemption(deps, redemptionInput()) + await waitFor(() => postBodies.length === 2) + + expect(uuidCount).toBe(1) + expect(postBodies[1]).toEqual(postBodies[0]) + + for (const pending of pendingPosts) { + pending.resolve(Response.json({ code: 'reset' })) + } + await Promise.all([winner, loser]) + }) +}) diff --git a/packages/opencode/src/tests/rpc-client.test.ts b/packages/opencode/src/tests/rpc-client.test.ts new file mode 100644 index 0000000..99314c2 --- /dev/null +++ b/packages/opencode/src/tests/rpc-client.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRpcClient, DEFAULT_RPC_TIMEOUT_MS } from '../rpc/rpc-client' +import { startRpcServer } from '../rpc/rpc-server' + +let stop: (() => Promise) | null = null +let dir: string + +afterEach(async () => { + await stop?.() + stop = null + if (dir) await rm(dir, { recursive: true, force: true }) +}) + +describe('rpc-client', () => { + test('keeps the default call timeout at two seconds', () => { + expect(DEFAULT_RPC_TIMEOUT_MS).toBe(2_000) + }) + + test('apply honors a per-call timeout override', async () => { + dir = await mkdtemp(join(tmpdir(), 'oa-rpcclient-')) + const server = await startRpcServer({ + dir, + timeoutMs: 2_000, + drain: () => [], + apply: async () => { + await Bun.sleep(300) + return { text: 'completed', knobs: { stage: 'result' } } + }, + }) + stop = server.stop + const client = createRpcClient(dir, process.pid) + const request = { + command: 'openai-reset', + arguments: 'confirm account id', + } as const + + expect(await client.apply(request, 100)).toEqual({ + text: 'apply failed', + knobs: {}, + }) + expect(await client.apply(request, 1_000)).toEqual({ + text: 'completed', + knobs: { stage: 'result' }, + }) + }) +}) diff --git a/packages/opencode/src/tests/rpc-server.test.ts b/packages/opencode/src/tests/rpc-server.test.ts index feaa387..94ae254 100644 --- a/packages/opencode/src/tests/rpc-server.test.ts +++ b/packages/opencode/src/tests/rpc-server.test.ts @@ -181,4 +181,32 @@ describe('rpc-server', () => { await expect(reqPromise).resolves.toBeUndefined() }) + + test('default timeout lets a slow apply handler respond before the socket is destroyed', async () => { + dir = await mkdtemp(join(tmpdir(), 'oa-rpcsrv-')) + // No explicit timeoutMs — the server default is the safety net. A reset + // apply takes a few seconds (network call to Codex); the default must not + // destroy the socket before the handler responds. + const server = await startRpcServer({ + dir, + drain: drainNotifications, + apply: async () => { + await Bun.sleep(3_000) + return { text: 'slow-ok', knobs: {} } + }, + }) + stop = server.stop + + const res = await fetch(`http://127.0.0.1:${server.port}/rpc/apply`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${server.token}`, + }, + body: JSON.stringify({ command: 'openai-reset', arguments: '' }), + signal: AbortSignal.timeout(15_000), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ text: 'slow-ok', knobs: {} }) + }) }) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 6b7be0a..f4d626a 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -897,7 +897,11 @@ const tui: TuiPlugin = async (api) => { openCommandDialog( api, message.payload, - (command, args) => rpcClient.apply({ command, arguments: args }), + (command, args) => + rpcClient.apply( + { command, arguments: args }, + command === 'openai-reset' ? 90_000 : undefined, + ), sessionId, ) } diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index b922b6e..fa33e94 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPluginApi } from '@opencode-ai/plugin/tui' +import { createLogger } from '../logger' import type { OpenDialogPayload } from '../rpc/protocol.js' import { type AccountQuota, @@ -8,8 +9,11 @@ import { resolveSessionSidebarRouting, type SidebarState, } from '../sidebar-state.js' +import { errorMessage } from '../util/error' import { openUrl } from '../util/open-url' +const log = createLogger('rpc-tui') + type ApplyFn = ( command: OpenDialogPayload['command'], args: string, @@ -94,6 +98,242 @@ export function buildCachekeepDialogOptions(payload: OpenDialogPayload) { ] } +type ResetAccountKnob = { + accountKey: string + label: string + usedPercent?: number + availableCount?: number + applicableAvailableCount?: number + eligible: boolean + reason?: string + selectedCreditId?: string + selectedCreditExpiresAt?: string +} + +type ResetPreviewKnob = ResetAccountKnob & { + chatgptAccountId?: string + resetTime?: string +} + +type ResetDialogOption = { + title: string + value: string + description: string + disabled?: boolean +} + +function resetAccountOptions(payload: OpenDialogPayload): ResetDialogOption[] { + const accounts = Array.isArray(payload.knobs.accounts) + ? (payload.knobs.accounts as ResetAccountKnob[]) + : [] + return [ + ...accounts.map((account) => { + const used = + account.usedPercent === undefined + ? 'quota unavailable' + : `${account.usedPercent}%` + const counts = `${account.applicableAvailableCount ?? 0}/${account.availableCount ?? 0}` + const status = account.eligible + ? 'eligible' + : (account.reason ?? 'unavailable') + const details = `${used} · ${counts}` + const shortDate = + account.selectedCreditExpiresAt?.slice(0, 10) ?? 'unknown' + return { + title: `${account.label} — ${status}`, + value: `account:${account.accountKey}`, + description: account.eligible + ? `${details} · exp ${shortDate}` + : details, + } + }), + { + title: 'Refresh', + value: 'refresh', + description: 'Re-check account quota and reset credits', + }, + { + title: 'Close', + value: 'close', + description: 'Close this dialog', + }, + ] +} + +function resetResultOptions(payload: OpenDialogPayload): ResetDialogOption[] { + const accountKey = + typeof payload.knobs.accountKey === 'string' + ? payload.knobs.accountKey + : undefined + const chatgptAccountId = + typeof payload.knobs.chatgptAccountId === 'string' + ? payload.knobs.chatgptAccountId + : undefined + const code = typeof payload.knobs.code === 'string' ? payload.knobs.code : '' + const retrySafe = + (code === 'ambiguous' || + code === 'http_error' || + code === 'expired_unreconciled' || + payload.knobs.stateWriteFailed === true) && + typeof payload.knobs.retryGuidance === 'string' && + accountKey !== undefined && + chatgptAccountId !== undefined + return [ + { + title: payload.text, + value: 'result', + description: 'Command result', + }, + ...(retrySafe + ? [ + { + title: 'Retry', + value: 'retry', + description: + 'Reuse the bound request and credit identifiers for this uncertain outcome', + }, + ] + : []), + { + title: 'Refresh', + value: 'refresh', + description: 'Re-check account quota and reset credits', + }, + { + title: 'Close', + value: 'close', + description: 'Close this dialog', + }, + ] +} + +function openResetDialog( + api: TuiPluginApi, + payload: OpenDialogPayload, + apply: ApplyFn, +) { + let applyGeneration = 0 + const render = (state: OpenDialogPayload) => { + const applyAndRender = (args: string) => { + const generation = ++applyGeneration + log.debug('reset dialog apply', { args, generation }) + void apply('openai-reset', args) + .then((result) => { + log.debug('reset dialog apply result', { + generation, + currentGeneration: applyGeneration, + stage: result.knobs?.stage, + accountRows: Array.isArray(result.knobs?.accounts) + ? (result.knobs.accounts as unknown[]).length + : undefined, + }) + if (generation !== applyGeneration) return + if (typeof result.knobs?.stage !== 'string') { + render({ + command: 'openai-reset', + text: 'The reset request is still completing or returned no result. Choose Refresh to check the current state — do NOT re-confirm.', + knobs: { stage: 'result', code: 'unknown' }, + }) + return + } + render({ + command: 'openai-reset', + text: result.text, + knobs: result.knobs, + }) + }) + .catch((error) => { + if (generation !== applyGeneration) return + log.warn('reset dialog apply rejected', { + error: errorMessage(error), + }) + render({ + command: 'openai-reset', + text: 'Command failed — the plugin could not complete the request; the plugin log has details.', + knobs: { stage: 'result', code: 'error' }, + }) + }) + } + const stage = state.knobs.stage + log.debug('reset dialog render', { + stage, + accountRows: Array.isArray(state.knobs.accounts) + ? (state.knobs.accounts as unknown[]).length + : undefined, + knobKeys: Object.keys(state.knobs), + }) + api.ui.dialog.setSize('xlarge') + + if (stage === 'confirm') { + const preview = state.knobs.preview as ResetPreviewKnob | undefined + const accountKey = preview?.accountKey + const chatgptAccountId = preview?.chatgptAccountId + const applicableCount = preview?.applicableAvailableCount ?? 0 + const DialogConfirm = api.ui.DialogConfirm + api.ui.dialog.replace(() => ( + { + if (!accountKey || !chatgptAccountId) return + applyAndRender( + `confirm ${encodeURIComponent(accountKey)} ${encodeURIComponent(chatgptAccountId)}`, + ) + }} + onCancel={() => applyAndRender('refresh')} + /> + )) + return + } + + const DialogSelect = api.ui.DialogSelect + const options = + stage === 'result' + ? resetResultOptions(state) + : resetAccountOptions(state) + api.ui.dialog.replace(() => ( + { + if (option.disabled) return + if (option.value === 'result') return + if (option.value === 'close') { + applyGeneration += 1 + api.ui.dialog.clear() + return + } + if (option.value === 'refresh') { + applyAndRender('refresh') + return + } + if (option.value === 'retry') { + const accountKey = state.knobs.accountKey + const chatgptAccountId = state.knobs.chatgptAccountId + if ( + typeof accountKey !== 'string' || + typeof chatgptAccountId !== 'string' + ) { + return + } + applyAndRender( + `retry ${encodeURIComponent(accountKey)} ${encodeURIComponent(chatgptAccountId)}`, + ) + return + } + if (!option.value.startsWith('account:')) return + const accountKey = option.value.slice('account:'.length) + applyAndRender(`select ${encodeURIComponent(accountKey)}`) + }} + /> + )) + } + + render(payload) +} + function showText(api: TuiPluginApi, text: string) { api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( @@ -147,6 +387,11 @@ export function openCommandDialog( apply: ApplyFn, sessionId?: string, ) { + if (payload.command === 'openai-reset') { + openResetDialog(api, payload, apply) + return + } + if (payload.command === 'openai-routing') { const current = (payload.knobs.mode as string) ?? 'main-first' const DialogSelect = api.ui.DialogSelect