From 463696a1f06a4075d73a73f5ce300ff558a7d6bd Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 23:24:06 +0800 Subject: [PATCH 01/39] add persistent account footer and toast cleanup --- index.ts | 353 ++++++++++++++++++++++++++++------ lib/config.ts | 26 +++ lib/schemas.ts | 4 + test/index.test.ts | 376 ++++++++++++++++++++++++++++++++++++- test/plugin-config.test.ts | 66 +++++++ test/schemas.test.ts | 9 + 6 files changed, 775 insertions(+), 59 deletions(-) diff --git a/index.ts b/index.ts index e29c9179..5072e324 100644 --- a/index.ts +++ b/index.ts @@ -55,6 +55,8 @@ import { getSessionRecovery, getAutoResume, getToastDurationMs, + getPersistAccountFooter, + getPersistAccountFooterStyle, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, @@ -83,6 +85,7 @@ import { logInfo, logWarn, logError, + maskEmail, setCorrelationId, clearCorrelationId, } from "./lib/logger.js"; @@ -209,6 +212,28 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let startupPreflightShown = false; let beginnerSafeModeEnabled = false; const MIN_BACKOFF_MS = 100; + const MAX_PERSISTED_ACCOUNT_INDICATORS = 200; + + type PersistedAccountDetails = { + accountId?: string; + accountLabel?: string; + email?: string; + }; + + type SessionModelRef = { + providerID: string; + modelID: string; + }; + + type PersistAccountFooterStyle = + | "label-masked-email" + | "full-email" + | "label-only"; + + type PersistedAccountIndicatorEntry = { + label: string; + revision: number; + }; type SelectionSnapshot = { timestamp: number; @@ -371,13 +396,50 @@ 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 accountId = account.accountId?.trim(); + const idSuffix = accountId + ? accountId.length > 6 + ? accountId.slice(-6) + : accountId + : null; + return accountLabel || + (idSuffix ? `Account ${index + 1} [id:${idSuffix}]` : `Account ${index + 1}`); + }; + + 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 = ( + const buildManualOAuthFlow = ( pkce: { verifier: string }, url: string, expectedState: string, @@ -1084,6 +1146,115 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; + const persistedAccountIndicators = new Map(); + let persistedAccountIndicatorRevision = 0; + + const nextPersistedAccountIndicatorRevision = (): number => { + persistedAccountIndicatorRevision += 1; + return persistedAccountIndicatorRevision; + }; + + const resolvePersistedAccountSessionID = ( + ...candidates: Array + ): string | undefined => { + for (const candidate of [process.env.CODEX_THREAD_ID, ...candidates]) { + const sessionID = candidate?.toString().trim(); + if (sessionID) { + return sessionID; + } + } + return undefined; + }; + + const trimPersistedAccountIndicators = (): void => { + while (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { + const oldestKey = persistedAccountIndicators.keys().next().value; + if (!oldestKey) break; + persistedAccountIndicators.delete(oldestKey); + } + }; + + const setPersistedAccountIndicator = ( + sessionID: string | null | undefined, + account: PersistedAccountDetails, + index: number, + accountCount: number, + style: PersistAccountFooterStyle, + revision: number = nextPersistedAccountIndicatorRevision(), + ): boolean => { + if (!sessionID) return false; + const existing = persistedAccountIndicators.get(sessionID); + if (existing && existing.revision > revision) { + return false; + } + persistedAccountIndicators.delete(sessionID); + persistedAccountIndicators.set(sessionID, { + label: formatPersistedAccountIndicator(account, index, accountCount, style), + revision, + }); + trimPersistedAccountIndicators(); + return true; + }; + + const refreshVisiblePersistedAccountIndicators = ( + account: PersistedAccountDetails, + index: number, + accountCount: number, + style: PersistAccountFooterStyle, + ): boolean => { + const sessionIDs = Array.from(persistedAccountIndicators.keys()); + if (sessionIDs.length === 0) return false; + const revision = nextPersistedAccountIndicatorRevision(); + for (const sessionID of sessionIDs) { + setPersistedAccountIndicator( + sessionID, + account, + index, + accountCount, + style, + 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 => { + 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; + if (!providerID || !modelID) { + return; + } + messageInfo.model = { + ...existingModel, + providerID, + modelID, + variant: indicatorLabel, + }; + }; + const resolveActiveIndex = ( storage: { activeIndex: number; @@ -1655,12 +1826,14 @@ 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 now = Date.now(); + account.lastUsed = now; + account.lastSwitchReason = "rotation"; storage.activeIndex = index; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { @@ -1677,7 +1850,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManagerPromise = Promise.resolve(reloadedManager); } - await showToast(`Switched to account ${index + 1}`, "info"); + const pluginConfig = loadPluginConfig(); + if (getPersistAccountFooter(pluginConfig)) { + refreshVisiblePersistedAccountIndicators( + account, + index, + storage.accounts.length, + getPersistAccountFooterStyle(pluginConfig), + ); + } else { + await showToast(`Switched to account ${index + 1}`, "info"); + } } } } catch (error) { @@ -1690,6 +1873,59 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return { event: eventHandler, + "chat.message": ( + input: { + sessionID: string; + model?: SessionModelRef; + }, + output: { message: unknown; parts: unknown[] }, + ): Promise => { + const indicator = getPersistedAccountIndicatorLabel(input.sessionID); + if (indicator) { + const message = + typeof output.message === "object" && output.message !== null + ? (output.message as Record) + : null; + if (message) { + applyPersistedAccountIndicator(message, indicator, input.model); + } + } + return Promise.resolve(); + }, + "experimental.chat.messages.transform": ( + _input: Record, + output: { + messages: Array<{ + info: Record; + parts: unknown[]; + }>; + }, + ): Promise => { + 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 = + 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, /** @@ -1806,6 +2042,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig); const toastDurationMs = getToastDurationMs(pluginConfig); + const persistAccountFooter = getPersistAccountFooter(pluginConfig); + const persistAccountFooterStyle = + getPersistAccountFooterStyle(pluginConfig); const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); @@ -1855,6 +2094,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) => { @@ -1989,9 +2245,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let modelFamily = model ? getModelFamily(model) : "gpt-5.4"; let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; const threadIdCandidate = - (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") - .toString() - .trim() || undefined; + resolvePersistedAccountSessionID(promptCacheKey); + const indicatorRevision = nextPersistedAccountIndicatorRevision(); const requestCorrelationId = setCorrelationId( threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, ); @@ -2138,19 +2393,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 showTerminalToastResponse( + "Auth refresh retry budget exhausted for this request. Try again or switch accounts.", + 503, ); } runtimeMetrics.authRefreshFailures++; @@ -2227,12 +2472,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 +2553,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 showTerminalToastResponse( + "Network retry budget exhausted for this request. Try again in a moment.", + 503, ); } runtimeMetrics.failedRequests++; @@ -2617,6 +2853,19 @@ while (attempted.size < Math.max(1, accountCount)) { } accountManager.recordSuccess(account, modelFamily, model); + if (persistAccountFooter) { + const persistedStorage = await loadAccounts(); + const persistedAccountCount = persistedStorage?.accounts.length ?? + accountManager.getAccountCount(); + setPersistedAccountIndicator( + threadIdCandidate, + account, + account.index, + persistedAccountCount, + persistAccountFooterStyle, + indicatorRevision, + ); + } runtimeMetrics.successfulRequests++; runtimeMetrics.lastError = null; runtimeMetrics.lastErrorCategory = null; @@ -2662,12 +2911,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 showTerminalToastResponse( + message, + waitMs > 0 ? 429 : 503, + ); } } finally { clearCorrelationId(); diff --git a/lib/config.ts b/lib/config.ts index af93ee73..98e19e51 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -16,6 +16,11 @@ 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 PERSIST_ACCOUNT_FOOTER_STYLES = new Set([ + "label-masked-email", + "full-email", + "label-only", +]); export type UnsupportedCodexPolicy = "strict" | "fallback"; @@ -45,6 +50,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, @@ -433,6 +440,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, +): "label-masked-email" | "full-email" | "label-only" { + return resolveStringSetting( + "CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE", + pluginConfig.persistAccountFooterStyle, + "label-masked-email", + PERSIST_ACCOUNT_FOOTER_STYLES, + ); +} + export function getPerProjectAccounts(pluginConfig: PluginConfig): boolean { return resolveBooleanSetting( "CODEX_AUTH_PER_PROJECT_ACCOUNTS", diff --git a/lib/schemas.ts b/lib/schemas.ts index 6028246d..b9759537 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -42,6 +42,10 @@ 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(["label-masked-email", "full-email", "label-only"]) + .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..f5796689 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -85,12 +85,14 @@ vi.mock("../lib/config.js", () => ({ getFastSession: () => false, getFastSessionStrategy: () => "hybrid", getFastSessionMaxInputItems: () => 30, + getPersistAccountFooter: vi.fn(() => false), + getPersistAccountFooterStyle: vi.fn(() => "label-masked-email"), getRetryProfile: () => "balanced", getRetryBudgetOverrides: () => ({}), getRateLimitToastDebounceMs: () => 5000, - getRetryAllAccountsMaxRetries: () => 3, - getRetryAllAccountsMaxWaitMs: () => 30000, - getRetryAllAccountsRateLimited: () => true, + getRetryAllAccountsMaxRetries: vi.fn(() => 3), + getRetryAllAccountsMaxWaitMs: vi.fn(() => 30000), + getRetryAllAccountsRateLimited: vi.fn(() => true), getUnsupportedCodexPolicy: vi.fn(() => "fallback"), getFallbackOnUnsupportedCodexModel: vi.fn(() => true), getFallbackToGpt52OnUnsupportedGpt53: vi.fn(() => false), @@ -109,7 +111,7 @@ vi.mock("../lib/config.js", () => ({ getCodexTuiColorProfile: () => "ansi16", getCodexTuiGlyphMode: () => "ascii", getBeginnerSafeMode: () => false, - loadPluginConfig: () => ({}), + loadPluginConfig: vi.fn(() => ({})), })); vi.mock("../lib/request/request-transformer.js", () => ({ @@ -123,6 +125,16 @@ vi.mock("../lib/logger.js", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), + maskEmail: (email: string) => { + const atIndex = email.indexOf("@"); + if (atIndex < 0) return "***@***"; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const parts = domain.split("."); + const tld = parts.pop() || ""; + const prefix = local.slice(0, Math.min(2, local.length)); + return `${prefix}***@***.${tld}`; + }, setCorrelationId: vi.fn(() => "test-correlation-id"), clearCorrelationId: vi.fn(), createLogger: vi.fn(() => ({ @@ -390,6 +402,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?: { providerID: string; modelID: string }; + }, + output: { message: unknown; parts: unknown[] }, + ) => Promise; + "experimental.chat.messages.transform": ( + input: Record, + output: { + messages: Array<{ + info: { + role: string; + sessionID: string; + model?: { providerID: string; modelID: string }; + variant?: string; + thinking?: string; + }; + parts: unknown[]; + }>; + }, + ) => Promise; auth: { provider: string; methods: Array<{ label: string; type: string }>; @@ -1974,9 +2008,22 @@ describe("OpenAIOAuthPlugin edge cases", () => { describe("OpenAIOAuthPlugin fetch handler", () => { let originalFetch: typeof globalThis.fetch; + let originalThreadId: string | undefined; - beforeEach(() => { + beforeEach(async () => { 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 +2033,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,6 +2065,89 @@ describe("OpenAIOAuthPlugin fetch handler", () => { return { plugin, sdk, mockClient }; }; + 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 setRetryAllAccountsRateLimited = async (enabled: boolean) => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getRetryAllAccountsRateLimited).mockReturnValue(enabled); + }; + + 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: "ok" }), { status: 200 }), + ); + + return await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify(requestBody), + }); + }; + + 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") => ({ + messages: [ + { + info: { + role: "user", + sessionID, + model: { providerID: "openai", modelID }, + }, + parts: [], + }, + ], + }); + + 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 }), @@ -2025,6 +2162,193 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(response.status).toBe(200); }); + 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(); + + await sendPersistedAccountRequest(sdk, "session-masked"); + + expect((await readPersistedAccountIndicator(plugin, "session-masked")).variant).toBe( + expectedMaskedIndicator, + ); + }); + + 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("decorates the last user message with a label-only indicator when configured", async () => { + await enablePersistedFooter("label-only"); + const { plugin, sdk } = await setupPlugin(); + + await sendPersistedAccountRequest(sdk, "session-label"); + + expect((await readPersistedAccountIndicator(plugin, "session-label")).variant).toBe( + expectedLabelOnlyIndicator, + ); + }); + + 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: [] }, + ); + + await sendPersistedAccountRequest(sdk); + + expect((await readPersistedAccountIndicator(plugin, "session-no-key")).variant).toBeUndefined(); + }); + + it("decorates chat.message output with the visible account indicator variant 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: { + model: { providerID: "openai", modelID: "gpt-5.4" }, + }, + 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("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 { plugin, sdk, mockClient } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-switch"); + 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((await readPersistedAccountIndicator(plugin, "session-switch")).variant).toBe( + "user2@example.com [2/2]", + ); + }); + + 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; + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + 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 new Promise((resolve) => setTimeout(resolve, 0)); + + 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("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 { 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")); @@ -2086,7 +2410,8 @@ describe("OpenAIOAuthPlugin fetch handler", () => { new Response(JSON.stringify({ content: "should-not-be-returned" }), { status: 200 }), ); - const { sdk } = await setupPlugin(); + 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" }), @@ -2095,6 +2420,45 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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("uses a warning toast for all-accounts rate-limit terminal responses", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + await setRetryAllAccountsRateLimited(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(); }); 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); From ee7b855640b7523b870dcca31882c8013ba6b38f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 23:53:12 +0800 Subject: [PATCH 02/39] fix footer review findings --- index.ts | 28 ++++++++++++++++++----- test/index.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 5072e324..3091868c 100644 --- a/index.ts +++ b/index.ts @@ -1148,16 +1148,26 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const persistedAccountIndicators = new Map(); let persistedAccountIndicatorRevision = 0; + let persistedAccountCountHint = 0; const nextPersistedAccountIndicatorRevision = (): number => { persistedAccountIndicatorRevision += 1; return persistedAccountIndicatorRevision; }; + const updatePersistedAccountCountHint = ( + count: number | null | undefined, + ): void => { + if (!Number.isFinite(count) || count === undefined || count === null) { + return; + } + persistedAccountCountHint = Math.max(0, Math.trunc(count)); + }; + const resolvePersistedAccountSessionID = ( ...candidates: Array ): string | undefined => { - for (const candidate of [process.env.CODEX_THREAD_ID, ...candidates]) { + for (const candidate of [...candidates, process.env.CODEX_THREAD_ID]) { const sessionID = candidate?.toString().trim(); if (sessionID) { return sessionID; @@ -1841,6 +1851,7 @@ 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. @@ -1916,10 +1927,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (!lastUserMessage) return Promise.resolve(); - const sessionID = + const sessionID = resolvePersistedAccountSessionID( typeof lastUserMessage.info.sessionID === "string" ? lastUserMessage.info.sessionID - : undefined; + : undefined, + ); const indicator = getPersistedAccountIndicatorLabel(sessionID); if (!indicator) return Promise.resolve(); @@ -1981,6 +1993,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } let accountManager = await accountManagerPromise; cachedAccountManager = accountManager; + updatePersistedAccountCountHint( + (await loadAccounts())?.accounts.length ?? accountManager.getAccountCount(), + ); const refreshToken = authFallback?.refresh ?? ""; const needsPersist = refreshToken && @@ -2854,9 +2869,10 @@ while (attempted.size < Math.max(1, accountCount)) { accountManager.recordSuccess(account, modelFamily, model); if (persistAccountFooter) { - const persistedStorage = await loadAccounts(); - const persistedAccountCount = persistedStorage?.accounts.length ?? - accountManager.getAccountCount(); + const persistedAccountCount = + persistedAccountCountHint > 0 + ? persistedAccountCountHint + : accountManager.getAccountCount(); setPersistedAccountIndicator( threadIdCandidate, account, diff --git a/test/index.test.ts b/test/index.test.ts index f5796689..5b4c5274 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -415,7 +415,7 @@ type PluginType = { messages: Array<{ info: { role: string; - sessionID: string; + sessionID?: string; model?: { providerID: string; modelID: string }; variant?: string; thinking?: string; @@ -2184,6 +2184,23 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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(); + + await sendPersistedAccountRequest(sdk, "session-warmup"); + vi.mocked(storageModule.loadAccounts).mockClear(); + + await sendPersistedAccountRequest(sdk, "session-no-read"); + + expect(storageModule.loadAccounts).not.toHaveBeenCalled(); + }); + it("decorates the last user message with a label-only indicator when configured", async () => { await enablePersistedFooter("label-only"); const { plugin, sdk } = await setupPlugin(); @@ -2239,6 +2256,44 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect((await readPersistedAccountIndicator(plugin, "session-chat-message")).thinking).toBeUndefined(); }); + it("prefers the explicit request session key over CODEX_THREAD_ID", 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, "session-explicit")).variant).toBe( + expectedFullIndicator, + ); + expect((await readPersistedAccountIndicator(plugin, "env-session")).variant).toBeUndefined(); + }); + + 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("suppresses account-switch info toasts when the footer is enabled and refreshes the visible indicator", async () => { await enablePersistedFooter("full-email"); mockStorage.accounts = [ From 15867c98c773be7bb58913b4eb72b6eb09504969 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:04:49 +0800 Subject: [PATCH 03/39] fix stale footer account counts --- index.ts | 9 ++++++--- test/index.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 3091868c..69a3acbd 100644 --- a/index.ts +++ b/index.ts @@ -2869,10 +2869,13 @@ while (attempted.size < Math.max(1, accountCount)) { accountManager.recordSuccess(account, modelFamily, model); if (persistAccountFooter) { + const liveAccountCount = accountManager.getAccountCount(); const persistedAccountCount = - persistedAccountCountHint > 0 - ? persistedAccountCountHint - : accountManager.getAccountCount(); + liveAccountCount > 0 + ? liveAccountCount + : persistedAccountCountHint > 0 + ? persistedAccountCountHint + : 1; setPersistedAccountIndicator( threadIdCandidate, account, diff --git a/test/index.test.ts b/test/index.test.ts index 5b4c5274..92fdb3f7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2201,6 +2201,40 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(storageModule.loadAccounts).not.toHaveBeenCalled(); }); + 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("decorates the last user message with a label-only indicator when configured", async () => { await enablePersistedFooter("label-only"); const { plugin, sdk } = await setupPlugin(); @@ -2300,6 +2334,20 @@ describe("OpenAIOAuthPlugin fetch handler", () => { { 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, mockClient } = await setupPlugin(); await sendPersistedAccountRequest(sdk, "session-switch"); From 47351771f94f587df69748d5b8404f20817a81e5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:20:35 +0800 Subject: [PATCH 04/39] Avoid extra footer storage reads during loader init --- index.ts | 6 ++---- test/index.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 69a3acbd..61930520 100644 --- a/index.ts +++ b/index.ts @@ -1158,7 +1158,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const updatePersistedAccountCountHint = ( count: number | null | undefined, ): void => { - if (!Number.isFinite(count) || count === undefined || count === null) { + if (typeof count !== "number" || !Number.isFinite(count)) { return; } persistedAccountCountHint = Math.max(0, Math.trunc(count)); @@ -1993,9 +1993,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } let accountManager = await accountManagerPromise; cachedAccountManager = accountManager; - updatePersistedAccountCountHint( - (await loadAccounts())?.accounts.length ?? accountManager.getAccountCount(), - ); + updatePersistedAccountCountHint(accountManager.getAccountCount()); const refreshToken = authFallback?.refresh ?? ""; const needsPersist = refreshToken && diff --git a/test/index.test.ts b/test/index.test.ts index 92fdb3f7..504ac17d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2201,6 +2201,33 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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; + }; + + 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 = [ From 0577c35eadb928413b8b9a275d6f48422fc3768f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 00:46:24 +0800 Subject: [PATCH 05/39] Cache footer config for switch refreshes --- index.ts | 40 ++++++++++++++++++++++++++++++---------- test/index.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/index.ts b/index.ts index 61930520..d957c230 100644 --- a/index.ts +++ b/index.ts @@ -1149,6 +1149,9 @@ 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"; const nextPersistedAccountIndicatorRevision = (): number => { persistedAccountIndicatorRevision += 1; @@ -1191,17 +1194,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountCount: number, style: PersistAccountFooterStyle, revision: number = nextPersistedAccountIndicatorRevision(), + options?: { preserveOrder?: boolean }, ): boolean => { if (!sessionID) return false; const existing = persistedAccountIndicators.get(sessionID); if (existing && existing.revision > revision) { return false; } - persistedAccountIndicators.delete(sessionID); - persistedAccountIndicators.set(sessionID, { + const nextEntry = { label: formatPersistedAccountIndicator(account, index, accountCount, style), revision, - }); + }; + if (existing && options?.preserveOrder) { + persistedAccountIndicators.set(sessionID, nextEntry); + return true; + } + if (existing) { + persistedAccountIndicators.delete(sessionID); + } + persistedAccountIndicators.set(sessionID, nextEntry); trimPersistedAccountIndicators(); return true; }; @@ -1223,6 +1234,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountCount, style, revision, + { preserveOrder: true }, ); } return true; @@ -1393,8 +1405,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; + const syncRuntimePluginConfig = ( + pluginConfig: ReturnType, + ): UiRuntimeOptions => { + runtimePersistAccountFooter = getPersistAccountFooter(pluginConfig); + runtimePersistAccountFooterStyle = + getPersistAccountFooterStyle(pluginConfig); + return applyUiRuntimeFromConfig(pluginConfig); + }; + const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); + return syncRuntimePluginConfig(loadPluginConfig()); }; const getStatusMarker = ( @@ -1861,13 +1882,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManagerPromise = Promise.resolve(reloadedManager); } - const pluginConfig = loadPluginConfig(); - if (getPersistAccountFooter(pluginConfig)) { + if (runtimePersistAccountFooter) { refreshVisiblePersistedAccountIndicators( account, index, storage.accounts.length, - getPersistAccountFooterStyle(pluginConfig), + runtimePersistAccountFooterStyle, ); } else { await showToast(`Switched to account ${index + 1}`, "info"); @@ -1957,7 +1977,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; @@ -2949,7 +2969,7 @@ while (attempted.size < Math.max(1, accountCount)) { type: "oauth" as const, authorize: async (inputs?: Record) => { const authPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(authPluginConfig); + syncRuntimePluginConfig(authPluginConfig); const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig); setStoragePath(authPerProjectAccounts ? process.cwd() : null); @@ -3924,7 +3944,7 @@ while (attempted.size < Math.max(1, accountCount)) { // Initialize storage path for manual OAuth flow // Must happen BEFORE persistAccountPool to ensure correct storage location const manualPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(manualPluginConfig); + syncRuntimePluginConfig(manualPluginConfig); const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig); setStoragePath(manualPerProjectAccounts ? process.cwd() : null); diff --git a/test/index.test.ts b/test/index.test.ts index 504ac17d..782dd47a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2375,9 +2375,12 @@ describe("OpenAIOAuthPlugin fetch handler", () => { { 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( @@ -2394,6 +2397,9 @@ describe("OpenAIOAuthPlugin fetch handler", () => { variant: "info", }, }); + expect(vi.mocked(configModule.loadPluginConfig)).toHaveBeenCalledTimes( + configReadCountBeforeSwitch, + ); expect((await readPersistedAccountIndicator(plugin, "session-switch")).variant).toBe( "user2@example.com [2/2]", ); @@ -2459,6 +2465,36 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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 maxPersistedIndicators = 200; + const sessionIDs = Array.from( + { length: maxPersistedIndicators }, + (_, 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("suppresses the account-use info toast when the footer is enabled", async () => { await enablePersistedFooter("full-email"); mockStorage.accounts = [ From ede4db39f3c097ac0c47201e153eec9ba0bd4633 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:06:42 +0800 Subject: [PATCH 06/39] Keep footer config synced across runtime refreshes --- index.ts | 12 ++++++------ test/index.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/index.ts b/index.ts index d957c230..a8e73fab 100644 --- a/index.ts +++ b/index.ts @@ -2075,9 +2075,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig); const toastDurationMs = getToastDurationMs(pluginConfig); - const persistAccountFooter = getPersistAccountFooter(pluginConfig); - const persistAccountFooterStyle = - getPersistAccountFooterStyle(pluginConfig); const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); @@ -2176,6 +2173,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { init?: RequestInit, ): Promise { try { + // Keep request-time toast behavior and later account.switch refreshes + // aligned with the latest config/env snapshot. + syncRuntimePluginConfig(loadPluginConfig()); if (cachedAccountManager && cachedAccountManager !== accountManager) { accountManager = cachedAccountManager; } @@ -2506,7 +2506,7 @@ while (attempted.size < Math.max(1, accountCount)) { extractAccountEmail(accountAuth.access) ?? account.email; if ( - !persistAccountFooter && + !runtimePersistAccountFooter && accountCount > 1 && accountManager.shouldShowAccountToast( account.index, @@ -2886,7 +2886,7 @@ while (attempted.size < Math.max(1, accountCount)) { } accountManager.recordSuccess(account, modelFamily, model); - if (persistAccountFooter) { + if (runtimePersistAccountFooter) { const liveAccountCount = accountManager.getAccountCount(); const persistedAccountCount = liveAccountCount > 0 @@ -2899,7 +2899,7 @@ while (attempted.size < Math.max(1, accountCount)) { account, account.index, persistedAccountCount, - persistAccountFooterStyle, + runtimePersistAccountFooterStyle, indicatorRevision, ); } diff --git a/test/index.test.ts b/test/index.test.ts index 782dd47a..3a520a6e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2405,6 +2405,44 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + it("syncs account-switch footer behavior after a runtime config 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 plugin.auth.loader( + async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }), + { options: {}, models: {} }, + ); + 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From ca660603984012b484e94ddfa8df2b67d222a7d5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:07:24 +0800 Subject: [PATCH 07/39] Align footer refresh regression with runtime config flow --- test/index.test.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 3a520a6e..a474583e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2405,7 +2405,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); - it("syncs account-switch footer behavior after a runtime config refresh enables it", async () => { + it("syncs account-switch footer behavior after a fetch reload enables it", async () => { await disablePersistedFooter(); mockStorage.accounts = [ { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, @@ -2415,16 +2415,6 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const { plugin, sdk, mockClient } = await setupPlugin(); await enablePersistedFooter("full-email"); - await plugin.auth.loader( - async () => ({ - type: "oauth" as const, - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - multiAccount: true, - }), - { options: {}, models: {} }, - ); await sendPersistedAccountRequest(sdk, "session-switch-sync"); mockClient.tui.showToast.mockClear(); From 6f89869c2d09f80c94c799b156af52510adcb3b4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:33:24 +0800 Subject: [PATCH 08/39] Stabilize persisted account footer metadata --- index.ts | 48 ++++++++++++------ lib/config.ts | 14 +++--- lib/persist-account-footer.ts | 8 +++ lib/schemas.ts | 5 +- test/index.test.ts | 94 ++++++++++++++++++++++++----------- 5 files changed, 114 insertions(+), 55 deletions(-) create mode 100644 lib/persist-account-footer.ts diff --git a/index.ts b/index.ts index a8e73fab..4459284e 100644 --- a/index.ts +++ b/index.ts @@ -126,6 +126,7 @@ import { type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; +import type { PersistAccountFooterStyle } from "./lib/persist-account-footer.js"; import { createCodexHeaders, extractRequestUrl, @@ -216,8 +217,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { type PersistedAccountDetails = { accountId?: string; + accountIdSource?: AccountIdSource; accountLabel?: string; email?: string; + access?: string; + accessToken?: string; }; type SessionModelRef = { @@ -225,11 +229,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { modelID: string; }; - type PersistAccountFooterStyle = - | "label-masked-email" - | "full-email" - | "label-only"; - type PersistedAccountIndicatorEntry = { label: string; revision: number; @@ -407,7 +406,20 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { index: number, ): string => { const accountLabel = account.accountLabel?.trim(); - const accountId = account.accountId?.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) @@ -1252,6 +1264,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { indicatorLabel: string, fallbackModel?: SessionModelRef, ): void => { + // `full-email` is an explicit user opt-in for visible variant fields only. + // Keep it out of thinking, and mask it before any downstream logging or telemetry. messageInfo.variant = indicatorLabel; const existingModel = @@ -1861,6 +1875,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!account) { return; } + const previousManagedAccount = + cachedAccountManager?.getAccountsSnapshot()[index]; const now = Date.now(); account.lastUsed = now; @@ -1876,15 +1892,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // 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); + } if (runtimePersistAccountFooter) { + // Prefer the pre-reload managed account so label-only footers keep + // the same token-derived id suffix until disk catches up. refreshVisiblePersistedAccountIndicators( - account, + previousManagedAccount ?? account, index, storage.accounts.length, runtimePersistAccountFooterStyle, @@ -2173,9 +2191,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { init?: RequestInit, ): Promise { try { - // Keep request-time toast behavior and later account.switch refreshes - // aligned with the latest config/env snapshot. - syncRuntimePluginConfig(loadPluginConfig()); + // Re-apply env overrides against the loader's config snapshot without + // another disk read on the request hot path. + syncRuntimePluginConfig(pluginConfig); if (cachedAccountManager && cachedAccountManager !== accountManager) { accountManager = cachedAccountManager; } diff --git a/lib/config.ts b/lib/config.ts index 98e19e51..cae02bfe 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,12 +20,6 @@ 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 PERSIST_ACCOUNT_FOOTER_STYLES = new Set([ - "label-masked-email", - "full-email", - "label-only", -]); - export type UnsupportedCodexPolicy = "strict" | "fallback"; /** @@ -450,12 +448,12 @@ export function getPersistAccountFooter(pluginConfig: PluginConfig): boolean { export function getPersistAccountFooterStyle( pluginConfig: PluginConfig, -): "label-masked-email" | "full-email" | "label-only" { +): PersistAccountFooterStyle { return resolveStringSetting( "CODEX_AUTH_PERSIST_ACCOUNT_FOOTER_STYLE", pluginConfig.persistAccountFooterStyle, "label-masked-email", - PERSIST_ACCOUNT_FOOTER_STYLES, + new Set(PERSIST_ACCOUNT_FOOTER_STYLES), ); } diff --git a/lib/persist-account-footer.ts b/lib/persist-account-footer.ts new file mode 100644 index 00000000..295e2054 --- /dev/null +++ b/lib/persist-account-footer.ts @@ -0,0 +1,8 @@ +export const PERSIST_ACCOUNT_FOOTER_STYLES = [ + "label-masked-email", + "full-email", + "label-only", +] as const; + +export type PersistAccountFooterStyle = + (typeof PERSIST_ACCOUNT_FOOTER_STYLES)[number]; diff --git a/lib/schemas.ts b/lib/schemas.ts index b9759537..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"; // ============================================================================ @@ -43,9 +44,7 @@ export const PluginConfigSchema = z.object({ rateLimitToastDebounceMs: z.number().min(0).optional(), toastDurationMs: z.number().min(1000).optional(), persistAccountFooter: z.boolean().optional(), - persistAccountFooterStyle: z - .enum(["label-masked-email", "full-email", "label-only"]) - .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 a474583e..2b41472f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -118,34 +118,30 @@ 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(), - maskEmail: (email: string) => { - const atIndex = email.indexOf("@"); - if (atIndex < 0) return "***@***"; - const local = email.slice(0, atIndex); - const domain = email.slice(atIndex + 1); - const parts = domain.split("."); - const tld = parts.pop() || ""; - const prefix = local.slice(0, Math.min(2, local.length)); - return `${prefix}***@***.${tld}`; - }, - 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 () => {}), @@ -2273,6 +2269,46 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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"); + const previousManager = await accountsModule.AccountManager.loadFromDisk() as unknown as { + accounts: Array<{ + index: number; + accountId: string; + email: string; + refreshToken: string; + }>; + }; + const reloadedManager = await accountsModule.AccountManager.loadFromDisk() as typeof previousManager; + previousManager.accounts = [ + { index: 0, accountId: "account-1", email: "user@example.com", refreshToken: "refresh-token" }, + { index: 1, accountId: "account-2", email: "user2@example.com", refreshToken: "refresh-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); + + const { plugin, sdk } = await setupPlugin(); + await sendPersistedAccountRequest(sdk, "session-label-switch"); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect((await readPersistedAccountIndicator(plugin, "session-label-switch")).variant).toBe( + "Account 2 [id:ount-2] [2/2]", + ); + }); + it("skips persisted indicators when the request has no session key", async () => { await enablePersistedFooter("label-masked-email"); const { plugin, sdk } = await setupPlugin(); @@ -2405,7 +2441,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); - it("syncs account-switch footer behavior after a fetch reload enables it", async () => { + 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" }, From 7c0671fdc8f5b9ef668faec6f8a4be319f3d9922 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:38:10 +0800 Subject: [PATCH 09/39] Fix persisted footer switch regression test --- test/index.test.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 2b41472f..fd514689 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2276,18 +2276,37 @@ describe("OpenAIOAuthPlugin fetch handler", () => { { 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; + }); + type TestManagedAccount = { + index: number; + accountId: string; + email: string; + refreshToken: string; + accessToken?: string; + }; const previousManager = await accountsModule.AccountManager.loadFromDisk() as unknown as { - accounts: Array<{ - index: number; - accountId: string; - email: string; - refreshToken: string; - }>; + 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" }, - { index: 1, accountId: "account-2", email: "user2@example.com", refreshToken: "refresh-2" }, + { + index: 0, + accountId: "account-1", + email: "user@example.com", + refreshToken: "refresh-token", + accessToken: "token-1", + }, + { + 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" }, From fabb18e562bb4539253c7cac24517f2a831f28ac Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 01:52:28 +0800 Subject: [PATCH 10/39] Harden persisted footer fetch guards --- index.ts | 37 ++++++++++++++++++++--------- test/index.test.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/index.ts b/index.ts index 4459284e..9d7ac028 100644 --- a/index.ts +++ b/index.ts @@ -1265,7 +1265,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fallbackModel?: SessionModelRef, ): void => { // `full-email` is an explicit user opt-in for visible variant fields only. - // Keep it out of thinking, and mask it before any downstream logging or telemetry. + // 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 = @@ -1421,15 +1422,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const syncRuntimePluginConfig = ( pluginConfig: ReturnType, - ): UiRuntimeOptions => { - runtimePersistAccountFooter = getPersistAccountFooter(pluginConfig); - runtimePersistAccountFooterStyle = + ): { + persistAccountFooter: boolean; + persistAccountFooterStyle: PersistAccountFooterStyle; + ui: UiRuntimeOptions; + } => { + const persistAccountFooter = getPersistAccountFooter(pluginConfig); + const persistAccountFooterStyle = getPersistAccountFooterStyle(pluginConfig); - return applyUiRuntimeFromConfig(pluginConfig); + runtimePersistAccountFooter = persistAccountFooter; + runtimePersistAccountFooterStyle = persistAccountFooterStyle; + return { + persistAccountFooter, + persistAccountFooterStyle, + ui: applyUiRuntimeFromConfig(pluginConfig), + }; }; const resolveUiRuntime = (): UiRuntimeOptions => { - return syncRuntimePluginConfig(loadPluginConfig()); + return syncRuntimePluginConfig(loadPluginConfig()).ui; }; const getStatusMarker = ( @@ -2192,8 +2203,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ): Promise { try { // Re-apply env overrides against the loader's config snapshot without - // another disk read on the request hot path. - syncRuntimePluginConfig(pluginConfig); + // 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; } @@ -2524,7 +2539,7 @@ while (attempted.size < Math.max(1, accountCount)) { extractAccountEmail(accountAuth.access) ?? account.email; if ( - !runtimePersistAccountFooter && + !persistAccountFooter && accountCount > 1 && accountManager.shouldShowAccountToast( account.index, @@ -2904,7 +2919,7 @@ while (attempted.size < Math.max(1, accountCount)) { } accountManager.recordSuccess(account, modelFamily, model); - if (runtimePersistAccountFooter) { + if (persistAccountFooter) { const liveAccountCount = accountManager.getAccountCount(); const persistedAccountCount = liveAccountCount > 0 @@ -2917,7 +2932,7 @@ while (attempted.size < Math.max(1, accountCount)) { account, account.index, persistedAccountCount, - runtimePersistAccountFooterStyle, + persistAccountFooterStyle, indicatorRevision, ); } diff --git a/test/index.test.ts b/test/index.test.ts index fd514689..4ffa564b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2578,12 +2578,70 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(); From d6b469e1d4abb1ba1108e736955324afc63a60b8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:15:50 +0800 Subject: [PATCH 11/39] Fix terminal toast await and event guard test --- index.ts | 6 +++--- test/index.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 9d7ac028..dfa8811f 100644 --- a/index.ts +++ b/index.ts @@ -2459,7 +2459,7 @@ while (attempted.size < Math.max(1, accountCount)) { `Auth refresh failed for account ${account.index + 1}`, ) ) { - return showTerminalToastResponse( + return await showTerminalToastResponse( "Auth refresh retry budget exhausted for this request. Try again or switch accounts.", 503, ); @@ -2619,7 +2619,7 @@ while (attempted.size < Math.max(1, accountCount)) { ) ) { accountManager.refundToken(account, modelFamily, model); - return showTerminalToastResponse( + return await showTerminalToastResponse( "Network retry budget exhausted for this request. Try again in a moment.", 503, ); @@ -2981,7 +2981,7 @@ while (attempted.size < Math.max(1, accountCount)) { runtimeMetrics.failedRequests++; runtimeMetrics.lastError = message; runtimeMetrics.lastErrorCategory = waitMs > 0 ? "rate-limit" : "account-failure"; - return showTerminalToastResponse( + return await showTerminalToastResponse( message, waitMs > 0 ? 429 : 503, ); diff --git a/test/index.test.ts b/test/index.test.ts index 4ffa564b..ac046d90 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4119,4 +4119,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({}); + }); }); From f09985aee532699a648fd5c3b494e74c541dba9b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:34:37 +0800 Subject: [PATCH 12/39] Harden footer indicator transform coverage --- index.ts | 7 ++----- test/index.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++ test/logger.test.ts | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index dfa8811f..a4ebf07c 100644 --- a/index.ts +++ b/index.ts @@ -1281,14 +1281,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { typeof existingModel.modelID === "string" ? existingModel.modelID : fallbackModel?.modelID; - if (!providerID || !modelID) { - return; - } messageInfo.model = { ...existingModel, - providerID, - modelID, variant: indicatorLabel, + ...(providerID ? { providerID } : {}), + ...(modelID ? { modelID } : {}), }; }; diff --git a/test/index.test.ts b/test/index.test.ts index ac046d90..5e546af9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2410,6 +2410,31 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toBe(expectedFullIndicator); }); + 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("suppresses account-switch info toasts when the footer is enabled and refreshes the visible indicator", async () => { await enablePersistedFooter("full-email"); mockStorage.accounts = [ @@ -2488,6 +2513,28 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toBe("user2@example.com [2/2]"); }); + it("uses the loader-synced footer setting before the first fetch completes", 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ 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', () => { From 4e785024e619acb7234333372a05d0302711c983 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 02:55:27 +0800 Subject: [PATCH 13/39] Clarify footer LRU semantics --- index.ts | 4 +++- test/index.test.ts | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index a4ebf07c..fc52bae9 100644 --- a/index.ts +++ b/index.ts @@ -1194,7 +1194,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const trimPersistedAccountIndicators = (): void => { while (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { const oldestKey = persistedAccountIndicators.keys().next().value; - if (!oldestKey) break; + if (oldestKey === undefined) break; persistedAccountIndicators.delete(oldestKey); } }; @@ -1222,6 +1222,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return true; } 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); diff --git a/test/index.test.ts b/test/index.test.ts index 5e546af9..2bcbb593 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2569,10 +2569,15 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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?.(); }), ); @@ -2581,7 +2586,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { body: JSON.stringify({ model: "gpt-5.1", prompt_cache_key: "session-stale" }), }); - await new Promise((resolve) => setTimeout(resolve, 0)); + await fetchStarted; await plugin.event({ event: { type: "account.select", properties: { index: 1 } }, From 19262a50f9bf71cb381780452380ae2c177bdf4b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:12:25 +0800 Subject: [PATCH 14/39] Keep UI runtime refresh side-effect free --- index.ts | 2 +- test/index.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index fc52bae9..7b107675 100644 --- a/index.ts +++ b/index.ts @@ -1439,7 +1439,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const resolveUiRuntime = (): UiRuntimeOptions => { - return syncRuntimePluginConfig(loadPluginConfig()).ui; + return applyUiRuntimeFromConfig(loadPluginConfig()); }; const getStatusMarker = ( diff --git a/test/index.test.ts b/test/index.test.ts index 2bcbb593..46f5d907 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2535,6 +2535,37 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }); }); + 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From 720bc56a20da11c8f6e587d45868500448e8d195 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:31:40 +0800 Subject: [PATCH 15/39] Cache footer config for auth flows --- index.ts | 14 ++++++++-- test/index.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 7b107675..918a944d 100644 --- a/index.ts +++ b/index.ts @@ -1164,6 +1164,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let runtimePersistAccountFooter = false; let runtimePersistAccountFooterStyle: PersistAccountFooterStyle = "label-masked-email"; + let runtimePluginConfigSnapshot: ReturnType | undefined; const nextPersistedAccountIndicatorRevision = (): number => { persistedAccountIndicatorRevision += 1; @@ -1429,6 +1430,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const persistAccountFooter = getPersistAccountFooter(pluginConfig); const persistAccountFooterStyle = getPersistAccountFooterStyle(pluginConfig); + runtimePluginConfigSnapshot = pluginConfig; runtimePersistAccountFooter = persistAccountFooter; runtimePersistAccountFooterStyle = persistAccountFooterStyle; return { @@ -1442,6 +1444,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return applyUiRuntimeFromConfig(loadPluginConfig()); }; + const resolveRuntimePluginConfig = (): ReturnType => { + return runtimePluginConfigSnapshot ?? loadPluginConfig(); + }; + const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", @@ -1939,7 +1945,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }, output: { message: unknown; parts: unknown[] }, ): Promise => { - const indicator = getPersistedAccountIndicatorLabel(input.sessionID); + const indicator = getPersistedAccountIndicatorLabel( + resolvePersistedAccountSessionID(input.sessionID), + ); if (indicator) { const message = typeof output.message === "object" && output.message !== null @@ -3000,7 +3008,7 @@ while (attempted.size < Math.max(1, accountCount)) { label: AUTH_LABELS.OAUTH, type: "oauth" as const, authorize: async (inputs?: Record) => { - const authPluginConfig = loadPluginConfig(); + const authPluginConfig = resolveRuntimePluginConfig(); syncRuntimePluginConfig(authPluginConfig); const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig); setStoragePath(authPerProjectAccounts ? process.cwd() : null); @@ -3975,7 +3983,7 @@ 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(); + const manualPluginConfig = resolveRuntimePluginConfig(); syncRuntimePluginConfig(manualPluginConfig); const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig); setStoragePath(manualPerProjectAccounts ? process.cwd() : null); diff --git a/test/index.test.ts b/test/index.test.ts index 46f5d907..8095c78f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2410,6 +2410,33 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).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: { + 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("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"; @@ -2566,6 +2593,47 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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" }; + const lockedConfig = { source: "locked-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(lockedConfig); + 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From 6aba74a97d02b95ef4ea8c2618f145f8e224157d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:40:26 +0800 Subject: [PATCH 16/39] Trim footer test helper and revision churn --- index.ts | 4 +++- test/index.test.ts | 8 ++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 918a944d..baa38a19 100644 --- a/index.ts +++ b/index.ts @@ -2319,7 +2319,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; const threadIdCandidate = resolvePersistedAccountSessionID(promptCacheKey); - const indicatorRevision = nextPersistedAccountIndicatorRevision(); + const indicatorRevision = persistAccountFooter + ? nextPersistedAccountIndicatorRevision() + : 0; const requestCorrelationId = setCorrelationId( threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, ); diff --git a/test/index.test.ts b/test/index.test.ts index 8095c78f..488dec68 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2082,11 +2082,6 @@ describe("OpenAIOAuthPlugin fetch handler", () => { vi.mocked(configModule.getPersistAccountFooter).mockReturnValue(false); }; - const setRetryAllAccountsRateLimited = async (enabled: boolean) => { - const configModule = await import("../lib/config.js"); - vi.mocked(configModule.getRetryAllAccountsRateLimited).mockReturnValue(enabled); - }; - const sendPersistedAccountRequest = async ( sdk: Awaited>["sdk"], promptCacheKey?: string, @@ -2890,7 +2885,8 @@ describe("OpenAIOAuthPlugin fetch handler", () => { it("uses a warning toast for all-accounts rate-limit terminal responses", async () => { const { AccountManager } = await import("../lib/accounts.js"); - await setRetryAllAccountsRateLimited(false); + 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") From 8ef68b990bac081b7bbe5539dd1b8b587c57eada Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 03:55:36 +0800 Subject: [PATCH 17/39] Fix first-switch footer feedback gap --- index.ts | 13 +++--- test/index.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/index.ts b/index.ts index baa38a19..d566eb15 100644 --- a/index.ts +++ b/index.ts @@ -1914,16 +1914,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManagerPromise = Promise.resolve(reloadedManager); } - if (runtimePersistAccountFooter) { - // Prefer the pre-reload managed account so label-only footers keep - // the same token-derived id suffix until disk catches up. - refreshVisiblePersistedAccountIndicators( + if ( + !runtimePersistAccountFooter || + !refreshVisiblePersistedAccountIndicators( + // Prefer the pre-reload managed account so label-only footers keep + // the same token-derived id suffix until disk catches up. previousManagedAccount ?? account, index, storage.accounts.length, runtimePersistAccountFooterStyle, - ); - } else { + ) + ) { await showToast(`Switched to account ${index + 1}`, "info"); } } diff --git a/test/index.test.ts b/test/index.test.ts index 488dec68..2cfbc1a2 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2535,7 +2535,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toBe("user2@example.com [2/2]"); }); - it("uses the loader-synced footer setting before the first fetch completes", async () => { + it("falls back to 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" }, @@ -2549,7 +2549,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { event: { type: "account.select", properties: { index: 1 } }, }); - expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ + expect(mockClient.tui.showToast).toHaveBeenCalledWith({ body: { message: "Switched to account 2", variant: "info", @@ -2694,6 +2694,108 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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 = [ From 912932a03c5c331a2fa0eb52e56305bcc210cfa9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 04:13:52 +0800 Subject: [PATCH 18/39] Align footer session lookup with thread id --- index.ts | 9 +++++++-- test/index.test.ts | 31 ++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index d566eb15..12a2eef8 100644 --- a/index.ts +++ b/index.ts @@ -2318,8 +2318,13 @@ 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; - const threadIdCandidate = - resolvePersistedAccountSessionID(promptCacheKey); + // 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. + const threadIdCandidate = resolvePersistedAccountSessionID( + process.env.CODEX_THREAD_ID, + promptCacheKey, + ); const indicatorRevision = persistAccountFooter ? nextPersistedAccountIndicatorRevision() : 0; diff --git a/test/index.test.ts b/test/index.test.ts index 2cfbc1a2..389e4342 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2367,17 +2367,16 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect((await readPersistedAccountIndicator(plugin, "session-chat-message")).thinking).toBeUndefined(); }); - it("prefers the explicit request session key over CODEX_THREAD_ID", async () => { + 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, "session-explicit")).variant).toBe( + expect((await readPersistedAccountIndicator(plugin, "env-session")).variant).toBe( expectedFullIndicator, ); - expect((await readPersistedAccountIndicator(plugin, "env-session")).variant).toBeUndefined(); }); it("falls back to CODEX_THREAD_ID in the transform hook when the message session is missing", async () => { @@ -2432,6 +2431,32 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + it("sets the chat.message indicator 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: {}, + parts: [], + }; + + await expect( + plugin["chat.message"]( + { + sessionID: "session-chat-message-no-model", + }, + output, + ), + ).resolves.toBeUndefined(); + + expect((output.message as { variant?: string }).variant).toBe(expectedFullIndicator); + expect((output.message as { model?: { variant?: string } }).model?.variant).toBe( + expectedFullIndicator, + ); + }); + 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"; From 3359386605cfd6544095d03c39344addb6ecf151 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 04:30:37 +0800 Subject: [PATCH 19/39] Guard chat footer decoration by role --- index.ts | 4 +++- test/index.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 12a2eef8..f0687f7b 100644 --- a/index.ts +++ b/index.ts @@ -1954,7 +1954,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { typeof output.message === "object" && output.message !== null ? (output.message as Record) : null; - if (message) { + const messageRole = + typeof message?.role === "string" ? message.role : "user"; + if (message && messageRole === "user") { applyPersistedAccountIndicator(message, indicator, input.model); } } diff --git a/test/index.test.ts b/test/index.test.ts index 389e4342..c7e04c97 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2457,6 +2457,36 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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"; From bdb4135209910accb68485f41463aac320d7a95d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 04:56:58 +0800 Subject: [PATCH 20/39] Tighten live chat footer coverage --- index.ts | 7 +++---- test/index.test.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index f0687f7b..bc8cc98e 100644 --- a/index.ts +++ b/index.ts @@ -2323,10 +2323,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // 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. - const threadIdCandidate = resolvePersistedAccountSessionID( - process.env.CODEX_THREAD_ID, - promptCacheKey, - ); + const runtimeThreadId = process.env.CODEX_THREAD_ID?.toString().trim(); + const threadIdCandidate = + runtimeThreadId || resolvePersistedAccountSessionID(promptCacheKey); const indicatorRevision = persistAccountFooter ? nextPersistedAccountIndicatorRevision() : 0; diff --git a/test/index.test.ts b/test/index.test.ts index c7e04c97..7e8bb649 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2339,13 +2339,14 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect((await readPersistedAccountIndicator(plugin, "session-no-key")).variant).toBeUndefined(); }); - it("decorates chat.message output with the visible account indicator variant without leaking to thinking", async () => { + 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" }, }, parts: [], @@ -2431,14 +2432,14 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); - it("sets the chat.message indicator when model info is absent", async () => { + 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: {}, + message: { role: "user" }, parts: [], }; @@ -2446,12 +2447,19 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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, ); From 7bf5fbc19be05b5831be6d038d72719748ef9320 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 05:11:52 +0800 Subject: [PATCH 21/39] Tighten chat footer role guards --- index.ts | 2 +- test/index.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index bc8cc98e..6485c655 100644 --- a/index.ts +++ b/index.ts @@ -1955,7 +1955,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ? (output.message as Record) : null; const messageRole = - typeof message?.role === "string" ? message.role : "user"; + typeof message?.role === "string" ? message.role : undefined; if (message && messageRole === "user") { applyPersistedAccountIndicator(message, indicator, input.model); } diff --git a/test/index.test.ts b/test/index.test.ts index 7e8bb649..3f36167c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2253,6 +2253,39 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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) + .mockImplementationOnce(() => 0); + + await sendPersistedAccountRequest(sdk, "session-count-hint"); + + expect((await readPersistedAccountIndicator(plugin, "session-count-hint")).variant).toBe( + "user@example.com [1/2]", + ); + }); + it("decorates the last user message with a label-only indicator when configured", async () => { await enablePersistedFooter("label-only"); const { plugin, sdk } = await setupPlugin(); @@ -2414,6 +2447,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const output = { message: { + role: "user", model: { providerID: "openai", modelID: "gpt-5.4" }, }, parts: [], @@ -2432,6 +2466,33 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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(); From c7e10e3a884baff6adb59a295de1086939851295 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:26:24 +0800 Subject: [PATCH 22/39] Harden footer count hint regression test --- test/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/index.test.ts b/test/index.test.ts index 3f36167c..826e2b6e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2277,7 +2277,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const { plugin, sdk } = await setupPlugin(); vi.spyOn(manager, "getAccountCount") .mockImplementationOnce(() => 2) - .mockImplementationOnce(() => 0); + .mockImplementation(() => 0); await sendPersistedAccountRequest(sdk, "session-count-hint"); From 50249be4735aaae4fcda4a307c4f198dff760455 Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:41:08 +0800 Subject: [PATCH 23/39] Clear stale footer indicators when disabled --- index.ts | 12 +++++++++++- test/index.test.ts | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 6485c655..c13fd8b4 100644 --- a/index.ts +++ b/index.ts @@ -203,6 +203,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); @@ -213,7 +215,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let startupPreflightShown = false; let beginnerSafeModeEnabled = false; const MIN_BACKOFF_MS = 100; - const MAX_PERSISTED_ACCOUNT_INDICATORS = 200; type PersistedAccountDetails = { accountId?: string; @@ -1430,6 +1431,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const persistAccountFooter = getPersistAccountFooter(pluginConfig); const persistAccountFooterStyle = getPersistAccountFooterStyle(pluginConfig); + if (runtimePersistAccountFooter && !persistAccountFooter) { + persistedAccountIndicators.clear(); + } runtimePluginConfigSnapshot = pluginConfig; runtimePersistAccountFooter = persistAccountFooter; runtimePersistAccountFooterStyle = persistAccountFooterStyle; @@ -1946,6 +1950,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }, output: { message: unknown; parts: unknown[] }, ): Promise => { + if (!runtimePersistAccountFooter) { + return Promise.resolve(); + } const indicator = getPersistedAccountIndicatorLabel( resolvePersistedAccountSessionID(input.sessionID), ); @@ -1971,6 +1978,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }>; }, ): Promise => { + if (!runtimePersistAccountFooter) { + return Promise.resolve(); + } let lastUserMessage: | { info: Record; diff --git a/test/index.test.ts b/test/index.test.ts index 826e2b6e..fd169353 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2581,6 +2581,40 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).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("suppresses account-switch info toasts when the footer is enabled and refreshes the visible indicator", async () => { await enablePersistedFooter("full-email"); mockStorage.accounts = [ @@ -2926,9 +2960,9 @@ describe("OpenAIOAuthPlugin fetch handler", () => { { accountId: "acc-1", email: "user@example.com", refreshToken: "refresh-token" }, { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, ]; - const maxPersistedIndicators = 200; + const { MAX_PERSISTED_ACCOUNT_INDICATORS } = await import("../index.js"); const sessionIDs = Array.from( - { length: maxPersistedIndicators }, + { length: MAX_PERSISTED_ACCOUNT_INDICATORS }, (_, index) => `session-overflow-${index}`, ); From 3bc1d89be01859eabeae6e6ed8251839f6e4a9f1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 11:40:23 +0800 Subject: [PATCH 24/39] Optimize persisted footer refresh --- index.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index c13fd8b4..7588ad0c 100644 --- a/index.ts +++ b/index.ts @@ -1207,8 +1207,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { index: number, accountCount: number, style: PersistAccountFooterStyle, - revision: number = nextPersistedAccountIndicatorRevision(), - options?: { preserveOrder?: boolean }, + revision: number, ): boolean => { if (!sessionID) return false; const existing = persistedAccountIndicators.get(sessionID); @@ -1219,10 +1218,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { label: formatPersistedAccountIndicator(account, index, accountCount, style), revision, }; - if (existing && options?.preserveOrder) { - persistedAccountIndicators.set(sessionID, nextEntry); - return true; - } if (existing) { // Default writes are true LRU touches: reinserting moves active sessions // to the tail so only inactive sessions age out first. @@ -1242,16 +1237,18 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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) { - setPersistedAccountIndicator( - sessionID, - account, - index, - accountCount, - style, - revision, - { preserveOrder: true }, - ); + const existing = persistedAccountIndicators.get(sessionID); + if (existing && existing.revision > revision) { + continue; + } + persistedAccountIndicators.set(sessionID, { label, revision }); } return true; }; From 16e685178b90fb04465baaf4410998d773edd10e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 13:49:05 +0800 Subject: [PATCH 25/39] Clarify footer refresh naming --- index.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/index.ts b/index.ts index 7588ad0c..65047c85 100644 --- a/index.ts +++ b/index.ts @@ -1892,7 +1892,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!account) { return; } - const previousManagedAccount = + const preReloadTargetAccount = cachedAccountManager?.getAccountsSnapshot()[index]; const now = Date.now(); @@ -1915,17 +1915,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManagerPromise = Promise.resolve(reloadedManager); } - if ( - !runtimePersistAccountFooter || - !refreshVisiblePersistedAccountIndicators( - // Prefer the pre-reload managed account so label-only footers keep - // the same token-derived id suffix until disk catches up. - previousManagedAccount ?? account, - index, - storage.accounts.length, - runtimePersistAccountFooterStyle, - ) - ) { + const refreshedVisibleIndicator = 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, + ) + : false; + if (!runtimePersistAccountFooter || !refreshedVisibleIndicator) { await showToast(`Switched to account ${index + 1}`, "info"); } } From 717938e72ba9613808995c86cc1926d89416ede7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 14:57:39 +0800 Subject: [PATCH 26/39] Reset footer count hint on disable --- index.ts | 1 + test/index.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/index.ts b/index.ts index 65047c85..4d5ec540 100644 --- a/index.ts +++ b/index.ts @@ -1430,6 +1430,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { getPersistAccountFooterStyle(pluginConfig); if (runtimePersistAccountFooter && !persistAccountFooter) { persistedAccountIndicators.clear(); + persistedAccountCountHint = 0; } runtimePluginConfigSnapshot = pluginConfig; runtimePersistAccountFooter = persistAccountFooter; diff --git a/test/index.test.ts b/test/index.test.ts index fd169353..ea3efa50 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2615,6 +2615,48 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).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 = [ From cec8b8d50a5ab3ee951beb247e5dbfc716a674fb Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:11:57 +0800 Subject: [PATCH 27/39] Align persisted footer test output types --- test/index.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index ea3efa50..14e4d438 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -412,7 +412,11 @@ type PluginType = { info: { role: string; sessionID?: string; - model?: { providerID: string; modelID: string }; + model?: { + providerID: string; + modelID: string; + variant?: string; + }; variant?: string; thinking?: string; }; @@ -2111,7 +2115,10 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const expectedFullIndicator = "user@example.com [1/1]"; const expectedLabelOnlyIndicator = "Account 1 [id:ount-1] [1/1]"; - const buildMessageTransformOutput = (sessionID: string, modelID = "gpt-5.1") => ({ + const buildMessageTransformOutput = ( + sessionID: string, + modelID = "gpt-5.1", + ): Parameters[1] => ({ messages: [ { info: { From 002c0d7df09be17fa396f032ae9314d2322a8b4f Mon Sep 17 00:00:00 2001 From: ndycode <405533+ndycode@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:33:09 +0800 Subject: [PATCH 28/39] Refresh auth storage path config on authorize --- index.ts | 22 ++++++++++++++-------- test/index.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/index.ts b/index.ts index 4d5ec540..83e870cd 100644 --- a/index.ts +++ b/index.ts @@ -1248,6 +1248,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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; @@ -1450,6 +1452,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return runtimePluginConfigSnapshot ?? loadPluginConfig(); }; + const refreshAuthorizeStoragePath = (): void => { + // Auth writes are infrequent, so prefer a fresh storage-location read + // while footer runtime state stays on the last loader/fetch snapshot. + const storagePluginConfig = loadPluginConfig(); + const perProjectAccounts = getPerProjectAccounts(storagePluginConfig); + setStoragePath(perProjectAccounts ? process.cwd() : null); + }; + const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", @@ -3025,10 +3035,8 @@ while (attempted.size < Math.max(1, accountCount)) { label: AUTH_LABELS.OAUTH, type: "oauth" as const, authorize: async (inputs?: Record) => { - const authPluginConfig = resolveRuntimePluginConfig(); - syncRuntimePluginConfig(authPluginConfig); - const authPerProjectAccounts = getPerProjectAccounts(authPluginConfig); - setStoragePath(authPerProjectAccounts ? process.cwd() : null); + syncRuntimePluginConfig(resolveRuntimePluginConfig()); + refreshAuthorizeStoragePath(); const accounts: TokenSuccessWithAccount[] = []; const noBrowser = @@ -4000,10 +4008,8 @@ 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 = resolveRuntimePluginConfig(); - syncRuntimePluginConfig(manualPluginConfig); - const manualPerProjectAccounts = getPerProjectAccounts(manualPluginConfig); - setStoragePath(manualPerProjectAccounts ? process.cwd() : null); + syncRuntimePluginConfig(resolveRuntimePluginConfig()); + refreshAuthorizeStoragePath(); const { pkce, state, url } = await createAuthorizationFlow(); return buildManualOAuthFlow(pkce, url, state, async (selection) => { diff --git a/test/index.test.ts b/test/index.test.ts index 14e4d438..4c5eb696 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2836,6 +2836,36 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }); }); + 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.spyOn(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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From 380c5af4ecd41981c47c4fd85b121c0fe0a66722 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 16:00:05 +0800 Subject: [PATCH 29/39] Harden footer review regressions --- index.ts | 20 +++++++--- test/index.test.ts | 99 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 83e870cd..f1ad67e9 100644 --- a/index.ts +++ b/index.ts @@ -1194,9 +1194,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const trimPersistedAccountIndicators = (): void => { - while (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { + if (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { const oldestKey = persistedAccountIndicators.keys().next().value; - if (oldestKey === undefined) break; + if (oldestKey === undefined) return; persistedAccountIndicators.delete(oldestKey); } }; @@ -1453,9 +1453,19 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const refreshAuthorizeStoragePath = (): void => { - // Auth writes are infrequent, so prefer a fresh storage-location read - // while footer runtime state stays on the last loader/fetch snapshot. - const storagePluginConfig = loadPluginConfig(); + // Auth writes should honor the latest per-project setting, but if a mocked + // or future config loader throws, keep login usable with the last snapshot. + let storagePluginConfig = runtimePluginConfigSnapshot; + try { + storagePluginConfig = loadPluginConfig(); + } catch (error) { + if (!storagePluginConfig) { + 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); }; diff --git a/test/index.test.ts b/test/index.test.ts index 4c5eb696..0aeceb43 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -413,8 +413,8 @@ type PluginType = { role: string; sessionID?: string; model?: { - providerID: string; - modelID: string; + providerID?: string; + modelID?: string; variant?: string; }; variant?: string; @@ -2293,6 +2293,40 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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( + (await readPersistedAccountIndicator(plugin, "session-count-hint-after-switch")).variant, + ).toBe("user@example.com [1/2]"); + }); + it("decorates the last user message with a label-only indicator when configured", async () => { await enablePersistedFooter("label-only"); const { plugin, sdk } = await setupPlugin(); @@ -2588,6 +2622,31 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).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(); @@ -2866,6 +2925,42 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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.spyOn(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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From 84e60f0a6ff7f82bd82a03ad5914949b8ce644c1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 16:13:11 +0800 Subject: [PATCH 30/39] Harden authorize config fallback --- index.ts | 23 +++++++--- lib/config.ts | 2 +- test/index.test.ts | 110 ++++++++++++++++++++++++++++++--------------- 3 files changed, 93 insertions(+), 42 deletions(-) diff --git a/index.ts b/index.ts index f1ad67e9..fc85969d 100644 --- a/index.ts +++ b/index.ts @@ -67,6 +67,7 @@ import { getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, + DEFAULT_CONFIG, loadPluginConfig, } from "./lib/config.js"; import { @@ -1453,13 +1454,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const refreshAuthorizeStoragePath = (): void => { - // Auth writes should honor the latest per-project setting, but if a mocked - // or future config loader throws, keep login usable with the last snapshot. - let storagePluginConfig = runtimePluginConfigSnapshot; + // Auth writes should honor the latest per-project setting, but a Windows + // config-file lock can make loadPluginConfig() fall back to 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 = runtimePluginConfigSnapshot ?? DEFAULT_CONFIG; try { - storagePluginConfig = loadPluginConfig(); + const refreshedPluginConfig = loadPluginConfig(); + if ( + refreshedPluginConfig === DEFAULT_CONFIG && + runtimePluginConfigSnapshot + ) { + logWarn( + "Falling back to cached authorize storage config after config loader returned defaults.", + ); + } else { + storagePluginConfig = refreshedPluginConfig; + } } catch (error) { - if (!storagePluginConfig) { + if (!runtimePluginConfigSnapshot) { throw error; } logWarn( diff --git a/lib/config.ts b/lib/config.ts index cae02bfe..7d34b186 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -26,7 +26,7 @@ export type UnsupportedCodexPolicy = "strict" | "fallback"; * Default plugin configuration * CODEX_MODE is enabled by default for better Codex CLI parity */ -const DEFAULT_CONFIG: PluginConfig = { +export const DEFAULT_CONFIG: PluginConfig = { codexMode: true, requestTransformMode: "native", codexTuiV2: true, diff --git a/test/index.test.ts b/test/index.test.ts index 0aeceb43..1cc3f62a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -79,40 +79,44 @@ 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, - 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: () => false, - getEmptyResponseMaxRetries: () => 2, - getEmptyResponseRetryDelayMs: () => 1000, - getPidOffsetEnabled: () => false, - getFetchTimeoutMs: () => 60000, - getStreamStallTimeoutMs: () => 45000, - getCodexTuiV2: () => false, - getCodexTuiColorProfile: () => "ansi16", - getCodexTuiGlyphMode: () => "ascii", - getBeginnerSafeMode: () => false, - loadPluginConfig: vi.fn(() => ({})), -})); +vi.mock("../lib/config.js", () => { + const DEFAULT_CONFIG = {}; + 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, + loadPluginConfig: vi.fn(() => ({})), + }; +}); vi.mock("../lib/request/request-transformer.js", () => ({ applyFastSessionDefaults: (config: T) => config, @@ -2902,7 +2906,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const authorizeConfig = { source: "authorize-config" }; vi.mocked(configModule.loadPluginConfig).mockReturnValue(loaderConfig); - vi.spyOn(configModule, "getPerProjectAccounts").mockImplementation( + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( (config) => config === authorizeConfig, ); @@ -2932,7 +2936,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { const loaderConfig = { source: "loader-config" }; vi.mocked(configModule.loadPluginConfig).mockReturnValue(loaderConfig); - vi.spyOn(configModule, "getPerProjectAccounts").mockImplementation( + vi.mocked(configModule.getPerProjectAccounts).mockImplementation( (config) => config === loaderConfig, ); @@ -2961,6 +2965,40 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From dbf09a642760d64cc178a8e49c49951af992e831 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 16:29:52 +0800 Subject: [PATCH 31/39] Avoid duplicate cold-start authorize reads --- index.ts | 57 ++++++++++++++++++++++++++++------------------ test/index.test.ts | 24 +++++++++++++++++++ 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/index.ts b/index.ts index fc85969d..4d291dcc 100644 --- a/index.ts +++ b/index.ts @@ -1453,31 +1453,36 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return runtimePluginConfigSnapshot ?? loadPluginConfig(); }; - const refreshAuthorizeStoragePath = (): void => { + const refreshAuthorizeStoragePath = ( + initialConfig?: ReturnType, + ): void => { // Auth writes should honor the latest per-project setting, but a Windows // config-file lock can make loadPluginConfig() fall back to 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 = runtimePluginConfigSnapshot ?? DEFAULT_CONFIG; - try { - const refreshedPluginConfig = loadPluginConfig(); - if ( - refreshedPluginConfig === DEFAULT_CONFIG && - runtimePluginConfigSnapshot - ) { + let storagePluginConfig = + initialConfig ?? runtimePluginConfigSnapshot ?? DEFAULT_CONFIG; + if (!initialConfig) { + try { + const refreshedPluginConfig = loadPluginConfig(); + if ( + refreshedPluginConfig === DEFAULT_CONFIG && + 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 config loader returned defaults.", + `Falling back to cached authorize storage config after refresh failure: ${(error as Error).message}`, ); - } 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); @@ -3058,8 +3063,12 @@ while (attempted.size < Math.max(1, accountCount)) { label: AUTH_LABELS.OAUTH, type: "oauth" as const, authorize: async (inputs?: Record) => { - syncRuntimePluginConfig(resolveRuntimePluginConfig()); - refreshAuthorizeStoragePath(); + const hadRuntimePluginConfig = runtimePluginConfigSnapshot !== undefined; + const authorizePluginConfig = resolveRuntimePluginConfig(); + syncRuntimePluginConfig(authorizePluginConfig); + refreshAuthorizeStoragePath( + hadRuntimePluginConfig ? undefined : authorizePluginConfig, + ); const accounts: TokenSuccessWithAccount[] = []; const noBrowser = @@ -4031,8 +4040,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 - syncRuntimePluginConfig(resolveRuntimePluginConfig()); - refreshAuthorizeStoragePath(); + 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/test/index.test.ts b/test/index.test.ts index 1cc3f62a..929b2fcc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2999,6 +2999,30 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From 781add59cdf2c23574f7330db509d6a0e3ffcc90 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 17:08:55 +0800 Subject: [PATCH 32/39] Finish footer review cleanup --- index.ts | 27 +++++++++++++++------------ test/index.test.ts | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/index.ts b/index.ts index 4d291dcc..b414df7d 100644 --- a/index.ts +++ b/index.ts @@ -1462,7 +1462,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // routing auth writes to the wrong storage path. let storagePluginConfig = initialConfig ?? runtimePluginConfigSnapshot ?? DEFAULT_CONFIG; - if (!initialConfig) { + const shouldRefreshStorageConfig = + !initialConfig || + (initialConfig === DEFAULT_CONFIG && + runtimePluginConfigSnapshot === initialConfig); + if (shouldRefreshStorageConfig) { try { const refreshedPluginConfig = loadPluginConfig(); if ( @@ -1954,17 +1958,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountManagerPromise = Promise.resolve(reloadedManager); } - const refreshedVisibleIndicator = 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, - ) - : false; - if (!runtimePersistAccountFooter || !refreshedVisibleIndicator) { + 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"); } } diff --git a/test/index.test.ts b/test/index.test.ts index 929b2fcc..1a41fbc5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2015,6 +2015,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { let originalThreadId: string | undefined; beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); const configModule = await import("../lib/config.js"); vi.mocked(configModule.getPersistAccountFooter).mockReturnValue(false); @@ -2805,7 +2806,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toBe("user2@example.com [2/2]"); }); - it("falls back to the switch toast before the first footer session exists", async () => { + 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" }, @@ -2819,7 +2820,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { event: { type: "account.select", properties: { index: 1 } }, }); - expect(mockClient.tui.showToast).toHaveBeenCalledWith({ + expect(mockClient.tui.showToast).not.toHaveBeenCalledWith({ body: { message: "Switched to account 2", variant: "info", @@ -3023,6 +3024,34 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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("shows the account-switch info toast when the footer is disabled", async () => { await disablePersistedFooter(); mockStorage.accounts = [ From ac9a951b2d44f596e44b2140fbc10542f4d6ca26 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 17:33:45 +0800 Subject: [PATCH 33/39] Align footer hook session key priority --- index.ts | 14 ++++++++---- test/index.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index b414df7d..5c6232f9 100644 --- a/index.ts +++ b/index.ts @@ -1194,6 +1194,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return undefined; }; + const resolvePersistedIndicatorSessionID = ( + ...candidates: Array + ): string | undefined => { + const runtimeThreadId = process.env.CODEX_THREAD_ID?.toString().trim(); + return runtimeThreadId || resolvePersistedAccountSessionID(...candidates); + }; + const trimPersistedAccountIndicators = (): void => { if (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { const oldestKey = persistedAccountIndicators.keys().next().value; @@ -1993,7 +2000,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return Promise.resolve(); } const indicator = getPersistedAccountIndicatorLabel( - resolvePersistedAccountSessionID(input.sessionID), + resolvePersistedIndicatorSessionID(input.sessionID), ); if (indicator) { const message = @@ -2035,7 +2042,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (!lastUserMessage) return Promise.resolve(); - const sessionID = resolvePersistedAccountSessionID( + const sessionID = resolvePersistedIndicatorSessionID( typeof lastUserMessage.info.sessionID === "string" ? lastUserMessage.info.sessionID : undefined, @@ -2372,9 +2379,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // 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. - const runtimeThreadId = process.env.CODEX_THREAD_ID?.toString().trim(); const threadIdCandidate = - runtimeThreadId || resolvePersistedAccountSessionID(promptCacheKey); + resolvePersistedIndicatorSessionID(promptCacheKey); const indicatorRevision = persistAccountFooter ? nextPersistedAccountIndicatorRevision() : 0; diff --git a/test/index.test.ts b/test/index.test.ts index 1a41fbc5..8742a871 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2484,6 +2484,32 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toBe(expectedFullIndicator); }); + 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"; @@ -2512,6 +2538,34 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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 set the chat.message indicator when role is missing", async () => { await enablePersistedFooter("full-email"); const { plugin, sdk } = await setupPlugin(); From 5adf448f44292580469139dfd419c130ff380a17 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 18:01:36 +0800 Subject: [PATCH 34/39] Tighten persisted footer runtime helpers --- index.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/index.ts b/index.ts index 5c6232f9..8eff1a23 100644 --- a/index.ts +++ b/index.ts @@ -1182,10 +1182,19 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { persistedAccountCountHint = Math.max(0, Math.trunc(count)); }; - const resolvePersistedAccountSessionID = ( + const resetPersistedAccountFooterState = (): void => { + persistedAccountIndicators.clear(); + persistedAccountCountHint = 0; + }; + + const resolvePersistedIndicatorSessionID = ( ...candidates: Array ): string | undefined => { - for (const candidate of [...candidates, process.env.CODEX_THREAD_ID]) { + 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; @@ -1194,13 +1203,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return undefined; }; - const resolvePersistedIndicatorSessionID = ( - ...candidates: Array - ): string | undefined => { - const runtimeThreadId = process.env.CODEX_THREAD_ID?.toString().trim(); - return runtimeThreadId || resolvePersistedAccountSessionID(...candidates); - }; - const trimPersistedAccountIndicators = (): void => { if (persistedAccountIndicators.size > MAX_PERSISTED_ACCOUNT_INDICATORS) { const oldestKey = persistedAccountIndicators.keys().next().value; @@ -1242,6 +1244,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { accountCount: number, style: PersistAccountFooterStyle, ): boolean => { + // Bulk refreshes are intentionally update-only: they only rewrite + // already-tracked sessions, so new entries still have to flow through + // setPersistedAccountIndicator() where the LRU cap is enforced. const sessionIDs = Array.from(persistedAccountIndicators.keys()); if (sessionIDs.length === 0) return false; const revision = nextPersistedAccountIndicatorRevision(); @@ -1438,9 +1443,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const persistAccountFooter = getPersistAccountFooter(pluginConfig); const persistAccountFooterStyle = getPersistAccountFooterStyle(pluginConfig); + // 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) { - persistedAccountIndicators.clear(); - persistedAccountCountHint = 0; + resetPersistedAccountFooterState(); } runtimePluginConfigSnapshot = pluginConfig; runtimePersistAccountFooter = persistAccountFooter; From 99c76e881d5cc5dc6c63ad93f2230b91656df899 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 18:22:49 +0800 Subject: [PATCH 35/39] test: cover persisted footer edge cases --- index.ts | 3 +++ test/index.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/index.ts b/index.ts index 8eff1a23..b9e52f8a 100644 --- a/index.ts +++ b/index.ts @@ -1204,6 +1204,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; 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; diff --git a/test/index.test.ts b/test/index.test.ts index 8742a871..96216d9f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2484,6 +2484,46 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).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"; @@ -3462,6 +3502,27 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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"); From cc0da767d40ed4cd53188443e3e35f7cdcb10daf Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 18:35:21 +0800 Subject: [PATCH 36/39] fix: harden authorize config fallback detection --- index.ts | 15 +++++++++------ lib/config.ts | 38 ++++++++++++++++++++++++++++++++++++-- test/index.test.ts | 18 +++++++++++++++++- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/index.ts b/index.ts index b9e52f8a..644d199b 100644 --- a/index.ts +++ b/index.ts @@ -68,6 +68,7 @@ import { getCodexTuiGlyphMode, getBeginnerSafeMode, DEFAULT_CONFIG, + isFallbackPluginConfig, loadPluginConfig, } from "./lib/config.js"; import { @@ -1248,8 +1249,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { style: PersistAccountFooterStyle, ): boolean => { // Bulk refreshes are intentionally update-only: they only rewrite - // already-tracked sessions, so new entries still have to flow through - // setPersistedAccountIndicator() where the LRU cap is enforced. + // 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(); @@ -1474,20 +1476,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { initialConfig?: ReturnType, ): void => { // Auth writes should honor the latest per-project setting, but a Windows - // config-file lock can make loadPluginConfig() fall back to DEFAULT_CONFIG. + // 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 || - (initialConfig === DEFAULT_CONFIG && - runtimePluginConfigSnapshot === initialConfig); + (isFallbackPluginConfig(initialConfig) && + runtimePluginConfigSnapshot !== undefined); if (shouldRefreshStorageConfig) { try { const refreshedPluginConfig = loadPluginConfig(); if ( - refreshedPluginConfig === DEFAULT_CONFIG && + isFallbackPluginConfig(refreshedPluginConfig) && runtimePluginConfigSnapshot ) { logWarn( diff --git a/lib/config.ts b/lib/config.ts index 7d34b186..3b97e456 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -20,7 +20,11 @@ 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; +}; /** * Default plugin configuration @@ -62,6 +66,36 @@ export const DEFAULT_CONFIG: PluginConfig = { streamStallTimeoutMs: 45_000, }; +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; +}; + +// Keep the exported default config marked so tests and callers can model the +// loader fallback path without depending on object identity. +markFallbackPluginConfig(DEFAULT_CONFIG); + +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 * Falls back to defaults if file doesn't exist or is invalid @@ -71,7 +105,7 @@ export 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"); @@ -107,7 +141,7 @@ export function loadPluginConfig(): PluginConfig { logWarn( `Failed to load config from ${CONFIG_PATH}: ${(error as Error).message}`, ); - return DEFAULT_CONFIG; + return createFallbackPluginConfig(); } } diff --git a/test/index.test.ts b/test/index.test.ts index 96216d9f..6b673e69 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -80,7 +80,15 @@ vi.mock("../lib/cli.js", () => ({ })); vi.mock("../lib/config.js", () => { - const DEFAULT_CONFIG = {}; + 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, @@ -114,6 +122,14 @@ vi.mock("../lib/config.js", () => { 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(() => ({})), }; }); From ba9e7a554edacdd1c63c3b58f32b63ecfb2dedcf Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 20:25:24 +0800 Subject: [PATCH 37/39] fix: tighten persisted footer contracts --- index.ts | 30 ++++++++----------------- lib/config.ts | 30 +++++++++++-------------- lib/persist-account-footer.ts | 21 ++++++++++++++++++ test/index.test.ts | 41 ++++++++++++++++++++++++++++++----- 4 files changed, 78 insertions(+), 44 deletions(-) diff --git a/index.ts b/index.ts index 644d199b..d665361e 100644 --- a/index.ts +++ b/index.ts @@ -128,7 +128,12 @@ import { type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; -import type { PersistAccountFooterStyle } from "./lib/persist-account-footer.js"; +import type { + PersistAccountFooterStyle, + PersistedAccountDetails, + PersistedAccountIndicatorEntry, + SessionModelRef, +} from "./lib/persist-account-footer.js"; import { createCodexHeaders, extractRequestUrl, @@ -218,25 +223,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let beginnerSafeModeEnabled = false; const MIN_BACKOFF_MS = 100; - type PersistedAccountDetails = { - accountId?: string; - accountIdSource?: AccountIdSource; - accountLabel?: string; - email?: string; - access?: string; - accessToken?: string; - }; - - type SessionModelRef = { - providerID: string; - modelID: string; - }; - - type PersistedAccountIndicatorEntry = { - label: string; - revision: number; - }; - type SelectionSnapshot = { timestamp: number; family: ModelFamily; @@ -2391,7 +2377,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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. + // 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 = resolvePersistedIndicatorSessionID(promptCacheKey); const indicatorRevision = persistAccountFooter diff --git a/lib/config.ts b/lib/config.ts index 3b97e456..a16d7598 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -26,11 +26,22 @@ 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 */ -export const DEFAULT_CONFIG: PluginConfig = { +export const DEFAULT_CONFIG: PluginConfig = markFallbackPluginConfig({ codexMode: true, requestTransformMode: "native", codexTuiV2: true, @@ -64,22 +75,7 @@ export const DEFAULT_CONFIG: PluginConfig = { pidOffsetEnabled: false, fetchTimeoutMs: 60_000, streamStallTimeoutMs: 45_000, -}; - -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; -}; - -// Keep the exported default config marked so tests and callers can model the -// loader fallback path without depending on object identity. -markFallbackPluginConfig(DEFAULT_CONFIG); +}); function createFallbackPluginConfig(): PluginConfig { // Spread drops the non-enumerable fallback marker, so exact-default config diff --git a/lib/persist-account-footer.ts b/lib/persist-account-footer.ts index 295e2054..a78df6f3 100644 --- a/lib/persist-account-footer.ts +++ b/lib/persist-account-footer.ts @@ -1,3 +1,5 @@ +import type { AccountIdSource } from "./types.js"; + export const PERSIST_ACCOUNT_FOOTER_STYLES = [ "label-masked-email", "full-email", @@ -6,3 +8,22 @@ export const PERSIST_ACCOUNT_FOOTER_STYLES = [ 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/test/index.test.ts b/test/index.test.ts index 6b673e69..62b9925e 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 = () => ({ @@ -421,7 +422,7 @@ type PluginType = { "chat.message": ( input: { sessionID: string; - model?: { providerID: string; modelID: string }; + model?: SessionModelRef; }, output: { message: unknown; parts: unknown[] }, ) => Promise; @@ -432,11 +433,7 @@ type PluginType = { info: { role: string; sessionID?: string; - model?: { - providerID?: string; - modelID?: string; - variant?: string; - }; + model?: Partial & { variant?: string }; variant?: string; thinking?: string; }; @@ -2622,6 +2619,38 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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(); From 65722bcb2eebb7f1abbb988cc21bb2a19abbce94 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 21:59:18 +0800 Subject: [PATCH 38/39] fix: reuse cached ui runtime config --- index.ts | 2 +- test/index.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index d665361e..53879cb1 100644 --- a/index.ts +++ b/index.ts @@ -1451,7 +1451,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); + return applyUiRuntimeFromConfig(resolveRuntimePluginConfig()); }; const resolveRuntimePluginConfig = (): ReturnType => { diff --git a/test/index.test.ts b/test/index.test.ts index 62b9925e..7a830038 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2998,6 +2998,19 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ); }); + 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" }, From 9dd973a27ed73e5af89bf79b50e78c6ae7ac2b57 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 00:20:40 +0800 Subject: [PATCH 39/39] fix: preserve footer runtime config on authorize refresh --- index.ts | 24 ++++++++++++++---------- test/index.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/index.ts b/index.ts index 53879cb1..1df6ceef 100644 --- a/index.ts +++ b/index.ts @@ -1431,22 +1431,27 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { persistAccountFooterStyle: PersistAccountFooterStyle; ui: UiRuntimeOptions; } => { - const persistAccountFooter = getPersistAccountFooter(pluginConfig); + const resolvedPluginConfig = + isFallbackPluginConfig(pluginConfig) && + runtimePluginConfigSnapshot !== undefined + ? runtimePluginConfigSnapshot + : pluginConfig; + const persistAccountFooter = getPersistAccountFooter(resolvedPluginConfig); const persistAccountFooterStyle = - getPersistAccountFooterStyle(pluginConfig); + 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 = pluginConfig; + runtimePluginConfigSnapshot = resolvedPluginConfig; runtimePersistAccountFooter = persistAccountFooter; runtimePersistAccountFooterStyle = persistAccountFooterStyle; return { persistAccountFooter, persistAccountFooterStyle, - ui: applyUiRuntimeFromConfig(pluginConfig), + ui: applyUiRuntimeFromConfig(resolvedPluginConfig), }; }; @@ -1460,7 +1465,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const refreshAuthorizeStoragePath = ( initialConfig?: ReturnType, - ): void => { + ): 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. @@ -1469,9 +1474,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let storagePluginConfig = initialConfig ?? runtimePluginConfigSnapshot ?? DEFAULT_CONFIG; const shouldRefreshStorageConfig = - !initialConfig || - (isFallbackPluginConfig(initialConfig) && - runtimePluginConfigSnapshot !== undefined); + !initialConfig || isFallbackPluginConfig(initialConfig); if (shouldRefreshStorageConfig) { try { const refreshedPluginConfig = loadPluginConfig(); @@ -1496,6 +1499,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } const perProjectAccounts = getPerProjectAccounts(storagePluginConfig); setStoragePath(perProjectAccounts ? process.cwd() : null); + return storagePluginConfig; }; const getStatusMarker = ( @@ -3075,10 +3079,10 @@ while (attempted.size < Math.max(1, accountCount)) { authorize: async (inputs?: Record) => { const hadRuntimePluginConfig = runtimePluginConfigSnapshot !== undefined; const authorizePluginConfig = resolveRuntimePluginConfig(); - syncRuntimePluginConfig(authorizePluginConfig); - refreshAuthorizeStoragePath( + const refreshedAuthorizePluginConfig = refreshAuthorizeStoragePath( hadRuntimePluginConfig ? undefined : authorizePluginConfig, ); + syncRuntimePluginConfig(refreshedAuthorizePluginConfig); const accounts: TokenSuccessWithAccount[] = []; const noBrowser = diff --git a/test/index.test.ts b/test/index.test.ts index 7a830038..d6c1786e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3018,7 +3018,6 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ]; const configModule = await import("../lib/config.js"); const healthyConfig = { source: "healthy-config" }; - const lockedConfig = { source: "locked-config" }; vi.mocked(configModule.loadPluginConfig).mockReturnValue(healthyConfig); vi.mocked(configModule.getPersistAccountFooter).mockImplementation( (config) => config === healthyConfig, @@ -3035,7 +3034,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { await sendPersistedAccountRequest(sdk, "session-authorize-refresh"); mockClient.tui.showToast.mockClear(); - vi.mocked(configModule.loadPluginConfig).mockReturnValue(lockedConfig); + vi.mocked(configModule.loadPluginConfig).mockReturnValue(configModule.DEFAULT_CONFIG); await autoMethod.authorize({ loginMode: "add", accountCount: "1" }); await manualMethod.authorize(); mockClient.tui.showToast.mockClear(); @@ -3204,6 +3203,45 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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 = [