diff --git a/index.ts b/index.ts index e29c9179..1df6ceef 100644 --- a/index.ts +++ b/index.ts @@ -55,6 +55,8 @@ import { getSessionRecovery, getAutoResume, getToastDurationMs, + getPersistAccountFooter, + getPersistAccountFooterStyle, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, @@ -65,6 +67,8 @@ import { getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, + DEFAULT_CONFIG, + isFallbackPluginConfig, loadPluginConfig, } from "./lib/config.js"; import { @@ -83,6 +87,7 @@ import { logInfo, logWarn, logError, + maskEmail, setCorrelationId, clearCorrelationId, } from "./lib/logger.js"; @@ -123,6 +128,12 @@ import { type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; +import type { + PersistAccountFooterStyle, + PersistedAccountDetails, + PersistedAccountIndicatorEntry, + SessionModelRef, +} from "./lib/persist-account-footer.js"; import { createCodexHeaders, extractRequestUrl, @@ -199,6 +210,8 @@ import { * } * ``` */ +export const MAX_PERSISTED_ACCOUNT_INDICATORS = 200; + // eslint-disable-next-line @typescript-eslint/require-await export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { initLogger(client); @@ -371,13 +384,63 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); } - return { - primary, - variantsForPersistence, - }; + return { + primary, + variantsForPersistence, }; + }; + + const getPersistedAccountLabel = ( + account: PersistedAccountDetails, + index: number, + ): string => { + const accountLabel = account.accountLabel?.trim(); + const storedAccountId = account.accountId?.trim(); + const tokenAccountId = extractAccountId( + (typeof account.access === "string" && account.access.trim()) || + (typeof account.accessToken === "string" && account.accessToken.trim()) || + undefined, + ); + const accountId = + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + storedAccountId, + ) + ? tokenAccountId + : storedAccountId; + const idSuffix = accountId + ? accountId.length > 6 + ? accountId.slice(-6) + : accountId + : null; + return accountLabel || + (idSuffix ? `Account ${index + 1} [id:${idSuffix}]` : `Account ${index + 1}`); + }; - const buildManualOAuthFlow = ( + const getPersistedAccountValue = ( + account: PersistedAccountDetails, + index: number, + style: PersistAccountFooterStyle, + ): string => { + const sanitizedEmail = sanitizeEmail(account.email); + if (style === "label-only" || !sanitizedEmail) { + return getPersistedAccountLabel(account, index); + } + return style === "full-email" ? sanitizedEmail : maskEmail(sanitizedEmail); + }; + + const formatPersistedAccountIndicator = ( + account: PersistedAccountDetails, + index: number, + accountCount: number, + style: PersistAccountFooterStyle, + ): string => { + const accountPosition = `[${index + 1}/${Math.max(1, accountCount)}]`; + return `${getPersistedAccountValue(account, index, style)} ${accountPosition}`; + }; + + const buildManualOAuthFlow = ( pkce: { verifier: string }, url: string, expectedState: string, @@ -1084,6 +1147,155 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; + const persistedAccountIndicators = new Map(); + let persistedAccountIndicatorRevision = 0; + let persistedAccountCountHint = 0; + let runtimePersistAccountFooter = false; + let runtimePersistAccountFooterStyle: PersistAccountFooterStyle = + "label-masked-email"; + let runtimePluginConfigSnapshot: ReturnType | undefined; + + const nextPersistedAccountIndicatorRevision = (): number => { + persistedAccountIndicatorRevision += 1; + return persistedAccountIndicatorRevision; + }; + + const updatePersistedAccountCountHint = ( + count: number | null | undefined, + ): void => { + if (typeof count !== "number" || !Number.isFinite(count)) { + return; + } + persistedAccountCountHint = Math.max(0, Math.trunc(count)); + }; + + const resetPersistedAccountFooterState = (): void => { + persistedAccountIndicators.clear(); + persistedAccountCountHint = 0; + }; + + const resolvePersistedIndicatorSessionID = ( + ...candidates: Array + ): string | undefined => { + const runtimeThreadId = process.env.CODEX_THREAD_ID?.toString().trim(); + if (runtimeThreadId) { + return runtimeThreadId; + } + for (const candidate of candidates) { + const sessionID = candidate?.toString().trim(); + if (sessionID) { + return sessionID; + } + } + return undefined; + }; + + const trimPersistedAccountIndicators = (): void => { + // setPersistedAccountIndicator() is the only insertion path and adds at + // most one new entry per call, so a single oldest-entry eviction is + // sufficient to restore the cap. + if (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { + const oldestKey = persistedAccountIndicators.keys().next().value; + if (oldestKey === undefined) return; + persistedAccountIndicators.delete(oldestKey); + } + }; + + const setPersistedAccountIndicator = ( + sessionID: string | null | undefined, + account: PersistedAccountDetails, + index: number, + accountCount: number, + style: PersistAccountFooterStyle, + revision: number, + ): boolean => { + if (!sessionID) return false; + const existing = persistedAccountIndicators.get(sessionID); + if (existing && existing.revision > revision) { + return false; + } + const nextEntry = { + label: formatPersistedAccountIndicator(account, index, accountCount, style), + revision, + }; + if (existing) { + // Default writes are true LRU touches: reinserting moves active sessions + // to the tail so only inactive sessions age out first. + persistedAccountIndicators.delete(sessionID); + } + persistedAccountIndicators.set(sessionID, nextEntry); + trimPersistedAccountIndicators(); + return true; + }; + + const refreshVisiblePersistedAccountIndicators = ( + account: PersistedAccountDetails, + index: number, + accountCount: number, + style: PersistAccountFooterStyle, + ): boolean => { + // Bulk refreshes are intentionally update-only: they only rewrite + // already-tracked sessions, so they never grow the map. New entries + // still have to flow through setPersistedAccountIndicator() where + // trimPersistedAccountIndicators() enforces the LRU cap. + const sessionIDs = Array.from(persistedAccountIndicators.keys()); + if (sessionIDs.length === 0) return false; + const revision = nextPersistedAccountIndicatorRevision(); + const label = formatPersistedAccountIndicator( + account, + index, + accountCount, + style, + ); + for (const sessionID of sessionIDs) { + const existing = persistedAccountIndicators.get(sessionID); + if (existing && existing.revision > revision) { + continue; + } + // Bulk switch refreshes should update the visible label without + // re-promoting every tracked session to the newest LRU position. + persistedAccountIndicators.set(sessionID, { label, revision }); + } + return true; + }; + + const getPersistedAccountIndicatorLabel = ( + sessionID: string | null | undefined, + ): string | undefined => { + if (!sessionID) return undefined; + return persistedAccountIndicators.get(sessionID)?.label; + }; + + const applyPersistedAccountIndicator = ( + messageInfo: Record, + indicatorLabel: string, + fallbackModel?: SessionModelRef, + ): void => { + // `full-email` is an explicit user opt-in for visible variant fields only. + // Keep it out of thinking, and route any logging through `lib/logger.ts`, + // which masks emails in both message strings and structured payloads. + messageInfo.variant = indicatorLabel; + + const existingModel = + typeof messageInfo.model === "object" && messageInfo.model !== null + ? (messageInfo.model as Record) + : {}; + const providerID = + typeof existingModel.providerID === "string" + ? existingModel.providerID + : fallbackModel?.providerID; + const modelID = + typeof existingModel.modelID === "string" + ? existingModel.modelID + : fallbackModel?.modelID; + messageInfo.model = { + ...existingModel, + variant: indicatorLabel, + ...(providerID ? { providerID } : {}), + ...(modelID ? { modelID } : {}), + }; + }; + const resolveActiveIndex = ( storage: { activeIndex: number; @@ -1212,8 +1424,82 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; + const syncRuntimePluginConfig = ( + pluginConfig: ReturnType, + ): { + persistAccountFooter: boolean; + persistAccountFooterStyle: PersistAccountFooterStyle; + ui: UiRuntimeOptions; + } => { + const resolvedPluginConfig = + isFallbackPluginConfig(pluginConfig) && + runtimePluginConfigSnapshot !== undefined + ? runtimePluginConfigSnapshot + : pluginConfig; + const persistAccountFooter = getPersistAccountFooter(resolvedPluginConfig); + const persistAccountFooterStyle = + getPersistAccountFooterStyle(resolvedPluginConfig); + // Footer disable transitions intentionally reset the in-memory footer + // state. Authorize flows keep using the cached runtime snapshot here, so + // a transient config-loader fallback does not clear live indicators. + if (runtimePersistAccountFooter && !persistAccountFooter) { + resetPersistedAccountFooterState(); + } + runtimePluginConfigSnapshot = resolvedPluginConfig; + runtimePersistAccountFooter = persistAccountFooter; + runtimePersistAccountFooterStyle = persistAccountFooterStyle; + return { + persistAccountFooter, + persistAccountFooterStyle, + ui: applyUiRuntimeFromConfig(resolvedPluginConfig), + }; + }; + const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); + return applyUiRuntimeFromConfig(resolveRuntimePluginConfig()); + }; + + const resolveRuntimePluginConfig = (): ReturnType => { + return runtimePluginConfigSnapshot ?? loadPluginConfig(); + }; + + const refreshAuthorizeStoragePath = ( + initialConfig?: ReturnType, + ): ReturnType => { + // Auth writes should honor the latest per-project setting, but a Windows + // config-file lock can make loadPluginConfig() fall back to a marked + // default config. + // If we already have a runtime snapshot, keep using it instead of silently + // routing auth writes to the wrong storage path. + let storagePluginConfig = + initialConfig ?? runtimePluginConfigSnapshot ?? DEFAULT_CONFIG; + const shouldRefreshStorageConfig = + !initialConfig || isFallbackPluginConfig(initialConfig); + if (shouldRefreshStorageConfig) { + try { + const refreshedPluginConfig = loadPluginConfig(); + if ( + isFallbackPluginConfig(refreshedPluginConfig) && + runtimePluginConfigSnapshot + ) { + logWarn( + "Falling back to cached authorize storage config after config loader returned defaults.", + ); + } else { + storagePluginConfig = refreshedPluginConfig; + } + } catch (error) { + if (!runtimePluginConfigSnapshot) { + throw error; + } + logWarn( + `Falling back to cached authorize storage config after refresh failure: ${(error as Error).message}`, + ); + } + } + const perProjectAccounts = getPerProjectAccounts(storagePluginConfig); + setStoragePath(perProjectAccounts ? process.cwd() : null); + return storagePluginConfig; }; const getStatusMarker = ( @@ -1655,12 +1941,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return; } - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } + const account = storage.accounts[index]; + if (!account) { + return; + } + const preReloadTargetAccount = + cachedAccountManager?.getAccountsSnapshot()[index]; + + const now = Date.now(); + account.lastUsed = now; + account.lastSwitchReason = "rotation"; storage.activeIndex = index; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { @@ -1668,16 +1958,28 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } await saveAccounts(storage); + updatePersistedAccountCountHint(storage.accounts.length); // Reload manager from disk so we don't overwrite newer rotated // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - const reloadedManager = await AccountManager.loadFromDisk(); - cachedAccountManager = reloadedManager; - accountManagerPromise = Promise.resolve(reloadedManager); - } + if (cachedAccountManager) { + const reloadedManager = await AccountManager.loadFromDisk(); + cachedAccountManager = reloadedManager; + accountManagerPromise = Promise.resolve(reloadedManager); + } - await showToast(`Switched to account ${index + 1}`, "info"); + if (runtimePersistAccountFooter) { + refreshVisiblePersistedAccountIndicators( + // Prefer the pre-reload target account so label-only footers keep + // the same token-derived id suffix until disk catches up. + preReloadTargetAccount ?? account, + index, + storage.accounts.length, + runtimePersistAccountFooterStyle, + ); + } else { + await showToast(`Switched to account ${index + 1}`, "info"); + } } } } catch (error) { @@ -1690,6 +1992,70 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return { event: eventHandler, + "chat.message": ( + input: { + sessionID: string; + model?: SessionModelRef; + }, + output: { message: unknown; parts: unknown[] }, + ): Promise => { + if (!runtimePersistAccountFooter) { + return Promise.resolve(); + } + const indicator = getPersistedAccountIndicatorLabel( + resolvePersistedIndicatorSessionID(input.sessionID), + ); + if (indicator) { + const message = + typeof output.message === "object" && output.message !== null + ? (output.message as Record) + : null; + const messageRole = + typeof message?.role === "string" ? message.role : undefined; + if (message && messageRole === "user") { + applyPersistedAccountIndicator(message, indicator, input.model); + } + } + return Promise.resolve(); + }, + "experimental.chat.messages.transform": ( + _input: Record, + output: { + messages: Array<{ + info: Record; + parts: unknown[]; + }>; + }, + ): Promise => { + if (!runtimePersistAccountFooter) { + return Promise.resolve(); + } + let lastUserMessage: + | { + info: Record; + parts: unknown[]; + } + | undefined; + for (let i = output.messages.length - 1; i >= 0; i -= 1) { + const message = output.messages[i]; + if (message?.info.role === "user") { + lastUserMessage = message; + break; + } + } + if (!lastUserMessage) return Promise.resolve(); + + const sessionID = resolvePersistedIndicatorSessionID( + typeof lastUserMessage.info.sessionID === "string" + ? lastUserMessage.info.sessionID + : undefined, + ); + const indicator = getPersistedAccountIndicatorLabel(sessionID); + if (!indicator) return Promise.resolve(); + + applyPersistedAccountIndicator(lastUserMessage.info, indicator); + return Promise.resolve(); + }, auth: { provider: PROVIDER_ID, /** @@ -1709,7 +2075,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { async loader(getAuth: () => Promise, provider: unknown) { const auth = await getAuth(); const pluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(pluginConfig); + syncRuntimePluginConfig(pluginConfig); const perProjectAccounts = getPerProjectAccounts(pluginConfig); setStoragePath(perProjectAccounts ? process.cwd() : null); const authFallback = auth.type === "oauth" ? (auth as OAuthAuthDetails) : undefined; @@ -1745,6 +2111,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } let accountManager = await accountManagerPromise; cachedAccountManager = accountManager; + updatePersistedAccountCountHint(accountManager.getAccountCount()); const refreshToken = authFallback?.refresh ?? ""; const needsPersist = refreshToken && @@ -1855,6 +2222,23 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ) : null; + const showTerminalToastResponse = async ( + message: string, + status: 429 | 503, + ): Promise => { + await showToast( + message, + status === 429 ? "warning" : "error", + { duration: toastDurationMs }, + ); + return new Response(JSON.stringify({ error: { message } }), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + }, + }); + }; + checkAndNotify(async (message, variant) => { await showToast(message, variant); }).catch((err) => { @@ -1887,6 +2271,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { init?: RequestInit, ): Promise { try { + // Re-apply env overrides against the loader's config snapshot without + // another disk read on the request hot path. Use request-local copies + // below so concurrent fetches cannot observe another request's footer state. + const { + persistAccountFooter, + persistAccountFooterStyle, + } = syncRuntimePluginConfig(pluginConfig); if (cachedAccountManager && cachedAccountManager !== accountManager) { accountManager = cachedAccountManager; } @@ -1988,10 +2379,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let model = transformedBody?.model; let modelFamily = model ? getModelFamily(model) : "gpt-5.4"; let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; + // When the host provides a runtime thread id, prefer it over + // prompt_cache_key so the fetch path stores indicators under the + // same session key that the chat hooks resolve later. Without + // CODEX_THREAD_ID, the host has to reuse the same session id in the + // hooks or the persisted footer cannot be resolved back. const threadIdCandidate = - (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") - .toString() - .trim() || undefined; + resolvePersistedIndicatorSessionID(promptCacheKey); + const indicatorRevision = persistAccountFooter + ? nextPersistedAccountIndicatorRevision() + : 0; const requestCorrelationId = setCorrelationId( threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, ); @@ -2138,19 +2535,9 @@ while (attempted.size < Math.max(1, accountCount)) { `Auth refresh failed for account ${account.index + 1}`, ) ) { - return new Response( - JSON.stringify({ - error: { - message: - "Auth refresh retry budget exhausted for this request. Try again or switch accounts.", - }, - }), - { - status: 503, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }, + return await showTerminalToastResponse( + "Auth refresh retry budget exhausted for this request. Try again or switch accounts.", + 503, ); } runtimeMetrics.authRefreshFailures++; @@ -2227,12 +2614,13 @@ while (attempted.size < Math.max(1, accountCount)) { account.email = extractAccountEmail(accountAuth.access) ?? account.email; - if ( - accountCount > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) + if ( + !persistAccountFooter && + accountCount > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) ) { const accountLabel = formatAccountLabel(account, account.index); await showToast( @@ -2307,19 +2695,9 @@ while (attempted.size < Math.max(1, accountCount)) { ) ) { accountManager.refundToken(account, modelFamily, model); - return new Response( - JSON.stringify({ - error: { - message: - "Network retry budget exhausted for this request. Try again in a moment.", - }, - }), - { - status: 503, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }, + return await showTerminalToastResponse( + "Network retry budget exhausted for this request. Try again in a moment.", + 503, ); } runtimeMetrics.failedRequests++; @@ -2617,6 +2995,23 @@ while (attempted.size < Math.max(1, accountCount)) { } accountManager.recordSuccess(account, modelFamily, model); + if (persistAccountFooter) { + const liveAccountCount = accountManager.getAccountCount(); + const persistedAccountCount = + liveAccountCount > 0 + ? liveAccountCount + : persistedAccountCountHint > 0 + ? persistedAccountCountHint + : 1; + setPersistedAccountIndicator( + threadIdCandidate, + account, + account.index, + persistedAccountCount, + persistAccountFooterStyle, + indicatorRevision, + ); + } runtimeMetrics.successfulRequests++; runtimeMetrics.lastError = null; runtimeMetrics.lastErrorCategory = null; @@ -2662,12 +3057,10 @@ while (attempted.size < Math.max(1, accountCount)) { runtimeMetrics.failedRequests++; runtimeMetrics.lastError = message; runtimeMetrics.lastErrorCategory = waitMs > 0 ? "rate-limit" : "account-failure"; - return new Response(JSON.stringify({ error: { message } }), { - status: waitMs > 0 ? 429 : 503, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }); + return await showTerminalToastResponse( + message, + waitMs > 0 ? 429 : 503, + ); } } finally { clearCorrelationId(); @@ -2684,10 +3077,12 @@ while (attempted.size < Math.max(1, accountCount)) { label: AUTH_LABELS.OAUTH, type: "oauth" as const, authorize: async (inputs?: Record) => { - const authPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(authPluginConfig); - const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig); - setStoragePath(authPerProjectAccounts ? process.cwd() : null); + const hadRuntimePluginConfig = runtimePluginConfigSnapshot !== undefined; + const authorizePluginConfig = resolveRuntimePluginConfig(); + const refreshedAuthorizePluginConfig = refreshAuthorizeStoragePath( + hadRuntimePluginConfig ? undefined : authorizePluginConfig, + ); + syncRuntimePluginConfig(refreshedAuthorizePluginConfig); const accounts: TokenSuccessWithAccount[] = []; const noBrowser = @@ -3659,10 +4054,12 @@ while (attempted.size < Math.max(1, accountCount)) { authorize: async () => { // Initialize storage path for manual OAuth flow // Must happen BEFORE persistAccountPool to ensure correct storage location - const manualPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(manualPluginConfig); - const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig); - setStoragePath(manualPerProjectAccounts ? process.cwd() : null); + const hadRuntimePluginConfig = runtimePluginConfigSnapshot !== undefined; + const authorizePluginConfig = resolveRuntimePluginConfig(); + syncRuntimePluginConfig(authorizePluginConfig); + refreshAuthorizeStoragePath( + hadRuntimePluginConfig ? undefined : authorizePluginConfig, + ); const { pkce, state, url } = await createAuthorizationFlow(); return buildManualOAuthFlow(pkce, url, state, async (selection) => { diff --git a/lib/config.ts b/lib/config.ts index af93ee73..a16d7598 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -2,6 +2,10 @@ import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { PluginConfig } from "./types.js"; +import { + PERSIST_ACCOUNT_FOOTER_STYLES, + type PersistAccountFooterStyle, +} from "./persist-account-footer.js"; import { normalizeRetryBudgetValue, type RetryBudgetOverrides, @@ -16,14 +20,28 @@ const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]); const REQUEST_TRANSFORM_MODES = new Set(["native", "legacy"]); const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]); const RETRY_PROFILES = new Set(["conservative", "balanced", "aggressive"]); - +const FALLBACK_PLUGIN_CONFIG = Symbol("fallbackPluginConfig"); export type UnsupportedCodexPolicy = "strict" | "fallback"; +type PluginConfigWithFallbackMarker = PluginConfig & { + [FALLBACK_PLUGIN_CONFIG]?: true; +}; + +const markFallbackPluginConfig = (config: T): T => { + if ((config as PluginConfigWithFallbackMarker)[FALLBACK_PLUGIN_CONFIG]) { + return config; + } + Object.defineProperty(config, FALLBACK_PLUGIN_CONFIG, { + value: true, + enumerable: false, + }); + return config; +}; /** * Default plugin configuration * CODEX_MODE is enabled by default for better Codex CLI parity */ -const DEFAULT_CONFIG: PluginConfig = { +export const DEFAULT_CONFIG: PluginConfig = markFallbackPluginConfig({ codexMode: true, requestTransformMode: "native", codexTuiV2: true, @@ -45,6 +63,8 @@ const DEFAULT_CONFIG: PluginConfig = { tokenRefreshSkewMs: 60_000, rateLimitToastDebounceMs: 60_000, toastDurationMs: 5_000, + persistAccountFooter: false, + persistAccountFooterStyle: "label-masked-email", perProjectAccounts: true, sessionRecovery: true, autoResume: true, @@ -55,7 +75,22 @@ const DEFAULT_CONFIG: PluginConfig = { pidOffsetEnabled: false, fetchTimeoutMs: 60_000, streamStallTimeoutMs: 45_000, -}; +}); + +function createFallbackPluginConfig(): PluginConfig { + // Spread drops the non-enumerable fallback marker, so exact-default config + // files stay distinct from loader fallbacks. + return markFallbackPluginConfig({ ...DEFAULT_CONFIG }); +} + +export function isFallbackPluginConfig( + pluginConfig: PluginConfig | undefined, +): boolean { + return !!( + pluginConfig && + (pluginConfig as PluginConfigWithFallbackMarker)[FALLBACK_PLUGIN_CONFIG] + ); +} /** * Load plugin configuration from ~/.opencode/openai-codex-auth-config.json @@ -66,7 +101,7 @@ const DEFAULT_CONFIG: PluginConfig = { export function loadPluginConfig(): PluginConfig { try { if (!existsSync(CONFIG_PATH)) { - return DEFAULT_CONFIG; + return createFallbackPluginConfig(); } const fileContent = readFileSync(CONFIG_PATH, "utf-8"); @@ -102,7 +137,7 @@ export function loadPluginConfig(): PluginConfig { logWarn( `Failed to load config from ${CONFIG_PATH}: ${(error as Error).message}`, ); - return DEFAULT_CONFIG; + return createFallbackPluginConfig(); } } @@ -433,6 +468,25 @@ export function getToastDurationMs(pluginConfig: PluginConfig): number { ); } +export function getPersistAccountFooter(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_PERSIST_ACCOUNT_FOOTER", + pluginConfig.persistAccountFooter, + false, + ); +} + +export function getPersistAccountFooterStyle( + pluginConfig: PluginConfig, +): PersistAccountFooterStyle { + return resolveStringSetting( + "CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE", + pluginConfig.persistAccountFooterStyle, + "label-masked-email", + new Set(PERSIST_ACCOUNT_FOOTER_STYLES), + ); +} + export function getPerProjectAccounts(pluginConfig: PluginConfig): boolean { return resolveBooleanSetting( "CODEX_AUTH_PER_PROJECT_ACCOUNTS", diff --git a/lib/persist-account-footer.ts b/lib/persist-account-footer.ts new file mode 100644 index 00000000..a78df6f3 --- /dev/null +++ b/lib/persist-account-footer.ts @@ -0,0 +1,29 @@ +import type { AccountIdSource } from "./types.js"; + +export const PERSIST_ACCOUNT_FOOTER_STYLES = [ + "label-masked-email", + "full-email", + "label-only", +] as const; + +export type PersistAccountFooterStyle = + (typeof PERSIST_ACCOUNT_FOOTER_STYLES)[number]; + +export type PersistedAccountDetails = { + accountId?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + email?: string; + access?: string; + accessToken?: string; +}; + +export type SessionModelRef = { + providerID: string; + modelID: string; +}; + +export type PersistedAccountIndicatorEntry = { + label: string; + revision: number; +}; diff --git a/lib/schemas.ts b/lib/schemas.ts index 6028246d..e269af76 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -4,6 +4,7 @@ * Types are inferred from schemas using z.infer. */ import { z } from "zod"; +import { PERSIST_ACCOUNT_FOOTER_STYLES } from "./persist-account-footer.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; // ============================================================================ @@ -42,6 +43,8 @@ export const PluginConfigSchema = z.object({ tokenRefreshSkewMs: z.number().min(0).optional(), rateLimitToastDebounceMs: z.number().min(0).optional(), toastDurationMs: z.number().min(1000).optional(), + persistAccountFooter: z.boolean().optional(), + persistAccountFooterStyle: z.enum(PERSIST_ACCOUNT_FOOTER_STYLES).optional(), perProjectAccounts: z.boolean().optional(), sessionRecovery: z.boolean().optional(), autoResume: z.boolean().optional(), diff --git a/test/index.test.ts b/test/index.test.ts index daf55c6c..d6c1786e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { SessionModelRef } from "../lib/persist-account-footer.js"; vi.mock("@opencode-ai/plugin/tool", () => { const makeSchema = () => ({ @@ -79,61 +80,89 @@ vi.mock("../lib/cli.js", () => ({ promptAddAnotherAccount: vi.fn(async () => false), })); -vi.mock("../lib/config.js", () => ({ - getCodexMode: () => true, - getRequestTransformMode: () => "native", - getFastSession: () => false, - getFastSessionStrategy: () => "hybrid", - getFastSessionMaxInputItems: () => 30, - getRetryProfile: () => "balanced", - getRetryBudgetOverrides: () => ({}), - getRateLimitToastDebounceMs: () => 5000, - getRetryAllAccountsMaxRetries: () => 3, - getRetryAllAccountsMaxWaitMs: () => 30000, - getRetryAllAccountsRateLimited: () => true, - getUnsupportedCodexPolicy: vi.fn(() => "fallback"), - getFallbackOnUnsupportedCodexModel: vi.fn(() => true), - getFallbackToGpt52OnUnsupportedGpt53: vi.fn(() => false), - getUnsupportedCodexFallbackChain: () => ({}), - getTokenRefreshSkewMs: () => 60000, - getSessionRecovery: () => false, - getAutoResume: () => false, - getToastDurationMs: () => 5000, - getPerProjectAccounts: () => false, - getEmptyResponseMaxRetries: () => 2, - getEmptyResponseRetryDelayMs: () => 1000, - getPidOffsetEnabled: () => false, - getFetchTimeoutMs: () => 60000, - getStreamStallTimeoutMs: () => 45000, - getCodexTuiV2: () => false, - getCodexTuiColorProfile: () => "ansi16", - getCodexTuiGlyphMode: () => "ascii", - getBeginnerSafeMode: () => false, - loadPluginConfig: () => ({}), -})); +vi.mock("../lib/config.js", () => { + const FALLBACK_PLUGIN_CONFIG = Symbol("fallbackPluginConfig"); + const markFallbackPluginConfig = >(config: T): T => { + Object.defineProperty(config, FALLBACK_PLUGIN_CONFIG, { + value: true, + enumerable: false, + }); + return config; + }; + const DEFAULT_CONFIG = markFallbackPluginConfig({}); + return { + DEFAULT_CONFIG, + getCodexMode: () => true, + getRequestTransformMode: () => "native", + getFastSession: () => false, + getFastSessionStrategy: () => "hybrid", + getFastSessionMaxInputItems: () => 30, + getPersistAccountFooter: vi.fn(() => false), + getPersistAccountFooterStyle: vi.fn(() => "label-masked-email"), + getRetryProfile: () => "balanced", + getRetryBudgetOverrides: () => ({}), + getRateLimitToastDebounceMs: () => 5000, + getRetryAllAccountsMaxRetries: vi.fn(() => 3), + getRetryAllAccountsMaxWaitMs: vi.fn(() => 30000), + getRetryAllAccountsRateLimited: vi.fn(() => true), + getUnsupportedCodexPolicy: vi.fn(() => "fallback"), + getFallbackOnUnsupportedCodexModel: vi.fn(() => true), + getFallbackToGpt52OnUnsupportedGpt53: vi.fn(() => false), + getUnsupportedCodexFallbackChain: () => ({}), + getTokenRefreshSkewMs: () => 60000, + getSessionRecovery: () => false, + getAutoResume: () => false, + getToastDurationMs: () => 5000, + getPerProjectAccounts: vi.fn(() => false), + getEmptyResponseMaxRetries: () => 2, + getEmptyResponseRetryDelayMs: () => 1000, + getPidOffsetEnabled: () => false, + getFetchTimeoutMs: () => 60000, + getStreamStallTimeoutMs: () => 45000, + getCodexTuiV2: () => false, + getCodexTuiColorProfile: () => "ansi16", + getCodexTuiGlyphMode: () => "ascii", + getBeginnerSafeMode: () => false, + isFallbackPluginConfig: vi.fn( + (config) => + !!config && + (config as Record)[FALLBACK_PLUGIN_CONFIG] === true, + ), + // NOTE: loadPluginConfig returns a fresh {} by default (not marked as a + // loader fallback). Tests that exercise the fallback marker should return + // DEFAULT_CONFIG explicitly. + loadPluginConfig: vi.fn(() => ({})), + }; +}); vi.mock("../lib/request/request-transformer.js", () => ({ applyFastSessionDefaults: (config: T) => config, })); -vi.mock("../lib/logger.js", () => ({ - initLogger: vi.fn(), - logRequest: vi.fn(), - logDebug: vi.fn(), - logInfo: vi.fn(), - logWarn: vi.fn(), - logError: vi.fn(), - setCorrelationId: vi.fn(() => "test-correlation-id"), - clearCorrelationId: vi.fn(), - createLogger: vi.fn(() => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - time: vi.fn(() => vi.fn(() => 0)), - timeEnd: vi.fn(), - })), -})); +vi.mock("../lib/logger.js", async () => { + const actual = await vi.importActual( + "../lib/logger.js", + ); + return { + ...actual, + initLogger: vi.fn(), + logRequest: vi.fn(), + logDebug: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), + setCorrelationId: vi.fn(() => "test-correlation-id"), + clearCorrelationId: vi.fn(), + createLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + time: vi.fn(() => vi.fn(() => 0)), + timeEnd: vi.fn(), + })), + }; +}); vi.mock("../lib/auto-update-checker.js", () => ({ checkAndNotify: vi.fn(async () => {}), @@ -390,6 +419,28 @@ type ToolExecute = { execute: (args: T) => Promise }; type OptionalToolExecute = { execute: (args?: T) => Promise }; type PluginType = { event: (input: { event: { type: string; properties?: unknown } }) => Promise; + "chat.message": ( + input: { + sessionID: string; + model?: SessionModelRef; + }, + output: { message: unknown; parts: unknown[] }, + ) => Promise; + "experimental.chat.messages.transform": ( + input: Record, + output: { + messages: Array<{ + info: { + role: string; + sessionID?: string; + model?: Partial & { variant?: string }; + variant?: string; + thinking?: string; + }; + parts: unknown[]; + }>; + }, + ) => Promise; auth: { provider: string; methods: Array<{ label: string; type: string }>; @@ -1974,9 +2025,23 @@ describe("OpenAIOAuthPlugin edge cases", () => { describe("OpenAIOAuthPlugin fetch handler", () => { let originalFetch: typeof globalThis.fetch; + let originalThreadId: string | undefined; - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getPersistAccountFooter).mockReturnValue(false); + vi.mocked(configModule.getPersistAccountFooterStyle).mockReturnValue( + "label-masked-email", + ); + vi.mocked(configModule.getRetryAllAccountsMaxRetries).mockReturnValue(3); + vi.mocked(configModule.getRetryAllAccountsMaxWaitMs).mockReturnValue(30000); + vi.mocked(configModule.getRetryAllAccountsRateLimited).mockReturnValue(true); + vi.mocked(configModule.getUnsupportedCodexPolicy).mockReturnValue("fallback"); + vi.mocked(configModule.getFallbackOnUnsupportedCodexModel).mockReturnValue(true); + vi.mocked(configModule.getFallbackToGpt52OnUnsupportedGpt53).mockReturnValue(false); + vi.mocked(configModule.loadPluginConfig).mockReturnValue({}); mockStorage.accounts = [ { accountId: "acc-1", @@ -1986,11 +2051,18 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ]; mockStorage.activeIndex = 0; mockStorage.activeIndexByFamily = {}; + originalThreadId = process.env.CODEX_THREAD_ID; + delete process.env.CODEX_THREAD_ID; originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; + if (originalThreadId === undefined) { + delete process.env.CODEX_THREAD_ID; + } else { + process.env.CODEX_THREAD_ID = originalThreadId; + } vi.restoreAllMocks(); }); @@ -2011,22 +2083,91 @@ describe("OpenAIOAuthPlugin fetch handler", () => { return { plugin, sdk, mockClient }; }; - it("returns success response for successful fetch", async () => { + const createPersistedAccountRequestBody = ( + promptCacheKey?: string, + model = "gpt-5.1", + ) => + promptCacheKey + ? { model, prompt_cache_key: promptCacheKey } + : { model }; + + const enablePersistedFooter = async ( + style: "label-masked-email" | "full-email" | "label-only", + ) => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getPersistAccountFooter).mockReturnValue(true); + vi.mocked(configModule.getPersistAccountFooterStyle).mockReturnValue(style); + }; + + const disablePersistedFooter = async () => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getPersistAccountFooter).mockReturnValue(false); + }; + + const sendPersistedAccountRequest = async ( + sdk: Awaited>["sdk"], + promptCacheKey?: string, + model = "gpt-5.1", + ) => { + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const requestBody = createPersistedAccountRequestBody(promptCacheKey, model); + vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ + updatedInit: { + method: "POST", + body: JSON.stringify(requestBody), + }, + body: requestBody, + }); + globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ content: "test" }), { status: 200 }), + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), ); - const { sdk } = await setupPlugin(); - const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + return await sdk.fetch!("https://api.openai.com/v1/chat", { method: "POST", - body: JSON.stringify({ model: "gpt-5.1" }), + body: JSON.stringify(requestBody), }); + }; - expect(response.status).toBe(200); + const expectedMaskedIndicator = "us***@***.com [1/1]"; + const expectedFullIndicator = "user@example.com [1/1]"; + const expectedLabelOnlyIndicator = "Account 1 [id:ount-1] [1/1]"; + + const buildMessageTransformOutput = ( + sessionID: string, + modelID = "gpt-5.1", + ): Parameters[1] => ({ + messages: [ + { + info: { + role: "user", + sessionID, + model: { providerID: "openai", modelID }, + }, + parts: [], + }, + ], }); - it("handles network errors and rotates to next account", async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network timeout")); + const readPersistedAccountIndicator = async ( + plugin: PluginType, + sessionID: string, + modelID = "gpt-5.1", + ) => { + const output = buildMessageTransformOutput(sessionID, modelID); + await plugin["experimental.chat.messages.transform"]({}, output); + return { + variant: + output.messages[0]?.info.model?.variant ?? + output.messages[0]?.info.variant, + thinking: output.messages[0]?.info.thinking, + }; + }; + + it("returns success response for successful fetch", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "test" }), { status: 200 }), + ); const { sdk } = await setupPlugin(); const response = await sdk.fetch!("https://api.openai.com/v1/chat", { @@ -2034,152 +2175,1565 @@ describe("OpenAIOAuthPlugin fetch handler", () => { body: JSON.stringify({ model: "gpt-5.1" }), }); - expect(response.status).toBe(503); - expect(await response.text()).toContain("server errors or auth issues"); + expect(response.status).toBe(200); }); - it("cools down the account when grouped auth removal removes zero entries", async () => { - const fetchHelpers = await import("../lib/request/fetch-helpers.js"); - const { AccountManager } = await import("../lib/accounts.js"); - const { ACCOUNT_LIMITS } = await import("../lib/constants.js"); + it("decorates the last user message with a masked-email indicator after the first successful response", async () => { + await enablePersistedFooter("label-masked-email"); + const { plugin, sdk } = await setupPlugin(); - vi.spyOn(fetchHelpers, "shouldRefreshToken").mockReturnValue(true); - vi.mocked(fetchHelpers.refreshAndUpdateToken).mockRejectedValue( - new Error("Token expired"), - ); - const incrementAuthFailuresSpy = vi - .spyOn(AccountManager.prototype, "incrementAuthFailures") - .mockReturnValue(ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL); - const removeGroupedAccountsSpy = vi - .spyOn(AccountManager.prototype, "removeAccountsWithSameRefreshToken") - .mockReturnValue(0); - const markAccountsWithRefreshTokenCoolingDownSpy = vi.spyOn( - AccountManager.prototype, - "markAccountsWithRefreshTokenCoolingDown", + await sendPersistedAccountRequest(sdk, "session-masked"); + + expect((await readPersistedAccountIndicator(plugin, "session-masked")).variant).toBe( + expectedMaskedIndicator, ); + }); - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ content: "should-not-fetch" }), { status: 200 }), + it("decorates the last user message with a full-email indicator when configured", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-full"); + + expect((await readPersistedAccountIndicator(plugin, "session-full")).variant).toBe( + expectedFullIndicator, ); + }); + it("does not reload account storage on the successful footer hot path", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const storageModule = await import("../lib/storage.js"); const { sdk } = await setupPlugin(); - const response = await sdk.fetch!("https://api.openai.com/v1/chat", { - method: "POST", - body: JSON.stringify({ model: "gpt-5.1" }), + + await sendPersistedAccountRequest(sdk, "session-warmup"); + vi.mocked(storageModule.loadAccounts).mockClear(); + + await sendPersistedAccountRequest(sdk, "session-no-read"); + + expect(storageModule.loadAccounts).not.toHaveBeenCalled(); + }); + + it("does not add storage reads during loader init when footer counts are enabled", async () => { + const storageModule = await import("../lib/storage.js"); + const getAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, }); + const runLoaderAndCountStorageReads = async (): Promise => { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + vi.mocked(storageModule.loadAccounts).mockClear(); + await plugin.auth.loader(getAuth, { options: {}, models: {} }); + return vi.mocked(storageModule.loadAccounts).mock.calls.length; + }; - expect(response.status).toBe(503); - expect(globalThis.fetch).not.toHaveBeenCalled(); - expect(incrementAuthFailuresSpy).toHaveBeenCalledTimes(1); - expect(removeGroupedAccountsSpy).toHaveBeenCalledTimes(1); - expect(markAccountsWithRefreshTokenCoolingDownSpy).toHaveBeenCalledWith( - "refresh-1", - ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, - "auth-failure", + await disablePersistedFooter(); + const baselineReadCount = await runLoaderAndCountStorageReads(); + + await enablePersistedFooter("full-email"); + const footerReadCount = await runLoaderAndCountStorageReads(); + + expect(footerReadCount).toBe(baselineReadCount); + }); + + it("uses the live account count when the cached footer hint is stale", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-live-count"); + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + ]; + + await sendPersistedAccountRequest(sdk, "session-live-count"); + + expect((await readPersistedAccountIndicator(plugin, "session-live-count")).variant).toBe( + expectedFullIndicator, ); }); - it("skips fetch when local token bucket is depleted", async () => { - const { AccountManager } = await import("../lib/accounts.js"); - const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken").mockReturnValue(false); - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), + it("falls back to the persisted account count hint when the live count transiently drops to zero", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + getAccountCount: () => number; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + const { plugin, sdk } = await setupPlugin(); + vi.spyOn(manager, "getAccountCount") + .mockImplementationOnce(() => 2) + .mockImplementation(() => 0); + + await sendPersistedAccountRequest(sdk, "session-count-hint"); + + expect((await readPersistedAccountIndicator(plugin, "session-count-hint")).variant).toBe( + "user@example.com [1/2]", ); + }); - const { sdk } = await setupPlugin(); - const response = await sdk.fetch!("https://api.openai.com/v1/chat", { - method: "POST", - body: JSON.stringify({ model: "gpt-5.1" }), + it("keeps the manual-switch account count hint available for a later zero-count fetch", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + getAccountCount: () => number; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + const { plugin, sdk } = await setupPlugin(); + vi.spyOn(manager, "getAccountCount").mockImplementation(() => 0); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, }); + await sendPersistedAccountRequest(sdk, "session-count-hint-after-switch"); - expect(globalThis.fetch).not.toHaveBeenCalled(); - expect(response.status).toBe(503); - expect(await response.text()).toContain("server errors or auth issues"); - consumeSpy.mockRestore(); + expect( + (await readPersistedAccountIndicator(plugin, "session-count-hint-after-switch")).variant, + ).toBe("user@example.com [1/2]"); }); - it("falls back from gpt-5.4-pro to gpt-5.4 when unsupported fallback is enabled", async () => { - const configModule = await import("../lib/config.js"); - const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + it("decorates the last user message with a label-only indicator when configured", async () => { + await enablePersistedFooter("label-only"); + const { plugin, sdk } = await setupPlugin(); - vi.mocked(configModule.getFallbackOnUnsupportedCodexModel).mockReturnValueOnce(true); - vi.mocked(configModule.getFallbackToGpt52OnUnsupportedGpt53).mockReturnValueOnce(false); - vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ - updatedInit: { - method: "POST", - body: JSON.stringify({ model: "gpt-5.4-pro" }), - }, - body: { model: "gpt-5.4-pro" }, + await sendPersistedAccountRequest(sdk, "session-label"); + + expect((await readPersistedAccountIndicator(plugin, "session-label")).variant).toBe( + expectedLabelOnlyIndicator, + ); + }); + + it("keeps the label-only indicator stable across manual account switches", async () => { + await enablePersistedFooter("label-only"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation((token) => { + if (token === "token-2") return "account-2"; + if (token) return "account-1"; + return undefined; }); - vi.mocked(fetchHelpers.handleErrorResponse).mockResolvedValueOnce({ - response: new Response( - JSON.stringify({ - error: { - code: "model_not_supported_with_chatgpt_account", - message: - "The 'gpt-5.4-pro' model is not supported when using Codex with a ChatGPT account.", - }, - }), - { status: 400 }, - ), - rateLimit: undefined, - errorBody: { - error: { - code: "model_not_supported_with_chatgpt_account", - message: - "The 'gpt-5.4-pro' model is not supported when using Codex with a ChatGPT account.", - }, + type TestManagedAccount = { + index: number; + accountId: string; + email: string; + refreshToken: string; + accessToken?: string; + }; + const previousManager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: TestManagedAccount[]; + }; + const reloadedManager = await accountsModule.AccountManager.loadFromDisk() as typeof previousManager; + previousManager.accounts = [ + { + index: 0, + accountId: "account-1", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "token-1", }, - }); - vi.mocked(fetchHelpers.resolveUnsupportedCodexFallbackModel).mockReturnValueOnce("gpt-5.4"); + { + index: 1, + accountId: "account-2", + email: "user2@example.com", + refreshToken: "refresh-2", + accessToken: "token-2", + }, + ]; + reloadedManager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk") + .mockResolvedValueOnce(previousManager as never) + .mockResolvedValueOnce(reloadedManager as never); - globalThis.fetch = vi - .fn() - .mockResolvedValueOnce(new Response("bad", { status: 400 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + const { plugin, sdk } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-label-switch"); - const { sdk } = await setupPlugin(); - const response = await sdk.fetch!("https://api.openai.com/v1/chat", { - method: "POST", - body: JSON.stringify({ model: "gpt-5.4-pro" }), + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, }); - expect(response.status).toBe(200); - expect(globalThis.fetch).toHaveBeenCalledTimes(2); - const firstInit = vi.mocked(globalThis.fetch).mock.calls[0]?.[1] as RequestInit; - const secondInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; - expect(JSON.parse(firstInit.body as string).model).toBe("gpt-5.4-pro"); - expect(JSON.parse(secondInit.body as string).model).toBe("gpt-5.4"); + expect((await readPersistedAccountIndicator(plugin, "session-label-switch")).variant).toBe( + "Account 2 [id:ount-2] [2/2]", + ); }); - it("falls back from gpt-5.3-codex to gpt-5.2-codex when unsupported fallback is enabled", async () => { - const configModule = await import("../lib/config.js"); - const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + it("skips persisted indicators when the request has no session key", async () => { + await enablePersistedFooter("label-masked-email"); + const { plugin, sdk } = await setupPlugin(); + await plugin["chat.message"]( + { + sessionID: "session-no-key", + model: { providerID: "openai", modelID: "gpt-5.1" }, + }, + { message: {}, parts: [] }, + ); - vi.mocked(configModule.getFallbackOnUnsupportedCodexModel).mockReturnValueOnce(true); - vi.mocked(configModule.getFallbackToGpt52OnUnsupportedGpt53).mockReturnValueOnce(true); - vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ - updatedInit: { - method: "POST", - body: JSON.stringify({ model: "gpt-5.3-codex" }), + await sendPersistedAccountRequest(sdk); + + expect((await readPersistedAccountIndicator(plugin, "session-no-key")).variant).toBeUndefined(); + }); + + it("decorates live user chat.message output with the visible account indicator without leaking to thinking", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-chat-message", "gpt-5.4"); + + const output = { + message: { + role: "user", + model: { providerID: "openai", modelID: "gpt-5.4" }, }, - body: { model: "gpt-5.3-codex" }, - }); - vi.mocked(fetchHelpers.handleErrorResponse).mockResolvedValueOnce({ - response: new Response( - JSON.stringify({ - error: { - code: "model_not_supported_with_chatgpt_account", - message: - "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", - }, - }), - { status: 400 }, - ), - rateLimit: undefined, - errorBody: { - error: { - code: "model_not_supported_with_chatgpt_account", + parts: [], + }; + await plugin["chat.message"]( + { + sessionID: "session-chat-message", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + output, + ); + + expect((output.message as { variant?: string }).variant).toBe(expectedFullIndicator); + expect((output.message as { thinking?: string }).thinking).toBeUndefined(); + expect((output.message as { model?: { modelID?: string } }).model?.modelID).toBe("gpt-5.4"); + expect((output.message as { model?: { variant?: string } }).model?.variant).toBe( + expectedFullIndicator, + ); + expect((await readPersistedAccountIndicator(plugin, "session-chat-message")).thinking).toBeUndefined(); + }); + + it("uses CODEX_THREAD_ID as the footer session key when it differs from prompt_cache_key", async () => { + await enablePersistedFooter("full-email"); + process.env.CODEX_THREAD_ID = "env-session"; + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-explicit"); + + expect((await readPersistedAccountIndicator(plugin, "env-session")).variant).toBe( + expectedFullIndicator, + ); + }); + + it("falls back to CODEX_THREAD_ID in the transform hook when the message session is missing", async () => { + await enablePersistedFooter("full-email"); + process.env.CODEX_THREAD_ID = "env-fallback"; + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk); + + const output: Parameters[1] = { + messages: [ + { + info: { + role: "user", + model: { providerID: "openai", modelID: "gpt-5.1" }, + }, + parts: [], + }, + ], + }; + await plugin["experimental.chat.messages.transform"]({}, output); + + expect( + output.messages[0]?.info.model?.variant ?? output.messages[0]?.info.variant, + ).toBe(expectedFullIndicator); + }); + + it("ignores transform outputs when there are no messages", async () => { + await enablePersistedFooter("full-email"); + const { plugin } = await setupPlugin(); + + const output: Parameters[1] = { + messages: [], + }; + + await expect( + plugin["experimental.chat.messages.transform"]({}, output), + ).resolves.toBeUndefined(); + expect(output.messages).toEqual([]); + }); + + it("ignores transform outputs when no user message is present", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-assistant-only"); + + const output: Parameters[1] = { + messages: [ + { + info: { + role: "assistant", + sessionID: "session-assistant-only", + model: { providerID: "openai", modelID: "gpt-5.1" }, + }, + parts: [], + }, + ], + }; + + await expect( + plugin["experimental.chat.messages.transform"]({}, output), + ).resolves.toBeUndefined(); + expect(output.messages[0]?.info.variant).toBeUndefined(); + expect(output.messages[0]?.info.model?.variant).toBeUndefined(); + }); + + it("prefers CODEX_THREAD_ID over a non-empty transform session id when looking up the footer", async () => { + await enablePersistedFooter("full-email"); + process.env.CODEX_THREAD_ID = "env-transform-priority"; + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-explicit"); + + const output: Parameters[1] = { + messages: [ + { + info: { + role: "user", + sessionID: "session-different", + model: { providerID: "openai", modelID: "gpt-5.1" }, + }, + parts: [], + }, + ], + }; + await plugin["experimental.chat.messages.transform"]({}, output); + + expect( + output.messages[0]?.info.model?.variant ?? output.messages[0]?.info.variant, + ).toBe(expectedFullIndicator); + }); + + it("falls back to CODEX_THREAD_ID in chat.message when the session id is empty", async () => { + await enablePersistedFooter("full-email"); + process.env.CODEX_THREAD_ID = "env-chat-message"; + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk); + + const output = { + message: { + role: "user", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + parts: [], + }; + await plugin["chat.message"]( + { + sessionID: "", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + output, + ); + + expect((output.message as { variant?: string }).variant).toBe(expectedFullIndicator); + expect((output.message as { model?: { variant?: string } }).model?.variant).toBe( + expectedFullIndicator, + ); + }); + + it("prefers CODEX_THREAD_ID over a non-empty chat.message session id when looking up the footer", async () => { + await enablePersistedFooter("full-email"); + process.env.CODEX_THREAD_ID = "env-chat-priority"; + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-explicit"); + + const output = { + message: { + role: "user", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + parts: [], + }; + await plugin["chat.message"]( + { + sessionID: "session-different", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + output, + ); + + expect((output.message as { variant?: string }).variant).toBe(expectedFullIndicator); + expect((output.message as { model?: { variant?: string } }).model?.variant).toBe( + expectedFullIndicator, + ); + }); + + it("does not apply a persisted footer when prompt_cache_key and hook session ids differ without CODEX_THREAD_ID", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-request-only"); + + const transformOutput = buildMessageTransformOutput("session-hook-only"); + await plugin["experimental.chat.messages.transform"]({}, transformOutput); + expect(transformOutput.messages[0]?.info.variant).toBeUndefined(); + expect(transformOutput.messages[0]?.info.model?.variant).toBeUndefined(); + + const liveOutput = { + message: { + role: "user", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + parts: [], + }; + await plugin["chat.message"]( + { + sessionID: "session-hook-only", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + liveOutput, + ); + + expect((liveOutput.message as { variant?: string }).variant).toBeUndefined(); + expect( + (liveOutput.message as { model?: { variant?: string } }).model?.variant, + ).toBeUndefined(); + }); + + it("does not set the chat.message indicator when role is missing", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-chat-message-missing-role"); + + const output = { + message: {}, + parts: [], + }; + + await expect( + plugin["chat.message"]( + { + sessionID: "session-chat-message-missing-role", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + output, + ), + ).resolves.toBeUndefined(); + + expect((output.message as { variant?: string }).variant).toBeUndefined(); + expect( + (output.message as { model?: { variant?: string } }).model?.variant, + ).toBeUndefined(); + }); + + it("uses input.model as the fallback chat.message model when model info is absent", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-chat-message-no-model"); + + const output = { + message: { role: "user" }, + parts: [], + }; + + await expect( + plugin["chat.message"]( + { + sessionID: "session-chat-message-no-model", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + output, + ), + ).resolves.toBeUndefined(); + + expect((output.message as { variant?: string }).variant).toBe(expectedFullIndicator); + expect( + (output.message as { model?: { providerID?: string } }).model?.providerID, + ).toBe("openai"); + expect( + (output.message as { model?: { modelID?: string } }).model?.modelID, + ).toBe("gpt-5.4"); + expect((output.message as { model?: { variant?: string } }).model?.variant).toBe( + expectedFullIndicator, + ); + }); + + it("does not set the chat.message indicator on assistant messages", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-chat-message-assistant"); + + const output = { + message: { + role: "assistant", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + parts: [], + }; + + await expect( + plugin["chat.message"]( + { + sessionID: "session-chat-message-assistant", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + output, + ), + ).resolves.toBeUndefined(); + + expect((output.message as { variant?: string }).variant).toBeUndefined(); + expect( + (output.message as { model?: { variant?: string } }).model?.variant, + ).toBeUndefined(); + }); + + it("fills model.variant in the transform hook even when the stored message has no model info", async () => { + await enablePersistedFooter("full-email"); + process.env.CODEX_THREAD_ID = "env-model-less"; + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk); + + const output: Parameters[1] = { + messages: [ + { + info: { + role: "user", + }, + parts: [], + }, + ], + }; + await plugin["experimental.chat.messages.transform"]({}, output); + + expect(output.messages[0]?.info.variant).toBe(expectedFullIndicator); + expect( + (output.messages[0]?.info.model as { variant?: string } | undefined)?.variant, + ).toBe(expectedFullIndicator); + }); + + it("preserves partial model info in the transform hook while still setting model.variant", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-partial-model"); + + const output: Parameters[1] = { + messages: [ + { + info: { + role: "user", + sessionID: "session-partial-model", + model: { providerID: "openai" }, + }, + parts: [], + }, + ], + }; + await plugin["experimental.chat.messages.transform"]({}, output); + + expect(output.messages[0]?.info.variant).toBe(expectedFullIndicator); + expect(output.messages[0]?.info.model?.providerID).toBe("openai"); + expect(output.messages[0]?.info.model?.modelID).toBeUndefined(); + expect(output.messages[0]?.info.model?.variant).toBe(expectedFullIndicator); + }); + + it("stops applying persisted indicators after the footer is disabled", async () => { + await enablePersistedFooter("full-email"); + const { plugin, sdk } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-footer-toggle"); + + expect((await readPersistedAccountIndicator(plugin, "session-footer-toggle")).variant).toBe( + expectedFullIndicator, + ); + + await disablePersistedFooter(); + await sendPersistedAccountRequest(sdk, "session-footer-toggle"); + + const liveOutput = { + message: { + role: "user", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + parts: [], + }; + await plugin["chat.message"]( + { + sessionID: "session-footer-toggle", + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + liveOutput, + ); + + expect((await readPersistedAccountIndicator(plugin, "session-footer-toggle")).variant).toBeUndefined(); + expect((liveOutput.message as { variant?: string }).variant).toBeUndefined(); + expect( + (liveOutput.message as { model?: { variant?: string } }).model?.variant, + ).toBeUndefined(); + }); + + it("clears the persisted account count hint when the footer is disabled", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + getAccountCount: () => number; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + const { plugin, sdk } = await setupPlugin(); + vi.spyOn(manager, "getAccountCount") + .mockImplementationOnce(() => 2) + .mockImplementation(() => 0); + + await sendPersistedAccountRequest(sdk, "session-count-hint-prime"); + expect((await readPersistedAccountIndicator(plugin, "session-count-hint-prime")).variant).toBe( + "user@example.com [1/2]", + ); + + await disablePersistedFooter(); + await sendPersistedAccountRequest(sdk, "session-count-hint-disabled"); + + await enablePersistedFooter("full-email"); + await sendPersistedAccountRequest(sdk, "session-count-hint-reset"); + + expect((await readPersistedAccountIndicator(plugin, "session-count-hint-reset")).variant).toBe( + expectedFullIndicator, + ); + }); + + it("suppresses account-switch info toasts when the footer is enabled and refreshes the visible indicator", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + const configModule = await import("../lib/config.js"); + + const { plugin, sdk, mockClient } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-switch"); + const configReadCountBeforeSwitch = + vi.mocked(configModule.loadPluginConfig).mock.calls.length; + mockClient.tui.showToast.mockClear(); + + expect((await readPersistedAccountIndicator(plugin, "session-switch")).variant).toBe( + "user@example.com [1/2]", + ); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + body: { + message: "Switched to account 2", + variant: "info", + }, + }); + expect(vi.mocked(configModule.loadPluginConfig)).toHaveBeenCalledTimes( + configReadCountBeforeSwitch, + ); + expect((await readPersistedAccountIndicator(plugin, "session-switch")).variant).toBe( + "user2@example.com [2/2]", + ); + }); + + it("syncs account-switch footer behavior after a fetch refresh enables it", async () => { + await disablePersistedFooter(); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + const { plugin, sdk, mockClient } = await setupPlugin(); + + await enablePersistedFooter("full-email"); + await sendPersistedAccountRequest(sdk, "session-switch-sync"); + mockClient.tui.showToast.mockClear(); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + body: { + message: "Switched to account 2", + variant: "info", + }, + }); + expect( + (await readPersistedAccountIndicator(plugin, "session-switch-sync")).variant, + ).toBe("user2@example.com [2/2]"); + }); + + it("does not show the switch toast before the first footer session exists", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + const { plugin, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockClear(); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + body: { + message: "Switched to account 2", + variant: "info", + }, + }); + }); + + it("does not let UI-only config refreshes reset the loader-synced footer state", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const configModule = await import("../lib/config.js"); + const { plugin, sdk, mockClient } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-ui-refresh"); + mockClient.tui.showToast.mockClear(); + + vi.mocked(configModule.getPersistAccountFooter).mockReturnValue(false); + await plugin.tool["codex-list"].execute(); + await enablePersistedFooter("full-email"); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + body: { + message: "Switched to account 2", + variant: "info", + }, + }); + expect((await readPersistedAccountIndicator(plugin, "session-ui-refresh")).variant).toBe( + "user2@example.com [2/2]", + ); + }); + + it("reuses the loader-synced config for UI-only tool renders", async () => { + const configModule = await import("../lib/config.js"); + const { plugin } = await setupPlugin(); + + vi.mocked(configModule.loadPluginConfig).mockClear(); + vi.mocked(configModule.loadPluginConfig).mockImplementation(() => { + throw new Error("config locked"); + }); + + await expect(plugin.tool["codex-list"].execute()).resolves.toContain("Codex Accounts"); + expect(configModule.loadPluginConfig).not.toHaveBeenCalled(); + }); + + it("does not let authorize flows reset the loader-synced footer state", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const configModule = await import("../lib/config.js"); + const healthyConfig = { source: "healthy-config" }; + vi.mocked(configModule.loadPluginConfig).mockReturnValue(healthyConfig); + vi.mocked(configModule.getPersistAccountFooter).mockImplementation( + (config) => config === healthyConfig, + ); + vi.mocked(configModule.getPersistAccountFooterStyle).mockReturnValue("full-email"); + const { plugin, sdk, mockClient } = await setupPlugin(); + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + const manualMethod = plugin.auth.methods[1] as unknown as { + authorize: () => Promise<{ instructions: string }>; + }; + + await sendPersistedAccountRequest(sdk, "session-authorize-refresh"); + mockClient.tui.showToast.mockClear(); + + vi.mocked(configModule.loadPluginConfig).mockReturnValue(configModule.DEFAULT_CONFIG); + await autoMethod.authorize({ loginMode: "add", accountCount: "1" }); + await manualMethod.authorize(); + mockClient.tui.showToast.mockClear(); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + body: { + message: "Switched to account 2", + variant: "info", + }, + }); + }); + + it("uses the latest perProjectAccounts setting when authorize writes storage", async () => { + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const loaderConfig = { source: "loader-config" }; + const authorizeConfig = { source: "authorize-config" }; + + vi.mocked(configModule.loadPluginConfig).mockReturnValue(loaderConfig); + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( + (config) => config === authorizeConfig, + ); + + const { plugin } = await setupPlugin(); + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + const manualMethod = plugin.auth.methods[1] as unknown as { + authorize: () => Promise; + }; + + vi.mocked(storageModule.setStoragePath).mockClear(); + vi.mocked(configModule.loadPluginConfig).mockReturnValue(authorizeConfig); + + await autoMethod.authorize({ loginMode: "add", accountCount: "1" }); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + + vi.mocked(storageModule.setStoragePath).mockClear(); + await manualMethod.authorize(); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + }); + + it("falls back to the cached authorize storage config when the fresh refresh throws", async () => { + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const loggerModule = await import("../lib/logger.js"); + const loaderConfig = { source: "loader-config" }; + + vi.mocked(configModule.loadPluginConfig).mockReturnValue(loaderConfig); + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( + (config) => config === loaderConfig, + ); + + const { plugin } = await setupPlugin(); + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + const manualMethod = plugin.auth.methods[1] as unknown as { + authorize: () => Promise; + }; + + vi.mocked(storageModule.setStoragePath).mockClear(); + vi.mocked(loggerModule.logWarn).mockClear(); + vi.mocked(configModule.loadPluginConfig).mockImplementation(() => { + throw new Error("config locked"); + }); + + await expect(autoMethod.authorize({ loginMode: "add", accountCount: "1" })).resolves.toBeDefined(); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + expect(loggerModule.logWarn).toHaveBeenCalledWith( + expect.stringContaining("Falling back to cached authorize storage config"), + ); + + vi.mocked(storageModule.setStoragePath).mockClear(); + await expect(manualMethod.authorize()).resolves.toBeDefined(); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + }); + + it("falls back to the cached authorize storage config when the fresh refresh returns DEFAULT_CONFIG", async () => { + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const loggerModule = await import("../lib/logger.js"); + const loaderConfig = { source: "loader-config" }; + + vi.mocked(configModule.loadPluginConfig).mockReturnValue(loaderConfig); + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( + (config) => config === loaderConfig, + ); + + const { plugin } = await setupPlugin(); + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + const manualMethod = plugin.auth.methods[1] as unknown as { + authorize: () => Promise; + }; + + vi.mocked(storageModule.setStoragePath).mockClear(); + vi.mocked(loggerModule.logWarn).mockClear(); + vi.mocked(configModule.loadPluginConfig).mockReturnValue(configModule.DEFAULT_CONFIG); + + await expect(autoMethod.authorize({ loginMode: "add", accountCount: "1" })).resolves.toBeDefined(); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + expect(loggerModule.logWarn).toHaveBeenCalledWith( + "Falling back to cached authorize storage config after config loader returned defaults.", + ); + + vi.mocked(storageModule.setStoragePath).mockClear(); + await expect(manualMethod.authorize()).resolves.toBeDefined(); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + }); + + it("reuses the cold-start authorize config instead of re-reading config twice", async () => { + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const coldStartConfig = { source: "cold-start-config" }; + + vi.mocked(configModule.loadPluginConfig).mockReturnValue(coldStartConfig); + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( + (config) => config === coldStartConfig, + ); + + const { plugin } = await setupPlugin(); + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + + vi.mocked(configModule.loadPluginConfig).mockClear(); + vi.mocked(storageModule.setStoragePath).mockClear(); + + await expect(autoMethod.authorize({ loginMode: "add", accountCount: "1" })).resolves.toBeDefined(); + + expect(configModule.loadPluginConfig).toHaveBeenCalledTimes(1); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + }); + + it("retries a pre-loader authorize refresh when the first authorize config falls back to defaults", async () => { + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const recoveredConfig = { source: "recovered-config" }; + + vi.mocked(configModule.loadPluginConfig) + .mockReturnValueOnce(configModule.DEFAULT_CONFIG) + .mockReturnValueOnce(configModule.DEFAULT_CONFIG) + .mockReturnValueOnce(recoveredConfig); + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( + (config) => config === recoveredConfig, + ); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + + vi.mocked(storageModule.setStoragePath).mockClear(); + + await expect(autoMethod.authorize({ loginMode: "add", accountCount: "1" })).resolves.toBeDefined(); + + expect(configModule.loadPluginConfig).toHaveBeenCalledTimes(3); + expect(storageModule.setStoragePath).toHaveBeenCalledWith(process.cwd()); + }); + + it("recovers footer runtime state after a cold-start authorize fallback refresh", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const configModule = await import("../lib/config.js"); + const recoveredConfig = { source: "recovered-footer-config" }; + + vi.mocked(configModule.loadPluginConfig) + .mockReturnValueOnce(configModule.DEFAULT_CONFIG) + .mockReturnValueOnce(recoveredConfig); + vi.mocked(configModule.getPersistAccountFooter).mockImplementation( + (config) => config === recoveredConfig, + ); + vi.mocked(configModule.getPersistAccountFooterStyle).mockReturnValue("full-email"); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise; + }; + + await expect(autoMethod.authorize({ loginMode: "add", accountCount: "1" })).resolves.toBeDefined(); + mockClient.tui.showToast.mockClear(); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect( + mockClient.tui.showToast.mock.calls.some(([payload]) => { + const body = (payload as { body?: { message?: string; variant?: string } }) + ?.body; + return body?.variant === "info" && body.message === "Switched to account 2"; + }), + ).toBe(false); + }); + + it("shows the account-switch info toast when the footer is disabled", async () => { + await disablePersistedFooter(); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + const { plugin, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockClear(); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(mockClient.tui.showToast).toHaveBeenCalledWith({ + body: { + message: "Switched to account 2", + variant: "info", + }, + }); + }); + + it("keeps the newer account indicator when an in-flight response completes after a manual switch", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + const { plugin, sdk, mockClient } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-stale"); + mockClient.tui.showToast.mockClear(); + + let resolveFetch: ((response: Response) => void) | undefined; + let markFetchStarted: (() => void) | undefined; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + markFetchStarted?.(); + }), + ); + + const pendingResponse = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", prompt_cache_key: "session-stale" }), + }); + + await fetchStarted; + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + resolveFetch?.(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + await pendingResponse; + + expect((await readPersistedAccountIndicator(plugin, "session-stale")).variant).toBe( + "user2@example.com [2/2]", + ); + }); + + it("keeps the higher revision when same-session responses resolve out of order", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + toAuthDetails: (account: { + index: number; + refreshToken: string; + }) => { + type: "oauth"; + access: string; + refresh: string; + expires: number; + }; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation((accessToken?: string) => + accessToken === "access-token-2" ? "user2@example.com" : "user@example.com", + ); + manager.toAuthDetails = (account) => ({ + type: "oauth", + access: account.index === 1 ? "access-token-2" : "access-token-1", + refresh: account.refreshToken, + expires: Date.now() + 60_000, + }); + vi.mocked(fetchHelpers.transformRequestForCodex).mockImplementation( + async (init, _url, _config, _codexMode, parsedBody) => ({ + updatedInit: init, + body: parsedBody, + }), + ); + + const { plugin, sdk } = await setupPlugin(); + const requestBody = JSON.stringify({ + model: "gpt-5.1", + prompt_cache_key: "session-same-revision", + }); + const fetchResolvers: Array<(response: Response) => void> = []; + let releaseFirstFetch: (() => void) | undefined; + const firstFetchStarted = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + let releaseSecondFetch: (() => void) | undefined; + const secondFetchStarted = new Promise((resolve) => { + releaseSecondFetch = resolve; + }); + let fetchCallIndex = 0; + + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + fetchResolvers[fetchCallIndex] = resolve; + if (fetchCallIndex === 0) { + releaseFirstFetch?.(); + } else { + releaseSecondFetch?.(); + } + fetchCallIndex += 1; + }), + ); + + const requestA = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: requestBody, + }); + await firstFetchStarted; + + manager.accounts = [ + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + ]; + + const requestB = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: requestBody, + }); + await secondFetchStarted; + + fetchResolvers[1]?.(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + await requestB; + fetchResolvers[0]?.(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + await requestA; + + expect( + (await readPersistedAccountIndicator(plugin, "session-same-revision")).variant, + ).toBe("user2@example.com [2/2]"); + }); + + it("evicts the oldest persisted indicator after a full refresh when a new session overflows the cap", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const { MAX_PERSISTED_ACCOUNT_INDICATORS } = await import("../index.js"); + const sessionIDs = Array.from( + { length: MAX_PERSISTED_ACCOUNT_INDICATORS }, + (_, index) => `session-overflow-${index}`, + ); + + const { plugin, sdk } = await setupPlugin(); + for (const sessionID of sessionIDs) { + await sendPersistedAccountRequest(sdk, sessionID); + } + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + await sendPersistedAccountRequest(sdk, "session-overflow-new"); + + expect((await readPersistedAccountIndicator(plugin, sessionIDs[0]!)).variant).toBeUndefined(); + expect((await readPersistedAccountIndicator(plugin, sessionIDs[1]!)).variant).toBe( + "user2@example.com [2/2]", + ); + expect((await readPersistedAccountIndicator(plugin, "session-overflow-new")).variant).toBeDefined(); + }); + + it("shows the account-use info toast when the footer is disabled", async () => { + await disablePersistedFooter(); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + vi.spyOn(accountsModule, "formatAccountLabel").mockImplementation( + (account: { email?: string }, index: number) => + account.email ?? `Account ${index + 1}`, + ); + vi.spyOn(accountsModule.AccountManager.prototype, "shouldShowAccountToast").mockReturnValue(true); + + const { sdk, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockClear(); + + await sendPersistedAccountRequest(sdk, "session-using-shown"); + + expect(mockClient.tui.showToast).toHaveBeenCalledWith({ + body: { + message: "Using user@example.com (1/2)", + variant: "info", + }, + }); + }); + + it("suppresses the account-use info toast when the footer is enabled", async () => { + await enablePersistedFooter("full-email"); + mockStorage.accounts = [ + { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const accountsModule = await import("../lib/accounts.js"); + const manager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + }; + manager.accounts = [ + { index: 0, accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + vi.spyOn(accountsModule.AccountManager, "loadFromDisk").mockResolvedValue(manager as never); + vi.spyOn(accountsModule, "formatAccountLabel").mockImplementation( + (account: { email?: string }, index: number) => + account.email ?? `Account ${index + 1}`, + ); + vi.spyOn(accountsModule.AccountManager.prototype, "shouldShowAccountToast").mockReturnValue(true); + + const { sdk, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockClear(); + + await sendPersistedAccountRequest(sdk, "session-using-hidden"); + + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + body: { + message: "Using user@example.com (1/2)", + variant: "info", + }, + }); + }); + + it("handles network errors and rotates to next account", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network timeout")); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(await response.text()).toContain("server errors or auth issues"); + }); + + it("cools down the account when grouped auth removal removes zero entries", async () => { + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + const { AccountManager } = await import("../lib/accounts.js"); + const { ACCOUNT_LIMITS } = await import("../lib/constants.js"); + + vi.spyOn(fetchHelpers, "shouldRefreshToken").mockReturnValue(true); + vi.mocked(fetchHelpers.refreshAndUpdateToken).mockRejectedValue( + new Error("Token expired"), + ); + const incrementAuthFailuresSpy = vi + .spyOn(AccountManager.prototype, "incrementAuthFailures") + .mockReturnValue(ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL); + const removeGroupedAccountsSpy = vi + .spyOn(AccountManager.prototype, "removeAccountsWithSameRefreshToken") + .mockReturnValue(0); + const markAccountsWithRefreshTokenCoolingDownSpy = vi.spyOn( + AccountManager.prototype, + "markAccountsWithRefreshTokenCoolingDown", + ); + + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "should-not-fetch" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(503); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(incrementAuthFailuresSpy).toHaveBeenCalledTimes(1); + expect(removeGroupedAccountsSpy).toHaveBeenCalledTimes(1); + expect(markAccountsWithRefreshTokenCoolingDownSpy).toHaveBeenCalledWith( + "refresh-1", + ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + }); + + it("skips fetch when local token bucket is depleted", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken").mockReturnValue(false); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), + ); + + const { sdk, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockClear(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(503); + expect(await response.text()).toContain("server errors or auth issues"); + expect(mockClient.tui.showToast).toHaveBeenCalledWith({ + body: { + message: "All 1 account(s) failed (server errors or auth issues). Check account health with `codex-health`.", + variant: "error", + duration: 5000, + }, + }); + consumeSpy.mockRestore(); + }); + + it("still returns a terminal 503 when the TUI toast channel throws", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken").mockReturnValue(false); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), + ); + + const { sdk, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockRejectedValue(new Error("tui closed")); + + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(503); + expect(await response.text()).toContain("server errors or auth issues"); + consumeSpy.mockRestore(); + }); + + it("uses a warning toast for all-accounts rate-limit terminal responses", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getRetryAllAccountsRateLimited).mockReturnValue(false); + const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken").mockReturnValue(false); + const waitSpy = vi + .spyOn(AccountManager.prototype, "getMinWaitTimeForFamily") + .mockReturnValue(60_000); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), + ); + + const { sdk, mockClient } = await setupPlugin(); + mockClient.tui.showToast.mockClear(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(429); + expect(mockClient.tui.showToast).toHaveBeenCalledWith({ + body: { + message: "All 1 account(s) are rate-limited. Try again in 60s or add another account with `opencode auth login`.", + variant: "warning", + duration: 5000, + }, + }); + + waitSpy.mockRestore(); + consumeSpy.mockRestore(); + }); + + it("falls back from gpt-5.4-pro to gpt-5.4 when unsupported fallback is enabled", async () => { + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + + vi.mocked(configModule.getFallbackOnUnsupportedCodexModel).mockReturnValueOnce(true); + vi.mocked(configModule.getFallbackToGpt52OnUnsupportedGpt53).mockReturnValueOnce(false); + vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ + updatedInit: { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4-pro" }), + }, + body: { model: "gpt-5.4-pro" }, + }); + vi.mocked(fetchHelpers.handleErrorResponse).mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + error: { + code: "model_not_supported_with_chatgpt_account", + message: + "The 'gpt-5.4-pro' model is not supported when using Codex with a ChatGPT account.", + }, + }), + { status: 400 }, + ), + rateLimit: undefined, + errorBody: { + error: { + code: "model_not_supported_with_chatgpt_account", + message: + "The 'gpt-5.4-pro' model is not supported when using Codex with a ChatGPT account.", + }, + }, + }); + vi.mocked(fetchHelpers.resolveUnsupportedCodexFallbackModel).mockReturnValueOnce("gpt-5.4"); + + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("bad", { status: 400 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.4-pro" }), + }); + + expect(response.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + const firstInit = vi.mocked(globalThis.fetch).mock.calls[0]?.[1] as RequestInit; + const secondInit = vi.mocked(globalThis.fetch).mock.calls[1]?.[1] as RequestInit; + expect(JSON.parse(firstInit.body as string).model).toBe("gpt-5.4-pro"); + expect(JSON.parse(secondInit.body as string).model).toBe("gpt-5.4"); + }); + + it("falls back from gpt-5.3-codex to gpt-5.2-codex when unsupported fallback is enabled", async () => { + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + + vi.mocked(configModule.getFallbackOnUnsupportedCodexModel).mockReturnValueOnce(true); + vi.mocked(configModule.getFallbackToGpt52OnUnsupportedGpt53).mockReturnValueOnce(true); + vi.mocked(fetchHelpers.transformRequestForCodex).mockResolvedValueOnce({ + updatedInit: { + method: "POST", + body: JSON.stringify({ model: "gpt-5.3-codex" }), + }, + body: { model: "gpt-5.3-codex" }, + }); + vi.mocked(fetchHelpers.handleErrorResponse).mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + error: { + code: "model_not_supported_with_chatgpt_account", + message: + "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", + }, + }), + { status: 400 }, + ), + rateLimit: undefined, + errorBody: { + error: { + code: "model_not_supported_with_chatgpt_account", message: "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", }, @@ -3448,4 +5002,28 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { event: { type: "account.select", properties: { index: "invalid" } }, }); }); + + it("ignores account.select with an out-of-bounds index", async () => { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + + const getAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + + await plugin.auth.loader(getAuth, { options: {}, models: {} }); + + await expect( + plugin.event({ + event: { type: "account.select", properties: { index: 99 } }, + }), + ).resolves.toBeUndefined(); + expect(mockStorage.activeIndex).toBe(0); + expect(mockStorage.activeIndexByFamily).toEqual({}); + }); }); diff --git a/test/logger.test.ts b/test/logger.test.ts index 13781f5c..e227a839 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -423,6 +423,40 @@ describe('Logger Module', () => { expect(message).not.toContain(jwtToken); expect(message).toContain('...'); }); + + it('masks full-email footer indicators in structured app logs', () => { + const mockLog = vi.fn(); + const rawIndicator = 'user@example.com [1/2]'; + initLogger({ app: { log: mockLog } }); + + logError('persisted footer indicator', { + messageInfo: { + variant: rawIndicator, + model: { + variant: rawIndicator, + }, + }, + }); + + const call = mockLog.mock.calls[0][0] as { + body: { + extra?: { + data?: { + messageInfo?: { + variant?: string; + model?: { variant?: string }; + }; + }; + }; + }; + }; + const data = call.body.extra?.data; + const serialized = JSON.stringify(call); + + expect(data?.messageInfo?.variant).toBe('us***@***.com [1/2]'); + expect(data?.messageInfo?.model?.variant).toBe('us***@***.com [1/2]'); + expect(serialized).not.toContain(rawIndicator); + }); }); describe('sanitizeValue edge cases', () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 1cf69951..4b40e226 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -9,6 +9,8 @@ import { getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, + getPersistAccountFooter, + getPersistAccountFooterStyle, getRetryProfile, getRetryBudgetOverrides, getUnsupportedCodexPolicy, @@ -58,6 +60,8 @@ describe('Plugin Configuration', () => { 'CODEX_AUTH_BEGINNER_SAFE_MODE', 'CODEX_AUTH_FAST_SESSION_STRATEGY', 'CODEX_AUTH_FAST_SESSION_MAX_INPUT_ITEMS', + 'CODEX_AUTH_PERSIST_ACCOUNT_FOOTER', + 'CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE', 'CODEX_AUTH_RETRY_PROFILE', 'CODEX_AUTH_REQUEST_TRANSFORM_MODE', 'CODEX_AUTH_UNSUPPORTED_MODEL_POLICY', @@ -112,6 +116,8 @@ describe('Plugin Configuration', () => { tokenRefreshSkewMs: 60_000, rateLimitToastDebounceMs: 60_000, toastDurationMs: 5_000, + persistAccountFooter: false, + persistAccountFooterStyle: 'label-masked-email', perProjectAccounts: true, sessionRecovery: true, autoResume: true, @@ -156,6 +162,8 @@ describe('Plugin Configuration', () => { tokenRefreshSkewMs: 60_000, rateLimitToastDebounceMs: 60_000, toastDurationMs: 5_000, + persistAccountFooter: false, + persistAccountFooterStyle: 'label-masked-email', perProjectAccounts: true, sessionRecovery: true, autoResume: true, @@ -197,6 +205,8 @@ describe('Plugin Configuration', () => { tokenRefreshSkewMs: 60_000, rateLimitToastDebounceMs: 60_000, toastDurationMs: 5_000, + persistAccountFooter: false, + persistAccountFooterStyle: 'label-masked-email', perProjectAccounts: true, sessionRecovery: true, autoResume: true, @@ -249,6 +259,8 @@ describe('Plugin Configuration', () => { tokenRefreshSkewMs: 60_000, rateLimitToastDebounceMs: 60_000, toastDurationMs: 5_000, + persistAccountFooter: false, + persistAccountFooterStyle: 'label-masked-email', perProjectAccounts: true, sessionRecovery: true, autoResume: true, @@ -295,6 +307,8 @@ describe('Plugin Configuration', () => { tokenRefreshSkewMs: 60_000, rateLimitToastDebounceMs: 60_000, toastDurationMs: 5_000, + persistAccountFooter: false, + persistAccountFooterStyle: 'label-masked-email', perProjectAccounts: true, sessionRecovery: true, autoResume: true, @@ -677,6 +691,58 @@ describe('Plugin Configuration', () => { }); }); + describe('getPersistAccountFooter', () => { + it('should default to false', () => { + delete process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER; + expect(getPersistAccountFooter({})).toBe(false); + }); + + it('should use config value when env var is not set', () => { + delete process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER; + expect(getPersistAccountFooter({ persistAccountFooter: true })).toBe(true); + }); + + it('should prioritize env var over config', () => { + process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER = '0'; + expect(getPersistAccountFooter({ persistAccountFooter: true })).toBe(false); + process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER = '1'; + expect(getPersistAccountFooter({ persistAccountFooter: false })).toBe(true); + }); + }); + + describe('getPersistAccountFooterStyle', () => { + it('should default to label-masked-email', () => { + delete process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE; + expect(getPersistAccountFooterStyle({})).toBe('label-masked-email'); + }); + + it('should use config value when env var is not set', () => { + delete process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE; + expect( + getPersistAccountFooterStyle({ persistAccountFooterStyle: 'full-email' }), + ).toBe('full-email'); + }); + + it('should prioritize valid env values over config', () => { + process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE = 'label-only'; + expect( + getPersistAccountFooterStyle({ + persistAccountFooterStyle: 'full-email', + }), + ).toBe('label-only'); + }); + + it('should ignore invalid env values and fall back to config/default', () => { + process.env.CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE = 'masked-only'; + expect( + getPersistAccountFooterStyle({ + persistAccountFooterStyle: 'full-email', + }), + ).toBe('full-email'); + expect(getPersistAccountFooterStyle({})).toBe('label-masked-email'); + }); + }); + describe('Priority order', () => { it('should follow priority: env var > config file > default', () => { // Test 1: env var overrides config diff --git a/test/schemas.test.ts b/test/schemas.test.ts index 81283aff..f9ae802a 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -48,6 +48,8 @@ describe("PluginConfigSchema", () => { tokenRefreshSkewMs: 60000, rateLimitToastDebounceMs: 30000, toastDurationMs: 5000, + persistAccountFooter: true, + persistAccountFooterStyle: "label-masked-email", perProjectAccounts: true, sessionRecovery: true, autoResume: false, @@ -85,6 +87,13 @@ describe("PluginConfigSchema", () => { expect(result.success).toBe(false); }); + it("rejects invalid persistAccountFooterStyle", () => { + const result = PluginConfigSchema.safeParse({ + persistAccountFooterStyle: "masked-only", + }); + expect(result.success).toBe(false); + }); + it("rejects invalid retryProfile", () => { const result = PluginConfigSchema.safeParse({ retryProfile: "wild" }); expect(result.success).toBe(false);