From 212e05dbbb80acd0d3ce88bf3de7c34a942d556a Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 12 Jun 2026 05:19:24 +0200 Subject: [PATCH] feat: added Codex saved reset-credit redemption flow to usage tooling - Added Codex reset-credit models, endpoints, and redemption methods. - Added reset-credit usage data and cache invalidation for successful redemption. - Added `/reset-usage` slash command and interactive account selector flow. - Added contract tests for reset-credit listing, fallback, and consume outcomes. --- packages/ai/CHANGELOG.md | 9 + packages/ai/src/auth-storage.ts | 101 ++++++++++ packages/ai/src/index.ts | 1 + packages/ai/src/usage.ts | 19 ++ packages/ai/src/usage/openai-codex-reset.ts | 174 ++++++++++++++++++ packages/ai/src/usage/openai-codex.ts | 19 +- packages/ai/test/openai-codex-reset.test.ts | 121 ++++++++++++ packages/ai/test/openai-codex-usage.test.ts | 23 +++ packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/cli/usage-cli.ts | 2 + .../src/config/settings-schema.ts | 6 +- .../modes/components/reset-usage-selector.ts | 155 ++++++++++++++++ .../modes/controllers/command-controller.ts | 23 +++ .../modes/controllers/selector-controller.ts | 66 +++++++ .../src/modes/interactive-mode.ts | 4 + packages/coding-agent/src/modes/types.ts | 1 + .../src/modes/utils/context-usage.ts | 4 +- .../coding-agent/src/session/agent-session.ts | 16 ++ .../coding-agent/src/session/auth-storage.ts | 2 + .../src/slash-commands/builtin-registry.ts | 60 ++++++ .../src/slash-commands/helpers/reset-usage.ts | 71 +++++++ .../slash-commands/helpers/usage-report.ts | 12 ++ .../test/modes/context-usage.test.ts | 9 +- .../test/settings-manager.test.ts | 2 +- .../test/snapcompact-inline.test.ts | 5 +- 25 files changed, 902 insertions(+), 6 deletions(-) create mode 100644 packages/ai/src/usage/openai-codex-reset.ts create mode 100644 packages/ai/test/openai-codex-reset.test.ts create mode 100644 packages/coding-agent/src/modes/components/reset-usage-selector.ts create mode 100644 packages/coding-agent/src/slash-commands/helpers/reset-usage.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 73006f5ac..c0ea1b4bb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,10 +1,19 @@ # Changelog ## [Unreleased] + ### Added +- Added `AuthStorage.redeemResetCredit` to redeem stored OpenAI Codex saved rate-limit reset credits for a target account by `credentialId`, `accountId`, or `email` +- Added `listCodexResetCredits` and `consumeCodexResetCredit` exports for OpenAI Codex saved reset-credit listing and redemption +- Added `resetCredits` with `availableCount` to `UsageReport` so OpenAI Codex usage data now exposes redeemable rate-limit resets +- Added `openai-codex-reset` exports via package barrel for out-of-band tooling usage - Added a one-shot request-debug target that writes the next provider HTTP request JSON to an explicit path. +### Changed + +- Changed `AuthStorage.redeemResetCredit` to invalidate cached usage data after a successful redemption so the next usage report reflects the reset immediately + ### Fixed - Fixed one-shot request-debug path handling so an explicit request log target is consumed after the next request and no longer affects subsequent calls diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 5fbb4cc35..8bc708b85 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -35,6 +35,7 @@ import { githubCopilotUsageProvider } from "./usage/github-copilot"; import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity"; import { kimiUsageProvider } from "./usage/kimi"; import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex"; +import { type CodexResetConsumeCode, consumeCodexResetCredit, listCodexResetCredits } from "./usage/openai-codex-reset"; import { zaiUsageProvider } from "./usage/zai"; const USAGE_RANKING_METRIC_EPSILON = 1e-9; @@ -638,6 +639,33 @@ export interface InvalidateCredentialMatchingOptions { sessionId?: string; } +/** + * Identifies which stored account to redeem a saved rate-limit reset for. + * Any one field is enough; `credentialId` is the most precise. + */ +export interface ResetCreditTarget { + credentialId?: number; + accountId?: string; + email?: string; +} + +/** Outcome of {@link AuthStorage.redeemResetCredit}. */ +export interface ResetCreditRedeemOutcome { + /** `true` only when a reset was actually applied (`code === "reset"`). */ + ok: boolean; + /** + * Result code. Backend codes: `reset` (success), `already_redeemed`, + * `no_credit`, `nothing_to_reset`. Locally-synthesized: `no_account` + * (target not found), `account_unavailable` (token refresh failed), + * `http_` (unexpected HTTP). + */ + code: CodexResetConsumeCode; + accountId?: string; + email?: string; + /** The credit that was spent (when one was). */ + creditId?: string; +} + function isAbortSignalOption( value: InvalidateCredentialMatchingOptions | AbortSignal | undefined, ): value is AbortSignal { @@ -3516,6 +3544,79 @@ export class AuthStorage { ); } + /** + * Redeem one saved rate-limit reset (OpenAI Codex "saved resets") for a + * specific stored account. + * + * Resolves a fresh access token for the target account, picks an available + * credit (the given `creditId`, else the first redeemable one), spends it, + * and invalidates the cached usage report so the next `/usage` reflects the + * reset. Never throws for business outcomes — inspect the returned `code`. + */ + async redeemResetCredit(options: { + target: ResetCreditTarget; + provider?: string; + creditId?: string; + baseUrlResolver?: (provider: string) => string | undefined; + signal?: AbortSignal; + }): Promise { + const provider = options.provider ?? "openai-codex"; + const baseUrl = options.baseUrlResolver?.(provider); + const { target } = options; + const accesses = await this.getOAuthAccesses(provider); + const match = accesses.find( + access => + (target.credentialId !== undefined && access.credentialId === target.credentialId) || + (!!target.accountId && access.accountId === target.accountId) || + (!!target.email && access.email === target.email), + ); + if (!match) return { ok: false, code: "no_account", accountId: target.accountId, email: target.email }; + if (!match.ok) { + return { ok: false, code: "account_unavailable", accountId: match.accountId, email: match.email }; + } + + let creditId = options.creditId; + if (!creditId) { + const list = await listCodexResetCredits({ + accessToken: match.accessToken, + accountId: match.accountId, + baseUrl, + fetch: this.#usageFetch, + signal: options.signal, + }); + const credit = list?.credits.find(entry => (entry.status ?? "available") === "available") ?? list?.credits[0]; + if (!credit) return { ok: false, code: "no_credit", accountId: match.accountId, email: match.email }; + creditId = credit.id; + } + + const result = await consumeCodexResetCredit({ + creditId, + accessToken: match.accessToken, + accountId: match.accountId, + baseUrl, + fetch: this.#usageFetch, + signal: options.signal, + }); + if (result.ok) this.#invalidateUsageReportCache(provider, baseUrl); + return { ok: result.ok, code: result.code, accountId: match.accountId, email: match.email, creditId }; + } + + /** + * Force the next usage fetch for `provider` to bypass the 5-min cache, so + * `/usage` reflects a freshly-redeemed reset instead of stale numbers. + */ + #invalidateUsageReportCache(provider: string, baseUrl?: string): void { + const expired = Date.now() - 1; + for (const entry of this.#getStoredCredentials(provider)) { + if (entry.credential.type !== "oauth") continue; + const cacheKey = this.#buildUsageReportCacheKey( + this.#buildUsageRequestForOauth(provider, entry.credential, baseUrl), + ); + const existing = this.#usageCache.getStale(cacheKey); + this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired }); + } + } + #extractStructuredApiKeyToken(apiKey: string): string | undefined { if (!apiKey.startsWith("{")) return undefined; try { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 28cba6af8..6c7e4eeb1 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -34,6 +34,7 @@ export * from "./usage/google-antigravity"; export * from "./usage/kimi"; export * from "./usage/minimax-code"; export * from "./usage/openai-codex"; +export * from "./usage/openai-codex-reset"; export * from "./usage/zai"; export * from "./utils/anthropic-auth"; export * from "./utils/event-stream"; diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 6af982360..154909dc0 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -63,11 +63,25 @@ export interface UsageLimit { notes?: string[]; } +/** + * Saved/banked rate-limit resets an account can redeem on demand. + * + * Surfaced by providers that let users defer a usage-window reset and spend it + * later (OpenAI Codex "saved rate limit resets"). The redeem itself is a + * separate, provider-specific action; this is the read-only count for display. + */ +export interface UsageResetCredits { + /** Number of resets available to redeem right now. */ + availableCount: number; +} + /** Aggregated usage report for a provider. */ export interface UsageReport { provider: Provider; fetchedAt: number; limits: UsageLimit[]; + /** Saved rate-limit resets the account can redeem, when the provider reports them. */ + resetCredits?: UsageResetCredits; metadata?: Record; raw?: unknown; } @@ -114,10 +128,15 @@ export const usageLimitSchema = z.object({ notes: z.array(z.string()).optional(), }); +export const usageResetCreditsSchema = z.object({ + availableCount: z.number(), +}); + export const usageReportSchema = z.object({ provider: z.string(), fetchedAt: z.number(), limits: z.array(usageLimitSchema), + resetCredits: usageResetCreditsSchema.optional(), metadata: z.record(z.string(), z.unknown()).optional(), // `raw` is provider-specific and may be anything; the broker strips it before // sending the report over the wire, so accept-but-ignore here. diff --git a/packages/ai/src/usage/openai-codex-reset.ts b/packages/ai/src/usage/openai-codex-reset.ts new file mode 100644 index 000000000..56dca5987 --- /dev/null +++ b/packages/ai/src/usage/openai-codex-reset.ts @@ -0,0 +1,174 @@ +/** + * OpenAI Codex "saved rate limit reset" redemption client. + * + * OpenAI lets paid Codex accounts bank a usage-window reset and spend it on + * demand (announced 2026-06-11). The count is surfaced on `/wham/usage` as + * `rate_limit_reset_credits.available_count` (see `./openai-codex.ts`), but the + * actual credit objects and the redeem action live on two dedicated routes: + * + * GET /wham/rate-limit-reset-credits → list redeemable credits + * POST /wham/rate-limit-reset-credits/consume → spend one credit + * body: { credit_id, redeem_request_id } + * + * `redeem_request_id` is a client-generated idempotency key (UUID). The consume + * response carries a `code`: `"reset"` on success, otherwise a business reason + * (`already_redeemed`, `no_credit`, `nothing_to_reset`). + * + * These are thin, dependency-light functions so both the interactive session + * (the `/reset-usage` command + auto-redeem) and any out-of-band tooling can + * share one wire contract. + */ +import type { FetchImpl } from "../types"; +import { isRecord } from "../utils"; +import { normalizeCodexBaseUrl } from "./openai-codex"; +import { toNumber } from "./shared"; + +const RESET_CREDITS_PATH = "wham/rate-limit-reset-credits"; +const RESET_CREDITS_CONSUME_PATH = "wham/rate-limit-reset-credits/consume"; + +/** A single redeemable (or already-spent) saved reset. */ +export interface CodexResetCredit { + /** Opaque credit id, e.g. `RateLimitResetCredit_…`. Pass to {@link consumeCodexResetCredit}. */ + id: string; + /** Backend reset family, e.g. `codex_rate_limits`. */ + resetType?: string; + /** `available`, `redeemed`, … */ + status?: string; + grantedAt?: string; + expiresAt?: string; + redeemStartedAt?: string | null; + redeemedAt?: string | null; + /** Human-facing card title, e.g. "One free rate limit reset". */ + title?: string; + description?: string; +} + +/** Result of listing an account's saved resets. */ +export interface CodexResetCreditList { + credits: CodexResetCredit[]; + /** Backend-reported count of credits redeemable right now. */ + availableCount: number; +} + +/** + * Consume outcome `code`. `reset` means a window was actually reset; the others + * are no-op business outcomes the caller should surface verbatim-ish to the user. + */ +export type CodexResetConsumeCode = + | "reset" + | "already_redeemed" + | "no_credit" + | "nothing_to_reset" + // Forward-compatible: unknown future codes pass through. + | (string & {}); + +export interface CodexResetConsumeResult { + /** `true` only when `code === "reset"` (a reset was applied). */ + ok: boolean; + code: CodexResetConsumeCode; + /** HTTP status of the consume call (for diagnostics). */ + status: number; + raw?: unknown; +} + +interface CodexResetAuth { + accessToken: string; + accountId?: string; + /** Provider base URL override; defaults to the Codex backend. */ + baseUrl?: string; + fetch: FetchImpl; + signal?: AbortSignal; +} + +function buildUrl(baseUrl: string | undefined, routePath: string): string { + const base = normalizeCodexBaseUrl(baseUrl); + const normalized = base.endsWith("/") ? base : `${base}/`; + return `${normalized}${routePath}`; +} + +function buildHeaders(auth: CodexResetAuth, json: boolean): Record { + const headers: Record = { + Authorization: `Bearer ${auth.accessToken}`, + "User-Agent": "OpenCode-Status-Plugin/1.0", + }; + if (auth.accountId) headers["ChatGPT-Account-Id"] = auth.accountId; + if (json) headers["Content-Type"] = "application/json"; + return headers; +} + +function parseCredit(value: unknown): CodexResetCredit | null { + if (!isRecord(value)) return null; + const id = typeof value.id === "string" ? value.id : undefined; + if (!id) return null; + const str = (key: string): string | undefined => + typeof value[key] === "string" ? (value[key] as string) : undefined; + const nullableStr = (key: string): string | null | undefined => { + const raw = value[key]; + if (raw === null) return null; + return typeof raw === "string" ? raw : undefined; + }; + return { + id, + resetType: str("reset_type"), + status: str("status"), + grantedAt: str("granted_at"), + expiresAt: str("expires_at"), + redeemStartedAt: nullableStr("redeem_started_at"), + redeemedAt: nullableStr("redeemed_at"), + title: str("title"), + description: str("description"), + }; +} + +/** + * List the account's saved rate-limit resets. Returns `null` on transport/auth + * failure (non-2xx or thrown), letting callers treat it the same as "no data". + */ +export async function listCodexResetCredits(auth: CodexResetAuth): Promise { + const url = buildUrl(auth.baseUrl, RESET_CREDITS_PATH); + let payload: unknown; + try { + const response = await auth.fetch(url, { headers: buildHeaders(auth, false), signal: auth.signal }); + if (!response.ok) return null; + payload = await response.json(); + } catch { + return null; + } + if (!isRecord(payload)) return null; + const credits = Array.isArray(payload.credits) + ? payload.credits.map(parseCredit).filter((c): c is CodexResetCredit => c !== null) + : []; + const reported = toNumber(payload.available_count); + const availableCount = + reported !== undefined + ? Math.max(0, Math.trunc(reported)) + : credits.filter(c => (c.status ?? "available") === "available").length; + return { credits, availableCount }; +} + +/** + * Spend one saved reset. `redeemRequestId` is the idempotency key; one is + * generated when omitted, so retrying with the SAME id is safe and won't + * double-spend. The returned `code` is `"reset"` on success. + */ +export async function consumeCodexResetCredit( + auth: CodexResetAuth & { creditId: string; redeemRequestId?: string }, +): Promise { + const redeemRequestId = auth.redeemRequestId ?? crypto.randomUUID(); + const url = buildUrl(auth.baseUrl, RESET_CREDITS_CONSUME_PATH); + const response = await auth.fetch(url, { + method: "POST", + headers: buildHeaders(auth, true), + body: JSON.stringify({ credit_id: auth.creditId, redeem_request_id: redeemRequestId }), + signal: auth.signal, + }); + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + const code = + isRecord(body) && typeof body.code === "string" ? body.code : response.ok ? "reset" : `http_${response.status}`; + return { ok: code === "reset", code, status: response.status, raw: body }; +} diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 20fe97669..1efc2fbc1 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -8,6 +8,7 @@ import type { UsageLimit, UsageProvider, UsageReport, + UsageResetCredits, UsageWindow, } from "../usage"; import { isRecord } from "../utils"; @@ -185,7 +186,21 @@ function parseUsagePayload(payload: unknown): ParsedUsage | null { return parsed; } -function normalizeCodexBaseUrl(baseUrl?: string): string { +/** + * Parse the `rate_limit_reset_credits` block from `/wham/usage`. OpenAI Codex + * reports the count of saved rate-limit resets the account can redeem here; the + * redeem action itself lives in `./openai-codex-reset`. + */ +function parseResetCredits(payload: unknown): UsageResetCredits | undefined { + if (!isRecord(payload)) return undefined; + const block = payload.rate_limit_reset_credits; + if (!isRecord(block)) return undefined; + const availableCount = toNumber(block.available_count); + if (availableCount === undefined) return undefined; + return { availableCount: Math.max(0, Math.trunc(availableCount)) }; +} + +export function normalizeCodexBaseUrl(baseUrl?: string): string { const fallback = CODEX_BASE_URL; const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback; const base = trimmed.replace(/\/+$/, ""); @@ -454,10 +469,12 @@ export const openaiCodexUsageProvider: UsageProvider = { } } + const resetCredits = parseResetCredits(payload); const report: UsageReport = { provider: "openai-codex", fetchedAt: nowMs, limits, + ...(resetCredits ? { resetCredits } : {}), metadata: { planType, allowed: parsed?.allowed, diff --git a/packages/ai/test/openai-codex-reset.test.ts b/packages/ai/test/openai-codex-reset.test.ts new file mode 100644 index 000000000..a7dc98431 --- /dev/null +++ b/packages/ai/test/openai-codex-reset.test.ts @@ -0,0 +1,121 @@ +/** + * Wire-contract regressions for OpenAI Codex "saved rate limit reset" + * redemption. The endpoints and request shape are reverse-engineered from the + * Codex desktop app; these tests pin them so the redeem path can't silently + * drift (and so we never need to spend a real credit to verify it): + * + * GET /wham/rate-limit-reset-credits + * POST /wham/rate-limit-reset-credits/consume { credit_id, redeem_request_id } + */ +import { describe, expect, it } from "bun:test"; +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { consumeCodexResetCredit, listCodexResetCredits } from "@oh-my-pi/pi-ai/usage/openai-codex-reset"; + +interface Captured { + url: string; + method: string; + headers: Record; + body?: unknown; +} + +function recordingFetch(status: number, payload: unknown): { fetch: FetchImpl; calls: Captured[] } { + const calls: Captured[] = []; + const fetch = (async (url: string, init?: RequestInit) => { + calls.push({ + url: String(url), + method: init?.method ?? "GET", + headers: (init?.headers as Record) ?? {}, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); + }) as unknown as FetchImpl; + return { fetch, calls }; +} + +describe("listCodexResetCredits", () => { + it("lists credits and surfaces available_count from the dedicated route", async () => { + const { fetch, calls } = recordingFetch(200, { + credits: [ + { + id: "RateLimitResetCredit_abc", + reset_type: "codex_rate_limits", + status: "available", + granted_at: "2026-06-12T02:11:50Z", + expires_at: "2026-07-12T02:11:50Z", + title: "One free rate limit reset", + description: "Thanks for using Codex!", + }, + ], + available_count: 1, + }); + const list = await listCodexResetCredits({ accessToken: "tok", accountId: "acct-1", fetch }); + expect(list).not.toBeNull(); + expect(list?.availableCount).toBe(1); + expect(list?.credits[0]?.id).toBe("RateLimitResetCredit_abc"); + expect(list?.credits[0]?.title).toBe("One free rate limit reset"); + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"); + expect(calls[0]?.headers.Authorization).toBe("Bearer tok"); + expect(calls[0]?.headers["ChatGPT-Account-Id"]).toBe("acct-1"); + }); + + it("falls back to counting available credits when available_count is absent", async () => { + const { fetch } = recordingFetch(200, { + credits: [ + { id: "c1", status: "available" }, + { id: "c2", status: "redeemed" }, + ], + }); + const list = await listCodexResetCredits({ accessToken: "tok", fetch }); + expect(list?.availableCount).toBe(1); + }); + + it("returns null on non-2xx", async () => { + const { fetch } = recordingFetch(401, { detail: "Unauthorized" }); + expect(await listCodexResetCredits({ accessToken: "tok", fetch })).toBeNull(); + }); +}); + +describe("consumeCodexResetCredit", () => { + it("POSTs credit_id + redeem_request_id and reports ok on code=reset", async () => { + const { fetch, calls } = recordingFetch(200, { code: "reset" }); + const result = await consumeCodexResetCredit({ + creditId: "RateLimitResetCredit_abc", + accessToken: "tok", + accountId: "acct-1", + redeemRequestId: "req-123", + fetch, + }); + expect(result.ok).toBe(true); + expect(result.code).toBe("reset"); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"); + expect(calls[0]?.body).toEqual({ credit_id: "RateLimitResetCredit_abc", redeem_request_id: "req-123" }); + expect(calls[0]?.headers["Content-Type"]).toBe("application/json"); + }); + + it("generates a redeem_request_id when none is supplied", async () => { + const { fetch, calls } = recordingFetch(200, { code: "reset" }); + await consumeCodexResetCredit({ creditId: "c1", accessToken: "tok", fetch }); + const body = calls[0]?.body as { redeem_request_id?: string }; + expect(typeof body.redeem_request_id).toBe("string"); + expect(body.redeem_request_id?.length).toBeGreaterThan(0); + }); + + it("reports not-ok for business outcomes like already_redeemed", async () => { + const { fetch } = recordingFetch(200, { code: "already_redeemed" }); + const result = await consumeCodexResetCredit({ creditId: "c1", accessToken: "tok", fetch }); + expect(result.ok).toBe(false); + expect(result.code).toBe("already_redeemed"); + }); + + it("synthesizes an http_ code on unexpected failures", async () => { + const { fetch } = recordingFetch(500, {}); + const result = await consumeCodexResetCredit({ creditId: "c1", accessToken: "tok", fetch }); + expect(result.ok).toBe(false); + expect(result.code).toBe("http_500"); + }); +}); diff --git a/packages/ai/test/openai-codex-usage.test.ts b/packages/ai/test/openai-codex-usage.test.ts index 6509ad545..4407d98fb 100644 --- a/packages/ai/test/openai-codex-usage.test.ts +++ b/packages/ai/test/openai-codex-usage.test.ts @@ -126,4 +126,27 @@ describe("openai-codex usage parser", () => { expect(report).not.toBeNull(); expect(report?.limits.map(l => l.id)).toEqual(["openai-codex:spark:primary"]); }); + + it("surfaces rate_limit_reset_credits.available_count as report.resetCredits", async () => { + const payload = { ...makePayload(), rate_limit_reset_credits: { available_count: 1 } }; + const report = await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fakeFetch(payload) }, + ); + expect(report?.resetCredits).toEqual({ availableCount: 1 }); + }); + + it("omits resetCredits when the account has no saved resets block", async () => { + const report = await openaiCodexUsageProvider.fetchUsage( + { + provider: "openai-codex", + credential: { type: "oauth", accessToken: accessTokenFixture, accountId: "acct-1", email: "u@example.com" }, + }, + { fetch: fakeFetch(makePayload()) }, + ); + expect(report?.resetCredits).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ed9efeb4f..1750a2b55 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Added +- Added `/reset-usage` command to spend a saved Codex rate-limit reset by running `/reset-usage `, with a TUI selector flow for interactive reset redemption - Added `snapcompact.systemPrompt` enum modes `none`, `agents-md`, and `all`, so users can disable system-prompt imaging, image only loaded AGENTS.md/context-file instruction sections, or image the full system prompt - Added `agents-md` snapcompact mode that rasterizes AGENTS.md-style context sections from the system prompt into frames and appends them to the first user message - `/context` (TUI panel and ACP report) now shows estimated snapcompact wire savings when `snapcompact.systemPrompt` or `snapcompact.toolResults` is enabled — per-feature text → frames token deltas, the reason a swap does not apply (savings margin, image budget, or text-only model), and the estimated size of the next request. The estimate and the live provider-request transform share one planner (`planInlineSwaps`) so displayed numbers cannot drift from wire behavior. @@ -11,6 +13,7 @@ ### Changed +- Changed usage views (`/usage` reports, status summaries, and account headers) to show available saved Codex rate-limit resets and point to `/reset-usage` for spending them - Changed `/context` system-prompt savings lines to show the scope as `AGENTS.md` or `all`, so savings estimates now indicate whether imaging applied only to context-file instructions or the entire prompt - Changed `/debug` handling in interactive mode so `/debug` with arguments now executes the requested debug subcommand instead of always opening the debug selector - Changed `/debug dump-next-request` path handling to expand `~` and resolve relative paths against the current working directory diff --git a/packages/coding-agent/src/cli/usage-cli.ts b/packages/coding-agent/src/cli/usage-cli.ts index 164d5c0aa..85f302de8 100644 --- a/packages/coding-agent/src/cli/usage-cli.ts +++ b/packages/coding-agent/src/cli/usage-cli.ts @@ -325,6 +325,8 @@ function formatAccountHeader( let header = `${icon} ${chalk.bold(redaction?.get(label) ?? label)}`; const planType = report.metadata?.planType; if (typeof planType === "string" && planType) header += chalk.dim(` · plan: ${planType}`); + const savedResets = report.resetCredits?.availableCount ?? 0; + if (savedResets > 0) header += chalk.cyan(` · ✦ ${savedResets} saved reset${savedResets === 1 ? "" : "s"}`); if (report.fetchedAt && nowMs - report.fetchedAt > 90_000) { header += chalk.dim(` · fetched ${formatDuration(nowMs - report.fetchedAt)} ago`); } diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index d42d71965..c0a858fbe 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1588,7 +1588,11 @@ export const SETTINGS_SCHEMA = { label: "AGENTS.md", description: "Only move loaded context-file instructions to images, when that saves tokens.", }, - { value: "all", label: "All", description: "Move the full system prompt to images, when that saves tokens." }, + { + value: "all", + label: "All", + description: "Move the full system prompt to images, when that saves tokens.", + }, ], }, }, diff --git a/packages/coding-agent/src/modes/components/reset-usage-selector.ts b/packages/coding-agent/src/modes/components/reset-usage-selector.ts new file mode 100644 index 000000000..bc6df93c2 --- /dev/null +++ b/packages/coding-agent/src/modes/components/reset-usage-selector.ts @@ -0,0 +1,155 @@ +import { Container, matchesKey, ScrollView, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; +import { theme } from "../../modes/theme/theme"; +import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers"; +import type { ResetUsageAccount } from "../../slash-commands/helpers/reset-usage"; +import { DynamicBorder } from "./dynamic-border"; + +const RESET_SELECTOR_MAX_VISIBLE = 10; + +/** + * Account picker for `/reset-usage`. Lists Codex accounts with their saved + * rate-limit reset counts; selecting one redeems a reset. Because a reset is a + * scarce, irreversible credit, Enter requires a second press to confirm. + */ +export class ResetUsageSelectorComponent extends Container { + #listContainer: Container; + #accounts: ResetUsageAccount[]; + #selectedIndex = 0; + #pendingIndex: number | null = null; + #statusMessage: string | undefined; + #onSelectCallback: (account: ResetUsageAccount) => void; + #onCancelCallback: () => void; + + constructor(accounts: ResetUsageAccount[], onSelect: (account: ResetUsageAccount) => void, onCancel: () => void) { + super(); + this.#accounts = accounts; + this.#onSelectCallback = onSelect; + this.#onCancelCallback = onCancel; + const firstRedeemable = accounts.findIndex(account => account.availableCount > 0); + this.#selectedIndex = firstRedeemable >= 0 ? firstRedeemable : 0; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new TruncatedText(theme.bold("Spend a saved rate-limit reset:"))); + this.addChild(new Spacer(1)); + this.#listContainer = new Container(); + this.addChild(this.#listContainer); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + this.#updateList(); + } + + #updateList(): void { + this.#listContainer.clear(); + + const total = this.#accounts.length; + const maxVisible = RESET_SELECTOR_MAX_VISIBLE; + const startIndex = + total <= maxVisible + ? 0 + : Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible)); + const endIndex = Math.min(startIndex + maxVisible, total); + + const rows: string[] = []; + for (let i = startIndex; i < endIndex; i++) { + const account = this.#accounts[i]; + if (!account) continue; + const isSelected = i === this.#selectedIndex; + const redeemable = account.availableCount > 0; + const countLabel = `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`; + const countText = redeemable ? theme.fg("success", countLabel) : theme.fg("dim", countLabel); + const activeTag = account.active ? theme.fg("muted", " (active)") : ""; + if (isSelected) { + const name = redeemable ? theme.fg("accent", account.label) : theme.fg("dim", account.label); + rows.push(`${theme.fg("accent", `${theme.nav.cursor} `)}${name}${activeTag} ${countText}`); + } else { + const name = redeemable ? ` ${account.label}` : theme.fg("dim", ` ${account.label}`); + rows.push(`${name}${activeTag} ${countText}`); + } + } + + if (rows.length > 0) { + const sv = new ScrollView(rows, { + height: rows.length, + scrollbar: "auto", + totalRows: total, + theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) }, + }); + sv.setScrollOffset(startIndex); + this.#listContainer.addChild(sv); + } + + if (total === 0) { + this.#listContainer.addChild( + new TruncatedText(theme.fg("muted", " No Codex accounts with saved resets"), 0, 0), + ); + } + + const pending = this.#pendingIndex !== null ? this.#accounts[this.#pendingIndex] : undefined; + const hint = pending + ? theme.fg("warning", ` Press Enter again to spend 1 reset for ${pending.label}, Esc to cancel`) + : theme.fg("muted", " ↑/↓ select · ↵ spend a reset · Esc cancel"); + this.#listContainer.addChild(new TruncatedText(hint, 0, 0)); + + if (this.#statusMessage) { + this.#listContainer.addChild(new Spacer(1)); + this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0)); + } + } + + handleInput(keyData: string): void { + if (matchesSelectCancel(keyData)) { + if (this.#pendingIndex !== null) { + this.#pendingIndex = null; + this.#statusMessage = undefined; + this.#updateList(); + return; + } + this.#onCancelCallback(); + return; + } + + if (matchesSelectUp(keyData)) { + if (this.#accounts.length > 0) { + this.#selectedIndex = this.#selectedIndex === 0 ? this.#accounts.length - 1 : this.#selectedIndex - 1; + } + this.#pendingIndex = null; + this.#statusMessage = undefined; + this.#updateList(); + } else if (matchesSelectDown(keyData)) { + if (this.#accounts.length > 0) { + this.#selectedIndex = this.#selectedIndex === this.#accounts.length - 1 ? 0 : this.#selectedIndex + 1; + } + this.#pendingIndex = null; + this.#statusMessage = undefined; + this.#updateList(); + } else if (matchesKey(keyData, "pageUp")) { + if (this.#accounts.length > 0) { + this.#selectedIndex = Math.max(0, this.#selectedIndex - RESET_SELECTOR_MAX_VISIBLE); + } + this.#pendingIndex = null; + this.#updateList(); + } else if (matchesKey(keyData, "pageDown")) { + if (this.#accounts.length > 0) { + this.#selectedIndex = Math.min(this.#accounts.length - 1, this.#selectedIndex + RESET_SELECTOR_MAX_VISIBLE); + } + this.#pendingIndex = null; + this.#updateList(); + } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { + const account = this.#accounts[this.#selectedIndex]; + if (!account) return; + if (account.availableCount <= 0) { + this.#statusMessage = "That account has no saved resets to spend."; + this.#updateList(); + return; + } + if (this.#pendingIndex === this.#selectedIndex) { + this.#onSelectCallback(account); + return; + } + this.#pendingIndex = this.#selectedIndex; + this.#statusMessage = undefined; + this.#updateList(); + } + } +} diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index bccb2b6a9..1ffc6d86e 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1528,6 +1528,29 @@ function renderUsageReports( lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`); } + const resetAccountLines: string[] = []; + for (const report of providerReports) { + const count = report.resetCredits?.availableCount ?? 0; + if (count <= 0) continue; + const label = + (report.metadata?.email as string | undefined) ?? + (report.metadata?.accountId as string | undefined) ?? + "account"; + const isActive = + !!activeAccount && + ((!!activeAccount.accountId && activeAccount.accountId === report.metadata?.accountId) || + (!!activeAccount.email && activeAccount.email === report.metadata?.email)); + resetAccountLines.push( + ` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`, + ); + } + if (resetAccountLines.length > 0) { + lines.push( + ` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/reset-usage to spend)")}`, + ); + for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line)); + } + const renderableGroups = Array.from(limitGroups.values()).map(group => { const entries = group.limits.map((limit, index) => ({ limit, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 98534d7d2..32d2d7329 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1,4 +1,5 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { UsageReport } from "@oh-my-pi/pi-ai"; import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types"; @@ -27,8 +28,15 @@ import { theme, } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; +import type { ResetCreditRedeemOutcome } from "../../session/auth-storage"; import { type SessionInfo, SessionManager } from "../../session/session-manager"; import { FileSessionStorage } from "../../session/session-storage"; +import { + buildResetUsageAccounts, + CODEX_PROVIDER_ID, + describeRedeemOutcome, + type ResetUsageAccount, +} from "../../slash-commands/helpers/reset-usage"; import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { isImageProviderPreference, @@ -48,6 +56,7 @@ import { HistorySearchComponent } from "../components/history-search"; import { ModelSelectorComponent } from "../components/model-selector"; import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; +import { ResetUsageSelectorComponent } from "../components/reset-usage-selector"; import { SessionSelectorComponent } from "../components/session-selector"; import { SettingsSelectorComponent } from "../components/settings-selector"; import { ToolExecutionComponent } from "../components/tool-execution"; @@ -1091,6 +1100,63 @@ export class SelectorController { }); } + async showResetUsageSelector(): Promise { + const session = this.ctx.session; + let reports: UsageReport[] | null = null; + try { + reports = await session.fetchUsageReports(); + } catch { + this.ctx.showError("Could not load usage data to find saved resets."); + return; + } + const active = session.modelRegistry.authStorage.getOAuthAccountIdentity(CODEX_PROVIDER_ID, session.sessionId); + const accounts = buildResetUsageAccounts(reports, active); + if (accounts.length === 0) { + this.ctx.showStatus("No Codex accounts found. Use /login to add one."); + return; + } + if (!accounts.some(account => account.availableCount > 0)) { + this.ctx.showStatus("No saved rate-limit resets available to spend right now."); + return; + } + this.showSelector(done => { + const selector = new ResetUsageSelectorComponent( + accounts, + account => { + done(); + void this.#redeemReset(account); + }, + () => { + done(); + this.ctx.ui.requestRender(); + }, + ); + return { component: selector, focus: selector }; + }); + } + + async #redeemReset(account: ResetUsageAccount): Promise { + this.ctx.showStatus(`Spending 1 saved reset for ${account.label}…`, { dim: true }); + let outcome: ResetCreditRedeemOutcome; + try { + outcome = await this.ctx.session.redeemResetCredit(account.target); + } catch (error) { + this.ctx.showError( + `Reset failed for ${account.label}: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + const message = describeRedeemOutcome(outcome, account.label); + if (outcome.ok) { + this.ctx.showStatus(message); + // Refresh the status-line usage so the freshly-reset window shows. + this.ctx.statusLine.invalidate(); + this.ctx.ui.requestRender(); + } else { + this.ctx.showWarning(message); + } + } + async showDebugSelector(): Promise { const { DebugSelectorComponent } = await import("../../debug"); this.showSelector(done => { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 771ce223a..29734cad4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -3235,6 +3235,10 @@ export class InteractiveMode implements InteractiveModeContext { return this.#selectorController.showOAuthSelector(mode, providerId); } + showResetUsageSelector(): Promise { + return this.#selectorController.showResetUsageSelector(); + } + showProviderSetup(): Promise { return runProviderSetupWizard(this); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 9df19c9b3..be059f851 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -287,6 +287,7 @@ export interface InteractiveModeContext { handleResumeSession(sessionPath: string): Promise; handleSessionDeleteCommand(): Promise; showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise; + showResetUsageSelector(): Promise; showProviderSetup(): Promise; showHookConfirm(title: string, message: string): Promise; showDebugSelector(): Promise; diff --git a/packages/coding-agent/src/modes/utils/context-usage.ts b/packages/coding-agent/src/modes/utils/context-usage.ts index 336453e60..2bd239a7e 100644 --- a/packages/coding-agent/src/modes/utils/context-usage.ts +++ b/packages/coding-agent/src/modes/utils/context-usage.ts @@ -345,7 +345,9 @@ function buildLegendLines(breakdown: ContextBreakdown, theme: typeof Theme): str : sp.reason === "empty" ? "nothing to image" : "frames would not save tokens"; - lines.push(` System prompt (${sp.scope === "agents-md" ? "AGENTS.md" : "all"}): ${theme.fg("dim", `stays text (${reason})`)}`); + lines.push( + ` System prompt (${sp.scope === "agents-md" ? "AGENTS.md" : "all"}): ${theme.fg("dim", `stays text (${reason})`)}`, + ); } } if (snap.toolResults) { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c992c627a..31aa03a90 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -73,6 +73,8 @@ import type { Model, ProviderResponseMetadata, ProviderSessionState, + ResetCreditRedeemOutcome, + ResetCreditTarget, ServiceTier, SimpleStreamOptions, TextContent, @@ -10137,6 +10139,20 @@ export class AgentSession { }); } + /** + * Redeem one saved Codex rate-limit reset for a specific account, injecting + * the provider base URL like {@link AgentSession.fetchUsageReports}. Powers + * the `/reset-usage` command and auto-redeem. Never throws for business + * outcomes — inspect the returned `code`. + */ + async redeemResetCredit(target: ResetCreditTarget, signal?: AbortSignal): Promise { + return this.#modelRegistry.authStorage.redeemResetCredit({ + target, + baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), + signal, + }); + } + /** * Estimate context tokens from messages, using the last assistant usage when available. */ diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index 0b8725443..944212900 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -14,6 +14,8 @@ export type { CredentialOriginKind, OAuthAccountIdentity, OAuthCredential, + ResetCreditRedeemOutcome, + ResetCreditTarget, SerializedAuthStorage, SnapshotResponse, StoredAuthCredential, diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 0c0cdac04..891669ece 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { UsageReport } from "@oh-my-pi/pi-ai"; import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug"; import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; @@ -34,6 +35,7 @@ import { handleSshAcp } from "./helpers/ssh"; import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard"; import { handleTodoAcp } from "./helpers/todo"; import { buildUsageReportText } from "./helpers/usage-report"; +import { buildResetUsageAccounts, describeRedeemOutcome } from "./helpers/reset-usage"; import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "./marketplace-install-parser"; import type { BuiltinSlashCommand, @@ -601,6 +603,64 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ runtime.ctx.editor.setText(""); }, }, + { + name: "reset-usage", + description: "Spend a saved Codex rate-limit reset", + acpDescription: "Spend a saved rate-limit reset", + inlineHint: "[account|active]", + allowArgs: true, + handle: async (command, runtime) => { + const { session } = runtime; + let reports: UsageReport[] | null = null; + try { + reports = await session.fetchUsageReports(); + } catch { + await runtime.output("Could not load usage data to find saved resets."); + return commandConsumed(); + } + const active = session.modelRegistry.authStorage.getOAuthAccountIdentity("openai-codex", session.sessionId); + const accounts = buildResetUsageAccounts(reports, active); + if (accounts.length === 0) { + await runtime.output("No Codex accounts found. Use /login to add one."); + return commandConsumed(); + } + const arg = command.args.trim(); + if (!arg) { + const lines = ["Saved Codex rate-limit resets:"]; + for (const account of accounts) { + lines.push(`- ${account.label}: ${account.availableCount} available${account.active ? " (active)" : ""}`); + } + lines.push("", "Spend one with `/reset-usage ` or `/reset-usage active`."); + await runtime.output(lines.join("\n")); + return commandConsumed(); + } + const wanted = arg.toLowerCase(); + const target = + wanted === "active" + ? accounts.find(account => account.active) + : accounts.find( + account => + account.label.toLowerCase() === wanted || + account.target.email?.toLowerCase() === wanted || + account.target.accountId?.toLowerCase() === wanted, + ); + if (!target) { + await runtime.output(`No Codex account matches "${arg}".`); + return commandConsumed(); + } + if (target.availableCount <= 0) { + await runtime.output(`${target.label}: no saved resets to spend.`); + return commandConsumed(); + } + const outcome = await session.redeemResetCredit(target.target); + await runtime.output(describeRedeemOutcome(outcome, target.label)); + return commandConsumed(); + }, + handleTui: (_command, runtime) => { + void runtime.ctx.showResetUsageSelector(); + runtime.ctx.editor.setText(""); + }, + }, { name: "stats", description: "Launch the local stats dashboard", diff --git a/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts b/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts new file mode 100644 index 000000000..79a888238 --- /dev/null +++ b/packages/coding-agent/src/slash-commands/helpers/reset-usage.ts @@ -0,0 +1,71 @@ +/** + * Shared helpers for the `/reset-usage` command (TUI selector + ACP) and + * auto-redeem: turn usage reports into a per-account list of redeemable saved + * rate-limit resets, and map a redeem outcome code to a human message. + */ +import type { UsageReport } from "@oh-my-pi/pi-ai"; +import type { OAuthAccountIdentity, ResetCreditRedeemOutcome, ResetCreditTarget } from "../../session/auth-storage"; + +export const CODEX_PROVIDER_ID = "openai-codex"; + +/** One Codex account row for the reset-usage selector. */ +export interface ResetUsageAccount { + /** Display label (email, else account id). */ + label: string; + /** Saved resets redeemable for this account right now. */ + availableCount: number; + /** Identifies the account when redeeming. */ + target: ResetCreditTarget; + /** Whether this is the session's active Codex account. */ + active: boolean; +} + +/** + * Build the per-account reset list from usage reports (Codex only). Sorted with + * the active account first, then most-credits, then label. + */ +export function buildResetUsageAccounts( + reports: UsageReport[] | null | undefined, + active?: OAuthAccountIdentity, +): ResetUsageAccount[] { + const accounts = (reports ?? []) + .filter(report => report.provider === CODEX_PROVIDER_ID) + .map(report => { + const accountId = typeof report.metadata?.accountId === "string" ? report.metadata.accountId : undefined; + const email = typeof report.metadata?.email === "string" ? report.metadata.email : undefined; + const isActive = + !!active && + ((!!active.accountId && active.accountId === accountId) || (!!active.email && active.email === email)); + return { + label: email ?? accountId ?? "account", + availableCount: report.resetCredits?.availableCount ?? 0, + target: { accountId, email } satisfies ResetCreditTarget, + active: isActive, + }; + }); + return accounts.sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1; + if (a.availableCount !== b.availableCount) return b.availableCount - a.availableCount; + return a.label.localeCompare(b.label); + }); +} + +/** Human-facing summary of a redeem outcome for status lines and ACP output. */ +export function describeRedeemOutcome(outcome: ResetCreditRedeemOutcome, label: string): string { + switch (outcome.code) { + case "reset": + return `Reset applied for ${label} — your rate-limit window has been refreshed.`; + case "already_redeemed": + return `${label}: that reset was already redeemed.`; + case "no_credit": + return `${label}: no saved resets available to spend.`; + case "nothing_to_reset": + return `${label}: nothing to reset right now — your limits aren't constrained, so no credit was spent.`; + case "no_account": + return `Could not find a stored Codex account matching "${label}".`; + case "account_unavailable": + return `${label}: could not authenticate this account — try /login.`; + default: + return `${label}: reset did not apply (${outcome.code}).`; + } +} diff --git a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts index 1e12c978b..54bf75208 100644 --- a/packages/coding-agent/src/slash-commands/helpers/usage-report.ts +++ b/packages/coding-agent/src/slash-commands/helpers/usage-report.ts @@ -54,6 +54,18 @@ function renderUsageReports( const activeAccount = resolveActiveAccount?.(provider); for (const report of providerReports) { const inUse = reportMatchesActiveAccount(report, activeAccount); + const savedResets = report.resetCredits?.availableCount ?? 0; + if (savedResets > 0) { + const resetLabel = + typeof report.metadata?.email === "string" + ? report.metadata.email + : typeof report.metadata?.accountId === "string" + ? report.metadata.accountId + : "account"; + lines.push( + `- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /reset-usage to spend`, + ); + } if (report.limits.length === 0) { const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account"; lines.push(`- ${email}: no limits reported`); diff --git a/packages/coding-agent/test/modes/context-usage.test.ts b/packages/coding-agent/test/modes/context-usage.test.ts index a98910752..04a44e9f9 100644 --- a/packages/coding-agent/test/modes/context-usage.test.ts +++ b/packages/coding-agent/test/modes/context-usage.test.ts @@ -56,7 +56,14 @@ describe("renderContextUsage snapcompact section", () => { const output = renderContextUsage( breakdownWith({ visionCapable: true, - systemPrompt: { applied: true, scope: "all", textTokens: 9768, frames: 2, imageTokens: 6600, savedTokens: 3168 }, + systemPrompt: { + applied: true, + scope: "all", + textTokens: 9768, + frames: 2, + imageTokens: 6600, + savedTokens: 3168, + }, toolResults: { total: 3, swapped: 0, textTokens: 0, frames: 0, imageTokens: 0, savedTokens: 0 }, savedTokens: 3168, }), diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 38e6ed25f..a7671d398 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -4,11 +4,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { Effort } from "@oh-my-pi/pi-ai"; import { - type SettingPath, getDefault, onAppendOnlyModeChanged, onStatusLineSessionAccentChanged, resetSettingsForTest, + type SettingPath, Settings, } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getProjectAgentDir, Snowflake } from "@oh-my-pi/pi-utils"; diff --git a/packages/coding-agent/test/snapcompact-inline.test.ts b/packages/coding-agent/test/snapcompact-inline.test.ts index 04c574af8..81e3a25dd 100644 --- a/packages/coding-agent/test/snapcompact-inline.test.ts +++ b/packages/coding-agent/test/snapcompact-inline.test.ts @@ -165,7 +165,10 @@ describe("SnapcompactInlineTransformer", () => { }); it("moves only loaded context-file instructions when AGENTS.md mode is selected", () => { - const transformer = new SnapcompactInlineTransformer({ renderSystemPrompt: "agents-md", renderToolResults: false }); + const transformer = new SnapcompactInlineTransformer({ + renderSystemPrompt: "agents-md", + renderToolResults: false, + }); const longContext = denseText(3000); const context: Context = { systemPrompt: [