From cabc6ebfe926db4788e74745a48a595cd72070aa Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:02:26 +0200 Subject: [PATCH] feat: add /openai-reset command to spend rate-limit reset credits An account with reset credits had no way to spend them: the quota sat exhausted until its window rolled over, while credits that exist to short-circuit exactly that wait went unused. /openai-reset lists each account with its eligibility, and consumes a credit against the one chosen. Redemption carries a per-attempt UUID as the idempotency key, so a retry after a timeout resolves to the original redemption rather than spending a second credit - verified live against the API, where a replayed request returned already_redeemed with the first attempt's timestamp and an unchanged credit count. Attempt state is claimed under the existing account file lock, so two processes cannot redeem concurrently. Credits are spent oldest-first. They expire, so preferring the credit nearest expiry wastes the least. The RPC server's two timeouts are deliberately different values. requestTimeout and headersTimeout bound how long a client may take to DELIVER a request and never bound handler execution - a 500ms receipt bound still returns 200 for a three-second handler. The socket inactivity timer is the one that can destroy a socket out from under a working handler, so it must outlast the slowest legitimate apply: a redemption is bounded by a 60s consume call, and a 2s timer there would kill the connection 58 seconds in regardless of what deadline the client passed. Collapsing the three into one value reintroduces one of two bugs, so the split carries a comment saying so. --- ARCHITECTURE.md | 21 +- README.md | 3 +- STRUCTURE.md | 4 +- packages/opencode/README.md | 3 +- packages/opencode/scripts/build-tui.ts | 1 + packages/opencode/src/commands.ts | 573 +++++++ packages/opencode/src/core/accounts.ts | 99 +- .../opencode/src/core/refresh-all-quota.ts | 5 +- packages/opencode/src/core/reset-credits.ts | 807 +++++++++ packages/opencode/src/index.ts | 240 ++- packages/opencode/src/quota-normalize.ts | 7 + packages/opencode/src/rpc/protocol.ts | 1 + packages/opencode/src/rpc/rpc-client.ts | 10 +- packages/opencode/src/rpc/rpc-server.ts | 28 +- .../opencode/src/tests/accounts-store.test.ts | 181 ++ .../src/tests/command-dialogs.test.ts | 580 +++++++ .../src/tests/command-registration.test.ts | 2 + packages/opencode/src/tests/commands.test.ts | 1489 ++++++++++++++++- .../src/tests/quota-normalize.test.ts | 6 +- .../opencode/src/tests/quota-push.test.ts | 59 + .../src/tests/refresh-all-quota.test.ts | 171 ++ .../opencode/src/tests/reset-credits.test.ts | 1303 +++++++++++++++ .../opencode/src/tests/rpc-client.test.ts | 43 +- .../opencode/src/tests/rpc-server.test.ts | 34 +- packages/opencode/src/tui.tsx | 5 +- packages/opencode/src/tui/command-dialogs.tsx | 245 +++ 26 files changed, 5880 insertions(+), 40 deletions(-) create mode 100644 packages/opencode/src/core/reset-credits.ts create mode 100644 packages/opencode/src/tests/reset-credits.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6536222..d845d6e 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`):** @@ -165,6 +165,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. + +Server-side deduplication of a repeated `redeem_request_id` is verified live (2026-07-23): replaying a consumed `(redeem_request_id, credit_id)` returns `already_redeemed` with `windows_reset: 0`, the account's available-credit count does not decrement a second time, and the response carries the original redemption's `redeemed_at`. Replaying a consumed identifier is therefore safe — the server dedupes on it and never spends another credit — which is the invariant the retry path relies on. + **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. @@ -195,6 +207,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` diff --git a/README.md b/README.md index f5dcf6a..ed8d836 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,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 | | --- | --- | --- | @@ -145,6 +145,7 @@ All commands open an interactive control surface in the TUI (a selectable dialog | `/openai-routing` | `main-first` · `fallback-first` · `sticky-balanced` · `reset` | Set account preference order, enable sticky balanced routing, or clear the current session pin. | | `/openai-killswitch` | `on` · `off` · `set :<5h>,<1w> ...` | Hard-block accounts below per-window quota thresholds. | | `/openai-cachekeep` | `on` · `off` · `subagents on` · `subagents off` · `sustain on` · `sustain off` | Idle prompt-cache keep-warm; optional subagent mode and main-only idle-pruning bypass. | +| `/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 4976c96..96ee79e 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -138,6 +138,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, subagent warm caps, and main-only sustain that bypasses idle pruning but not memory/LRU 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. @@ -155,6 +156,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`. @@ -172,7 +174,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). diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 6f88fbf..9603d47 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` · `sticky-balanced` · `reset` | Routing order, sticky balanced session pins, or clear the current pin. | | `/openai-killswitch` | `on` · `off` · `set :<5h>,<1w> ...` | Hard-block accounts below quota thresholds. | | `/openai-cachekeep` | `on` · `off` · `subagents on` · `subagents off` · `sustain on` · `sustain off` | Idle prompt-cache keep-warm; sustain bypasses only main idle pruning. | +| `/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 0bb9e8c..2f7a7f0 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', ] // --------------------------------------------------------------------------- @@ -84,6 +98,20 @@ export interface CommandContext { clearStickyRouting?: (sessionId: string) => Promise /** Resolves the current session's usable sticky account, if one exists. */ getStickyRouting?: (sessionId: string) => Promise + 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') @@ -1015,6 +1043,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 // --------------------------------------------------------------------------- @@ -1039,6 +1610,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 a6fca4a..042e1f9 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?: { @@ -206,6 +230,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 cc84132..d0ac4e0 100644 --- a/packages/opencode/src/core/refresh-all-quota.ts +++ b/packages/opencode/src/core/refresh-all-quota.ts @@ -70,6 +70,10 @@ export interface RefreshAllQuotaOptions { accountKey?: string } +export interface RefreshAllQuotaOptions { + accountKey?: string +} + export interface RefreshAllQuotaResult { account: string ok: boolean @@ -128,7 +132,6 @@ export async function refreshAllQuota( .loadAccounts(deps.configPath) .catch(() => undefined) const liveMainAccountId = storage?.mainAccountId ?? deps.storageMainAccountId - if (!options.accountKey || options.accountKey === 'main') { // --- MAIN --- try { 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 ed3c1f3..dff9af0 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 { @@ -188,6 +190,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 } @@ -460,24 +629,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 } // Returns the window unchanged when it already carries a usable checkedAt, or @@ -1542,6 +1713,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 @@ -1583,6 +1764,35 @@ export async function CodexAuthPlugin( isOAuthAccountFn: isOAuthAccount, whamFn: whamUsageFn, }), + refreshResetTargetQuota: async (accountKey) => { + const results = await refreshAllQuota( + { + getAuth, + codexRefreshFn, + refreshMainWithLease, + 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 @@ -3221,6 +3431,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 c78d4a0..8e030bb 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 9bbb9a5..87e946e 100644 --- a/packages/opencode/src/rpc/rpc-server.ts +++ b/packages/opencode/src/rpc/rpc-server.ts @@ -25,7 +25,10 @@ export interface RpcServerOptions { sweepRoot?: string drain: typeof drainNotifications apply: (request: ApplyRequest) => Promise + // Bounds handler execution via the socket inactivity timer. timeoutMs?: number + // Bounds request delivery only (requestTimeout/headersTimeout). + receiptTimeoutMs?: number } function readBody(req: IncomingMessage): Promise { @@ -57,15 +60,32 @@ export async function startRpcServer( options: RpcServerOptions, ): Promise { const token = randomBytes(32).toString('hex') - const timeoutMs = options.timeoutMs ?? 2000 + // Two different bounds, deliberately not one value. + // + // requestTimeout/headersTimeout bound how long a client may take to DELIVER + // a request; they do not bound handler execution. A receipt bound of 500ms + // still returns 200 for a 3s handler. Over loopback with a 1 MiB body cap, + // seconds is already generous. + // + // The inactivity timer is the one that can kill a working handler: it + // destroys the socket out from under it. It must therefore outlast the + // slowest legitimate apply — a reset redemption is bounded by a 60s consume + // call — or the response is lost server-side no matter what deadline the + // client passed. Fast commands respond in milliseconds regardless. + // + // Collapsing these back into a single value reintroduces one of two bugs: + // a receipt bound that aborts a slow reset, or a socket timer that leaves + // every endpoint holding a dead connection for 90s. + const handlerTimeoutMs = options.timeoutMs ?? 90_000 + const receiptTimeoutMs = options.receiptTimeoutMs ?? 2_000 const server = createServer((req, res) => { - req.setTimeout(timeoutMs, () => { + req.setTimeout(handlerTimeoutMs, () => { req.socket.destroy() }) void dispatch(req, res) }) - server.requestTimeout = timeoutMs - server.headersTimeout = timeoutMs + server.requestTimeout = receiptTimeoutMs + server.headersTimeout = receiptTimeoutMs async function dispatch(req: IncomingMessage, res: ServerResponse) { const json = (status: number, value: unknown) => { diff --git a/packages/opencode/src/tests/accounts-store.test.ts b/packages/opencode/src/tests/accounts-store.test.ts index 09de72c..54014cd 100644 --- a/packages/opencode/src/tests/accounts-store.test.ts +++ b/packages/opencode/src/tests/accounts-store.test.ts @@ -287,6 +287,7 @@ describe('accounts store', () => { windowMinutes: 10_080, }, resetCreditsAvailable: 4, + resetCreditsApplicable: 3, }, }) @@ -303,6 +304,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 () => { @@ -332,6 +334,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 295181c..7892f8a 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, @@ -139,6 +143,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: {} })) @@ -201,6 +386,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 36f549d..87017c9 100644 --- a/packages/opencode/src/tests/commands.test.ts +++ b/packages/opencode/src/tests/commands.test.ts @@ -19,7 +19,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 @@ -30,9 +30,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 { @@ -71,6 +82,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: { @@ -79,6 +99,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 @@ -1313,6 +1534,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 509dc7b..5eacbd5 100644 --- a/packages/opencode/src/tests/quota-push.test.ts +++ b/packages/opencode/src/tests/quota-push.test.ts @@ -440,6 +440,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 bfc4c32..147e6b2 100644 --- a/packages/opencode/src/tests/refresh-all-quota.test.ts +++ b/packages/opencode/src/tests/refresh-all-quota.test.ts @@ -146,6 +146,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 index d345a11..b831644 100644 --- a/packages/opencode/src/tests/rpc-client.test.ts +++ b/packages/opencode/src/tests/rpc-client.test.ts @@ -1,19 +1,24 @@ -import { afterEach, expect, test } from 'bun:test' +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 { drainNotifications } from '../rpc/notifications' -import { createRpcClient } from '../rpc/rpc-client' +import { createRpcClient, DEFAULT_RPC_TIMEOUT_MS } from '../rpc/rpc-client' import { type RpcServerHandle, startRpcServer } from '../rpc/rpc-server' let dir: string | undefined let server: RpcServerHandle | undefined +let stop: (() => Promise) | null = null afterEach(async () => { if (server) { await server.stop() server = undefined } + if (stop) { + await stop() + stop = null + } if (dir) { await rm(dir, { recursive: true, force: true }) dir = undefined @@ -51,3 +56,37 @@ test('RPC client preserves sessionId through the server apply callback', async ( 'session-b', ]) }) + +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 localServer = await startRpcServer({ + dir, + timeoutMs: 2_000, + drain: () => [], + apply: async () => { + await Bun.sleep(300) + return { text: 'completed', knobs: { stage: 'result' } } + }, + }) + stop = localServer.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 92a4ec4..51fb081 100644 --- a/packages/opencode/src/tests/rpc-server.test.ts +++ b/packages/opencode/src/tests/rpc-server.test.ts @@ -163,12 +163,16 @@ describe('rpc-server', () => { expect(rejected).toBe(true) }) - test('enforces request timeout', async () => { + test('destroys a socket that stalls part-way through sending a request', async () => { dir = await mkdtemp(join(tmpdir(), 'oa-rpcsrv-')) const server = await startRpcServer({ dir, drain: drainNotifications, apply: async () => ({ text: 'ok', knobs: {} }), + // The socket inactivity timer is what reclaims a stalled connection. + // requestTimeout cannot: Node only samples it on the + // connectionsCheckingInterval tick (30s by default), so it is a coarse + // ceiling rather than the mechanism that frees this socket. timeoutMs: 100, }) stop = server.stop @@ -291,4 +295,32 @@ describe('rpc-server', () => { expect((await stat(dir)).mode & 0o777).toBe(0o755) }) + + 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 1a41070..f61cccc 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -935,7 +935,10 @@ const tui: TuiPlugin = async (api) => { api, message.payload, (command, args) => - rpcClient.apply(buildApplyRequest(command, args, sessionId)), + rpcClient.apply( + buildApplyRequest(command, args, sessionId), + 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 64fe210..88d6a32 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, @@ -104,6 +108,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(() => ( @@ -157,6 +397,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