From cae16c2d00b7ee371e474b9bd67c6751b586ee35 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 04:46:25 +0800 Subject: [PATCH 1/2] refactor: extract forecast command --- lib/codex-manager.ts | 391 ++--------------- lib/codex-manager/commands/forecast.ts | 462 ++++++++++++++++++++ test/codex-manager-forecast-command.test.ts | 142 ++++++ 3 files changed, 633 insertions(+), 362 deletions(-) create mode 100644 lib/codex-manager/commands/forecast.ts create mode 100644 test/codex-manager-forecast-command.test.ts diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 07fccee6..40315059 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -36,6 +36,7 @@ import { } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { runCheckCommand } from "./codex-manager/commands/check.js"; +import { runForecastCommand } from "./codex-manager/commands/forecast.js"; import { runReportCommand } from "./codex-manager/commands/report.js"; import { runFeaturesCommand, @@ -2242,12 +2243,6 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { ); } -interface ForecastCliOptions { - live: boolean; - json: boolean; - model: string; -} - interface BestCliOptions { live: boolean; json: boolean; @@ -2272,20 +2267,6 @@ type ParsedArgsResult = | { ok: true; options: T } | { ok: false; message: string }; -function printForecastUsage(): void { - console.log( - [ - "Usage:", - " codex auth forecast [--live] [--json] [--model ]", - "", - "Options:", - " --live, -l Probe live quota headers via Codex backend", - " --json, -j Print machine-readable JSON output", - " --model, -m Probe model for live mode (default: gpt-5-codex)", - ].join("\n"), - ); -} - function printBestUsage(): void { console.log( [ @@ -2342,50 +2323,6 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs( - args: string[], -): ParsedArgsResult { - const options: ForecastCliOptions = { - live: false, - json: false, - model: "gpt-5-codex", - }; - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) continue; - if (!arg) continue; - if (arg === "--live" || arg === "-l") { - options.live = true; - continue; - } - if (arg === "--json" || arg === "-j") { - options.json = true; - continue; - } - if (arg === "--model" || arg === "-m") { - const value = args[i + 1]; - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - i += 1; - continue; - } - if (arg.startsWith("--model=")) { - const value = arg.slice("--model=".length).trim(); - if (!value) { - return { ok: false, message: "Missing value for --model" }; - } - options.model = value; - continue; - } - return { ok: false, message: `Unknown option: ${arg}` }; - } - - return { ok: true, options }; -} - function parseBestArgs(args: string[]): ParsedArgsResult { const options: BestCliOptions = { live: false, @@ -2552,305 +2489,35 @@ function parseDoctorArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function serializeForecastResults( - results: ForecastAccountResult[], - liveQuotaByIndex: Map< - number, - Awaited> - >, - refreshFailures: Map, -): Array<{ - index: number; - label: string; - isCurrent: boolean; - availability: ForecastAccountResult["availability"]; - riskScore: number; - riskLevel: ForecastAccountResult["riskLevel"]; - waitMs: number; - reasons: string[]; - liveQuota?: { - status: number; - planType?: string; - activeLimit?: number; - model: string; - summary: string; - }; - refreshFailure?: TokenFailure; -}> { - return results.map((result) => { - const liveQuota = liveQuotaByIndex.get(result.index); - return { - index: result.index, - label: result.label, - isCurrent: result.isCurrent, - availability: result.availability, - riskScore: result.riskScore, - riskLevel: result.riskLevel, - waitMs: result.waitMs, - reasons: result.reasons, - liveQuota: liveQuota - ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } - : undefined, - refreshFailure: refreshFailures.get(result.index), - }; - }); -} - async function runForecast(args: string[]): Promise { - if (args.includes("--help") || args.includes("-h")) { - printForecastUsage(); - return 0; - } - - const parsedArgs = parseForecastArgs(args); - if (!parsedArgs.ok) { - console.error(parsedArgs.message); - printForecastUsage(); - return 1; - } - const options = parsedArgs.options; - const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; - const quotaCache = options.live ? await loadQuotaCache() : null; - const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; - let quotaCacheChanged = false; - - setStoragePath(null); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("No accounts configured."); - return 0; - } - const quotaEmailFallbackState = - options.live && quotaCache - ? buildQuotaEmailFallbackState(storage.accounts) - : null; - - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - const refreshFailures = new Map(); - const liveQuotaByIndex = new Map< - number, - Awaited> - >(); - const probeErrors: string[] = []; - - for (let i = 0; i < storage.accounts.length; i += 1) { - const account = storage.accounts[i]; - if (!account || !options.live) continue; - if (account.enabled === false) continue; - - let probeAccessToken = account.accessToken; - let probeAccountId = - account.accountId ?? extractAccountId(account.accessToken); - if (!hasUsableAccessToken(account, now)) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - refreshFailures.set(i, { - ...refreshResult, - message: normalizeFailureDetail( - refreshResult.message, - refreshResult.reason, - ), - }); - continue; - } - probeAccessToken = refreshResult.access; - probeAccountId = - account.accountId ?? extractAccountId(refreshResult.access); - } - - if (!probeAccessToken || !probeAccountId) { - probeErrors.push( - `${formatAccountLabel(account, i)}: missing accountId for live probe`, - ); - continue; - } - - try { - const liveQuota = await fetchCodexQuotaSnapshot({ - accountId: probeAccountId, - accessToken: probeAccessToken, - model: options.model, - }); - liveQuotaByIndex.set(i, liveQuota); - if (workingQuotaCache) { - const account = storage.accounts[i]; - if (account) { - quotaCacheChanged = - updateQuotaCacheForAccount( - workingQuotaCache, - account, - liveQuota, - storage.accounts, - quotaEmailFallbackState ?? undefined, - ) || quotaCacheChanged; - } - } - } catch (error) { - const message = normalizeFailureDetail( - error instanceof Error ? error.message : String(error), - undefined, - ); - probeErrors.push(`${formatAccountLabel(account, i)}: ${message}`); - } - } - - const forecastInputs = storage.accounts.map((account, index) => ({ - index, - account, - isCurrent: index === activeIndex, - now, - refreshFailure: refreshFailures.get(index), - liveQuota: liveQuotaByIndex.get(index), - })); - const forecastResults = evaluateForecastAccounts(forecastInputs); - const summary = summarizeForecast(forecastResults); - const recommendation = recommendForecastAccount(forecastResults); - - if (options.json) { - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - console.log( - JSON.stringify( - { - command: "forecast", - model: options.model, - liveProbe: options.live, - summary, - recommendation, - probeErrors, - accounts: serializeForecastResults( - forecastResults, - liveQuotaByIndex, - refreshFailures, - ), - }, - null, - 2, - ), - ); - return 0; - } - - console.log( - stylePromptText( - `Best-account preview (${storage.accounts.length} account(s), model ${options.model}, live check ${options.live ? "on" : "off"})`, - "accent", - ), - ); - console.log( - formatResultSummary([ - { text: `${summary.ready} ready now`, tone: "success" }, - { text: `${summary.delayed} waiting`, tone: "warning" }, - { - text: `${summary.unavailable} unavailable`, - tone: summary.unavailable > 0 ? "danger" : "muted", - }, - { - text: `${summary.highRisk} high risk`, - tone: summary.highRisk > 0 ? "danger" : "muted", - }, - ]), - ); - console.log(""); - - for (const result of forecastResults) { - if (!display.showPerAccountRows) { - continue; - } - const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = - result.waitMs > 0 - ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") - : ""; - const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText( - `${result.label}${currentTag}`, - "accent", - ); - const riskLabel = stylePromptText( - `${result.riskLevel} risk (${result.riskScore})`, - riskTone(result.riskLevel), - ); - const availabilityLabel = stylePromptText( - result.availability, - availabilityTone(result.availability), - ); - const rowParts = [availabilityLabel, riskLabel]; - if (waitLabel) rowParts.push(waitLabel); - console.log( - `${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`, - ); - if (display.showForecastReasons && result.reasons.length > 0) { - console.log( - ` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, - ); - } - const liveQuota = liveQuotaByIndex.get(result.index); - if (display.showQuotaDetails && liveQuota) { - console.log( - ` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`, - ); - } - } - - if (!display.showPerAccountRows) { - console.log( - stylePromptText( - "Per-account lines are hidden in dashboard settings.", - "muted", - ), - ); - } - - if (display.showRecommendations) { - console.log(""); - if (recommendation.recommendedIndex !== null) { - const index = recommendation.recommendedIndex; - const account = forecastResults.find((result) => result.index === index); - if (account) { - console.log( - `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, - ); - console.log( - `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, - ); - if (index !== activeIndex) { - console.log( - `${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, - ); - } - } - } else { - console.log( - `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, - ); - } - } - - if (display.showLiveProbeNotes && probeErrors.length > 0) { - console.log(""); - console.log( - stylePromptText(`Live check notes (${probeErrors.length}):`, "warning"), - ); - for (const error of probeErrors) { - console.log( - ` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`, - ); - } - } - if (workingQuotaCache && quotaCacheChanged) { - await saveQuotaCache(workingQuotaCache); - } - - return 0; + return runForecastCommand(args, { + setStoragePath, + loadAccounts, + resolveActiveIndex, + loadQuotaCache, + saveQuotaCache, + cloneQuotaCacheData, + buildQuotaEmailFallbackState, + updateQuotaCacheForAccount, + hasUsableAccessToken, + queuedRefresh, + fetchCodexQuotaSnapshot, + normalizeFailureDetail, + formatAccountLabel, + extractAccountId, + evaluateForecastAccounts, + summarizeForecast, + recommendForecastAccount, + stylePromptText, + formatResultSummary, + styleQuotaSummary, + formatCompactQuotaSnapshot, + availabilityTone, + riskTone, + formatWaitTime, + defaultDisplay: DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + formatQuotaSnapshotLine, + }); } type FixOutcome = diff --git a/lib/codex-manager/commands/forecast.ts b/lib/codex-manager/commands/forecast.ts new file mode 100644 index 00000000..5793285c --- /dev/null +++ b/lib/codex-manager/commands/forecast.ts @@ -0,0 +1,462 @@ +import type { DashboardDisplaySettings } from "../../dashboard-settings.js"; +import type { ForecastAccountResult } from "../../forecast.js"; +import type { QuotaCacheData } from "../../quota-cache.js"; +import type { CodexQuotaSnapshot } from "../../quota-probe.js"; +import type { AccountMetadataV3, AccountStorageV3 } from "../../storage.js"; +import type { TokenFailure, TokenResult } from "../../types.js"; + +interface ForecastCliOptions { + live: boolean; + json: boolean; + model: string; +} + +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; + +type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +type QuotaEmailFallbackState = ReadonlyMap< + string, + { matchingCount: number; distinctAccountIds: Set } +>; + +export interface ForecastCommandDeps { + setStoragePath: (path: string | null) => void; + loadAccounts: () => Promise; + resolveActiveIndex: (storage: AccountStorageV3, family?: "codex") => number; + loadQuotaCache: () => Promise; + saveQuotaCache: (cache: QuotaCacheData) => Promise; + cloneQuotaCacheData: (cache: QuotaCacheData) => QuotaCacheData; + buildQuotaEmailFallbackState: ( + accounts: readonly Pick[], + ) => QuotaEmailFallbackState; + updateQuotaCacheForAccount: ( + cache: QuotaCacheData, + account: Pick, + snapshot: CodexQuotaSnapshot, + accounts: readonly Pick[], + emailFallbackState?: QuotaEmailFallbackState, + ) => boolean; + hasUsableAccessToken: ( + account: Pick, + now: number, + ) => boolean; + queuedRefresh: (refreshToken: string) => Promise; + fetchCodexQuotaSnapshot: (input: { + accountId: string; + accessToken: string; + model: string; + }) => Promise; + normalizeFailureDetail: ( + message: string | undefined, + reason: string | undefined, + ) => string; + formatAccountLabel: ( + account: Pick, + index: number, + ) => string; + extractAccountId: (accessToken: string | undefined) => string | undefined; + evaluateForecastAccounts: ( + inputs: Array<{ + index: number; + account: AccountMetadataV3; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + liveQuota?: CodexQuotaSnapshot; + }>, + ) => ForecastAccountResult[]; + summarizeForecast: (results: ForecastAccountResult[]) => { + total: number; + ready: number; + delayed: number; + unavailable: number; + highRisk: number; + }; + recommendForecastAccount: (results: ForecastAccountResult[]) => { + recommendedIndex: number | null; + reason: string; + }; + stylePromptText: (text: string, tone: PromptTone) => string; + formatResultSummary: ( + segments: ReadonlyArray<{ text: string; tone: PromptTone }>, + ) => string; + styleQuotaSummary: (summary: string) => string; + formatCompactQuotaSnapshot: (snapshot: CodexQuotaSnapshot) => string; + availabilityTone: ( + availability: ForecastAccountResult["availability"], + ) => "success" | "warning" | "danger"; + riskTone: ( + level: ForecastAccountResult["riskLevel"], + ) => "success" | "warning" | "danger"; + formatWaitTime: (ms: number) => string; + defaultDisplay: DashboardDisplaySettings; + logInfo?: (message: string) => void; + logError?: (message: string) => void; + getNow?: () => number; +} + +function printForecastUsage(logInfo: (message: string) => void): void { + logInfo( + [ + "Usage:", + " codex auth forecast [--live] [--json] [--model ]", + "", + "Options:", + " --live, -l Probe live quota headers via Codex backend", + " --json, -j Print machine-readable JSON output", + " --model, -m Probe model for live mode (default: gpt-5-codex)", + ].join("\n"), + ); +} + +function parseForecastArgs( + args: string[], +): ParsedArgsResult { + const options: ForecastCliOptions = { + live: false, + json: false, + model: "gpt-5-codex", + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) continue; + if (arg === "--live" || arg === "-l") { + options.live = true; + continue; + } + if (arg === "--json" || arg === "-j") { + options.json = true; + continue; + } + if (arg === "--model" || arg === "-m") { + const value = args[i + 1]; + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + i += 1; + continue; + } + if (arg.startsWith("--model=")) { + const value = arg.slice("--model=".length).trim(); + if (!value) return { ok: false, message: "Missing value for --model" }; + options.model = value; + continue; + } + return { ok: false, message: `Unknown option: ${arg}` }; + } + + return { ok: true, options }; +} + +function serializeForecastResults( + results: ForecastAccountResult[], + liveQuotaByIndex: Map, + refreshFailures: Map, +): Array<{ + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAccountResult["availability"]; + riskScore: number; + riskLevel: ForecastAccountResult["riskLevel"]; + waitMs: number; + reasons: string[]; + liveQuota?: { + status: number; + planType?: string; + activeLimit?: number; + model: string; + summary: string; + }; + refreshFailure?: TokenFailure; +}> { + return results.map((result) => { + const liveQuota = liveQuotaByIndex.get(result.index); + return { + index: result.index, + label: result.label, + isCurrent: result.isCurrent, + availability: result.availability, + riskScore: result.riskScore, + riskLevel: result.riskLevel, + waitMs: result.waitMs, + reasons: result.reasons, + liveQuota: liveQuota + ? { + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: deps_formatQuotaSnapshotLine(liveQuota), + } + : undefined, + refreshFailure: refreshFailures.get(result.index), + }; + }); +} + +let deps_formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; + +export async function runForecastCommand( + args: string[], + deps: ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; + }, +): Promise { + deps_formatQuotaSnapshotLine = deps.formatQuotaSnapshotLine; + const logInfo = deps.logInfo ?? console.log; + const logError = deps.logError ?? console.error; + if (args.includes("--help") || args.includes("-h")) { + printForecastUsage(logInfo); + return 0; + } + + const parsedArgs = parseForecastArgs(args); + if (!parsedArgs.ok) { + logError(parsedArgs.message); + printForecastUsage(logInfo); + return 1; + } + const options = parsedArgs.options; + const display = deps.defaultDisplay; + const quotaCache = options.live ? await deps.loadQuotaCache() : null; + const workingQuotaCache = quotaCache + ? deps.cloneQuotaCacheData(quotaCache) + : null; + let quotaCacheChanged = false; + + deps.setStoragePath(null); + const storage = await deps.loadAccounts(); + if (!storage || storage.accounts.length === 0) { + logInfo("No accounts configured."); + return 0; + } + const quotaEmailFallbackState = + options.live && quotaCache + ? deps.buildQuotaEmailFallbackState(storage.accounts) + : null; + + const now = deps.getNow?.() ?? Date.now(); + const activeIndex = deps.resolveActiveIndex(storage, "codex"); + const refreshFailures = new Map(); + const liveQuotaByIndex = new Map(); + const probeErrors: string[] = []; + + for (let i = 0; i < storage.accounts.length; i += 1) { + const account = storage.accounts[i]; + if (!account || !options.live) continue; + if (account.enabled === false) continue; + + let probeAccessToken = account.accessToken; + let probeAccountId = + account.accountId ?? deps.extractAccountId(account.accessToken); + if (!deps.hasUsableAccessToken(account, now)) { + const refreshResult = await deps.queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + refreshFailures.set(i, { + ...refreshResult, + message: deps.normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), + }); + continue; + } + probeAccessToken = refreshResult.access; + probeAccountId = + account.accountId ?? deps.extractAccountId(refreshResult.access); + } + + if (!probeAccessToken || !probeAccountId) { + probeErrors.push( + `${deps.formatAccountLabel(account, i)}: missing accountId for live probe`, + ); + continue; + } + + try { + const liveQuota = await deps.fetchCodexQuotaSnapshot({ + accountId: probeAccountId, + accessToken: probeAccessToken, + model: options.model, + }); + liveQuotaByIndex.set(i, liveQuota); + if (workingQuotaCache) { + const nextAccount = storage.accounts[i]; + if (nextAccount) { + quotaCacheChanged = + deps.updateQuotaCacheForAccount( + workingQuotaCache, + nextAccount, + liveQuota, + storage.accounts, + quotaEmailFallbackState ?? undefined, + ) || quotaCacheChanged; + } + } + } catch (error) { + const message = deps.normalizeFailureDetail( + error instanceof Error ? error.message : String(error), + undefined, + ); + probeErrors.push(`${deps.formatAccountLabel(account, i)}: ${message}`); + } + } + + const forecastInputs = storage.accounts.map((account, index) => ({ + index, + account, + isCurrent: index === activeIndex, + now, + refreshFailure: refreshFailures.get(index), + liveQuota: liveQuotaByIndex.get(index), + })); + const forecastResults = deps.evaluateForecastAccounts(forecastInputs); + const summary = deps.summarizeForecast(forecastResults); + const recommendation = deps.recommendForecastAccount(forecastResults); + + if (options.json) { + if (workingQuotaCache && quotaCacheChanged) { + await deps.saveQuotaCache(workingQuotaCache); + } + logInfo( + JSON.stringify( + { + command: "forecast", + model: options.model, + liveProbe: options.live, + summary, + recommendation, + probeErrors, + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), + }, + null, + 2, + ), + ); + return 0; + } + + logInfo( + deps.stylePromptText( + `Best-account preview (${storage.accounts.length} account(s), model ${options.model}, live check ${options.live ? "on" : "off"})`, + "accent", + ), + ); + logInfo( + deps.formatResultSummary([ + { text: `${summary.ready} ready now`, tone: "success" }, + { text: `${summary.delayed} waiting`, tone: "warning" }, + { + text: `${summary.unavailable} unavailable`, + tone: summary.unavailable > 0 ? "danger" : "muted", + }, + { + text: `${summary.highRisk} high risk`, + tone: summary.highRisk > 0 ? "danger" : "muted", + }, + ]), + ); + logInfo(""); + + for (const result of forecastResults) { + if (!display.showPerAccountRows) continue; + const currentTag = result.isCurrent ? " [current]" : ""; + const waitLabel = + result.waitMs > 0 + ? deps.stylePromptText( + `wait ${deps.formatWaitTime(result.waitMs)}`, + "muted", + ) + : ""; + const indexLabel = deps.stylePromptText(`${result.index + 1}.`, "accent"); + const accountLabel = deps.stylePromptText( + `${result.label}${currentTag}`, + "accent", + ); + const riskLabel = deps.stylePromptText( + `${result.riskLevel} risk (${result.riskScore})`, + deps.riskTone(result.riskLevel), + ); + const availabilityLabel = deps.stylePromptText( + result.availability, + deps.availabilityTone(result.availability), + ); + const rowParts = [availabilityLabel, riskLabel]; + if (waitLabel) rowParts.push(waitLabel); + logInfo( + `${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${rowParts.join(deps.stylePromptText(" | ", "muted"))}`, + ); + if (display.showForecastReasons && result.reasons.length > 0) { + logInfo( + ` ${deps.stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, + ); + } + const liveQuota = liveQuotaByIndex.get(result.index); + if (display.showQuotaDetails && liveQuota) { + logInfo( + ` ${deps.stylePromptText("quota:", "accent")} ${deps.styleQuotaSummary(deps.formatCompactQuotaSnapshot(liveQuota))}`, + ); + } + } + + if (!display.showPerAccountRows) { + logInfo( + deps.stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); + } + + if (display.showRecommendations) { + logInfo(""); + if (recommendation.recommendedIndex !== null) { + const index = recommendation.recommendedIndex; + const account = forecastResults.find((result) => result.index === index); + if (account) { + logInfo( + `${deps.stylePromptText("Best next account:", "accent")} ${deps.stylePromptText(`${index + 1} (${account.label})`, "success")}`, + ); + logInfo( + `${deps.stylePromptText("Why:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + if (index !== activeIndex) { + logInfo( + `${deps.stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, + ); + } + } + } else { + logInfo( + `${deps.stylePromptText("Note:", "accent")} ${deps.stylePromptText(recommendation.reason, "muted")}`, + ); + } + } + + if (display.showLiveProbeNotes && probeErrors.length > 0) { + logInfo(""); + logInfo( + deps.stylePromptText( + `Live check notes (${probeErrors.length}):`, + "warning", + ), + ); + for (const error of probeErrors) { + logInfo( + ` ${deps.stylePromptText("-", "warning")} ${deps.stylePromptText(error, "muted")}`, + ); + } + } + if (workingQuotaCache && quotaCacheChanged) { + await deps.saveQuotaCache(workingQuotaCache); + } + + return 0; +} diff --git a/test/codex-manager-forecast-command.test.ts b/test/codex-manager-forecast-command.test.ts new file mode 100644 index 00000000..6d0d5128 --- /dev/null +++ b/test/codex-manager-forecast-command.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type ForecastCommandDeps, + runForecastCommand, +} from "../lib/codex-manager/commands/forecast.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +function createStorage(): AccountStorageV3 { + return { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "forecast@example.com", + refreshToken: "refresh-forecast", + accessToken: "access-forecast", + expiresAt: Date.now() + 60_000, + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + ], + }; +} + +function createDeps( + overrides: Partial< + ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: unknown) => string; + } + > = {}, +): ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: unknown) => string; +} { + return { + setStoragePath: vi.fn(), + loadAccounts: vi.fn(async () => createStorage()), + resolveActiveIndex: vi.fn(() => 0), + loadQuotaCache: vi.fn(async () => ({ byAccountId: {}, byEmail: {} })), + saveQuotaCache: vi.fn(async () => undefined), + cloneQuotaCacheData: vi.fn((cache) => structuredClone(cache)), + buildQuotaEmailFallbackState: vi.fn(() => new Map()), + updateQuotaCacheForAccount: vi.fn(() => false), + hasUsableAccessToken: vi.fn(() => true), + queuedRefresh: vi.fn(async () => ({ + type: "success", + access: "access-forecast", + refresh: "refresh-forecast", + expires: Date.now() + 60_000, + })), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + normalizeFailureDetail: vi.fn((message) => message ?? "unknown"), + formatAccountLabel: vi.fn( + (_account, index) => `${index + 1}. forecast@example.com`, + ), + extractAccountId: vi.fn(() => "account-id"), + evaluateForecastAccounts: vi.fn(() => [ + { + index: 0, + label: "1. forecast@example.com", + isCurrent: true, + availability: "ready", + riskScore: 0, + riskLevel: "low", + waitMs: 0, + reasons: ["healthy"], + }, + ]), + summarizeForecast: vi.fn(() => ({ + total: 1, + ready: 1, + delayed: 0, + unavailable: 0, + highRisk: 0, + })), + recommendForecastAccount: vi.fn(() => ({ + recommendedIndex: 0, + reason: "lowest risk", + })), + stylePromptText: vi.fn((text) => text), + formatResultSummary: vi.fn((segments) => + segments.map((segment) => segment.text).join(" | "), + ), + styleQuotaSummary: vi.fn((summary) => summary), + formatCompactQuotaSnapshot: vi.fn(() => "5h 75%"), + availabilityTone: vi.fn(() => "success"), + riskTone: vi.fn(() => "success"), + formatWaitTime: vi.fn(() => "1m"), + defaultDisplay: { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }, + formatQuotaSnapshotLine: vi.fn(() => "quota summary"), + logInfo: vi.fn(), + logError: vi.fn(), + getNow: vi.fn(() => 1_000), + ...overrides, + } as ForecastCommandDeps & { + formatQuotaSnapshotLine: (snapshot: unknown) => string; + }; +} + +describe("runForecastCommand", () => { + it("prints usage for help", async () => { + const deps = createDeps(); + const result = await runForecastCommand(["--help"], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining("codex auth forecast"), + ); + }); + + it("rejects invalid options", async () => { + const deps = createDeps(); + const result = await runForecastCommand(["--bogus"], deps); + expect(result).toBe(1); + expect(deps.logError).toHaveBeenCalledWith("Unknown option: --bogus"); + }); + + it("prints json output for populated storage", async () => { + const deps = createDeps(); + const result = await runForecastCommand(["--json"], deps); + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"command": "forecast"'), + ); + }); +}); From 26f7219e50cb81422ca2f0edf6548c5525748423 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 21 Mar 2026 05:05:54 +0800 Subject: [PATCH 2/2] fix: isolate forecast formatter state --- lib/codex-manager/commands/forecast.ts | 17 ++++-- test/codex-manager-forecast-command.test.ts | 57 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lib/codex-manager/commands/forecast.ts b/lib/codex-manager/commands/forecast.ts index 5793285c..2d33a1a0 100644 --- a/lib/codex-manager/commands/forecast.ts +++ b/lib/codex-manager/commands/forecast.ts @@ -97,6 +97,14 @@ export interface ForecastCommandDeps { getNow?: () => number; } +function joinStyledSegments( + parts: string[], + styleText: (text: string, tone: PromptTone) => string, +): string { + if (parts.length === 0) return ""; + return parts.join(styleText(" | ", "muted")); +} + function printForecastUsage(logInfo: (message: string) => void): void { logInfo( [ @@ -154,6 +162,7 @@ function serializeForecastResults( results: ForecastAccountResult[], liveQuotaByIndex: Map, refreshFailures: Map, + formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string, ): Array<{ index: number; label: string; @@ -189,7 +198,7 @@ function serializeForecastResults( planType: liveQuota.planType, activeLimit: liveQuota.activeLimit, model: liveQuota.model, - summary: deps_formatQuotaSnapshotLine(liveQuota), + summary: formatQuotaSnapshotLine(liveQuota), } : undefined, refreshFailure: refreshFailures.get(result.index), @@ -197,15 +206,12 @@ function serializeForecastResults( }); } -let deps_formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; - export async function runForecastCommand( args: string[], deps: ForecastCommandDeps & { formatQuotaSnapshotLine: (snapshot: CodexQuotaSnapshot) => string; }, ): Promise { - deps_formatQuotaSnapshotLine = deps.formatQuotaSnapshotLine; const logInfo = deps.logInfo ?? console.log; const logError = deps.logError ?? console.error; if (args.includes("--help") || args.includes("-h")) { @@ -334,6 +340,7 @@ export async function runForecastCommand( forecastResults, liveQuotaByIndex, refreshFailures, + deps.formatQuotaSnapshotLine, ), }, null, @@ -391,7 +398,7 @@ export async function runForecastCommand( const rowParts = [availabilityLabel, riskLabel]; if (waitLabel) rowParts.push(waitLabel); logInfo( - `${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${rowParts.join(deps.stylePromptText(" | ", "muted"))}`, + `${indexLabel} ${accountLabel} ${deps.stylePromptText("|", "muted")} ${joinStyledSegments(rowParts, deps.stylePromptText)}`, ); if (display.showForecastReasons && result.reasons.length > 0) { logInfo( diff --git a/test/codex-manager-forecast-command.test.ts b/test/codex-manager-forecast-command.test.ts index 6d0d5128..ee1bb62e 100644 --- a/test/codex-manager-forecast-command.test.ts +++ b/test/codex-manager-forecast-command.test.ts @@ -139,4 +139,61 @@ describe("runForecastCommand", () => { expect.stringContaining('"command": "forecast"'), ); }); + + it("keeps concurrent json runs bound to their own quota formatter", async () => { + let releaseSlowLoad: (() => void) | undefined; + const slowLoad = new Promise((resolve) => { + releaseSlowLoad = resolve; + }); + const slowDeps = createDeps({ + loadAccounts: vi.fn(async () => { + await slowLoad; + return createStorage(); + }), + formatQuotaSnapshotLine: vi.fn(() => "slow quota"), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + }); + const fastDeps = createDeps({ + formatQuotaSnapshotLine: vi.fn(() => "fast quota"), + fetchCodexQuotaSnapshot: vi.fn(async () => ({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + })), + }); + + const slowRun = runForecastCommand(["--json", "--live"], slowDeps); + const fastRun = runForecastCommand(["--json", "--live"], fastDeps); + releaseSlowLoad?.(); + + const [slowResult, fastResult] = await Promise.all([slowRun, fastRun]); + + expect(slowResult).toBe(0); + expect(fastResult).toBe(0); + expect(slowDeps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"summary": "slow quota"'), + ); + expect(fastDeps.logInfo).toHaveBeenCalledWith( + expect.stringContaining('"summary": "fast quota"'), + ); + }); + + it("keeps muted separators between styled forecast row segments", async () => { + const deps = createDeps({ + stylePromptText: vi.fn((text, tone) => `<${tone}>${text}`), + }); + + const result = await runForecastCommand([], deps); + + expect(result).toBe(0); + expect(deps.logInfo).toHaveBeenCalledWith( + '1. 1. forecast@example.com [current] | ready | low risk (0)', + ); + }); });