diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 32d124b7fd..7b0eb72ecc 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -38,6 +38,7 @@ import { tryAcquireCodexQuotaScopeProbeLease, pickAlternateCodexAccount, resolveCodexAccountForThreadDetailed, + type CodexAffinityDecision, } from "./routing"; import { entitledCodexAccountIdsForModel, @@ -137,6 +138,8 @@ export type CodexAuthContext = probeLeaseId?: string; /** Native model quota group selected for this request, when known. */ quotaScope?: CodexQuotaScope; + /** What happened to this thread's binding on this request (#4546). */ + affinityDecision?: CodexAffinityDecision; /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */ probeQuotaScope?: CodexQuotaScope; } @@ -798,6 +801,9 @@ export async function resolveCodexAuthContext( const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential ? codexPoolAffinityKey(headers) : undefined; + // Why this request is on this account, carried to the request log so a move reads as an event + // instead of something inferred from account labels across lines (#4546). + let affinityDecision: CodexAffinityDecision | undefined; // Retained startup recovery makes the physical main identity ineligible. Routing // can still preserve service by selecting a healthy configured pool account. A // request-owned bearer likewise cannot inspect or reconcile file-main state. @@ -870,6 +876,7 @@ export async function resolveCodexAuthContext( ); if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const selected = resolution.status === "selected" ? resolution.accountId : null; + affinityDecision = "affinity" in resolution ? resolution.affinity : undefined; if (!selected) { // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy @@ -1066,6 +1073,7 @@ export async function resolveCodexAuthContext( ...(quotaScope ? { quotaScope } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), ...(probeQuotaScope ? { probeQuotaScope } : {}), + ...(affinityDecision ? { affinityDecision } : {}), }; } catch (cause) { if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 321d267aae..690e0636b4 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -59,9 +59,52 @@ type ThreadAffinityEntry = { }; export type CodexThreadResolution = - | { status: "selected"; accountId: string } - | { status: "none" } - | { status: "expired"; accountId: string }; + | { status: "selected"; accountId: string; affinity?: CodexAffinityDecision } + | { status: "none"; affinity?: CodexAffinityDecision } + | { status: "expired"; accountId: string; affinity?: CodexAffinityDecision }; + +/** What happened to this thread's binding on this request (#4546). */ +export type CodexAffinityMove = + /** Served by its own bound account, which was healthy. */ + | "reused" + /** Served by its own bound account while something transient was wrong with it. */ + | "held" + /** Served by another account while the binding stayed put. */ + | "detour" + /** The binding was released and a different account took the thread. */ + | "rebound" + /** There was no live binding; this request established one. */ + | "new_bind" + /** The binding was released without a replacement on this request. */ + | "cleared"; + +/** + * Why. A move is the expensive event -- it discards the prompt-cache prefix warmed on the old + * account -- so the operator should not have to infer it from account labels across log lines, + * which is how #4546 had to be diagnosed. + */ +export type CodexAffinityReason = + | "healthy" + | "quota_headroom" + | "quota_refusal" + | "transient" + | "transient_hold_expired" + | "unusable" + | "generation" + | "expired" + | "model_lane"; + +export interface CodexAffinityDecision { + move: CodexAffinityMove; + reason: CodexAffinityReason; +} + +/** The decision to report once a binding has been released and selection starts over. */ +function affinityAfterRelease(releaseReason: CodexAffinityReason | undefined): CodexAffinityDecision { + return releaseReason === undefined + ? { move: "new_bind", reason: "healthy" } + : { move: "rebound", reason: releaseReason }; +} /** * Process-local cursor for automatic RR/fill-first (and quota-429 when not @@ -360,17 +403,46 @@ export function clearThreadAccountMap(): void { threadAffinityEntryTotal = 0; } -export function clearThreadAccountMapForAccount(accountId: string): void { +export function clearThreadAccountMapForAccount( + accountId: string, + reason: CodexAffinityReason = "unusable", +): void { for (const [threadId, affinities] of threadAccountMap) { for (const [scope, entry] of affinities) { if (entry.accountId === accountId && affinities.delete(scope)) { threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1); + notePendingReleaseReason(threadId, reason); } } if (affinities.size === 0) threadAccountMap.delete(threadId); } } +/** + * Why a binding was released, held until that thread's next resolve can report it (#4546). + * + * A release and the request that pays for it are two different moments: a 429 clears the pin + * inside the outcome recorder, and the next request arrives with nothing left to explain why it + * is starting cold. Bounded, because it is a diagnostic and must not become a leak. + */ +const pendingReleaseReasons = new Map(); +const MAX_PENDING_RELEASE_REASONS = 4096; + +function notePendingReleaseReason(threadId: string, reason: CodexAffinityReason): void { + if (!pendingReleaseReasons.has(threadId) && pendingReleaseReasons.size >= MAX_PENDING_RELEASE_REASONS) { + const oldest = pendingReleaseReasons.keys().next(); + if (!oldest.done) pendingReleaseReasons.delete(oldest.value); + } + pendingReleaseReasons.set(threadId, reason); +} + +function consumePendingReleaseReason(threadId: string | null): CodexAffinityReason | undefined { + if (threadId === null) return undefined; + const reason = pendingReleaseReasons.get(threadId); + if (reason !== undefined) pendingReleaseReasons.delete(threadId); + return reason; +} + export function clearCodexUpstreamHealth(): void { // Operator preferences are routing state, not health, but they live and die with the same // reset points. Leaving them behind lets a selection from one context suppress the @@ -2701,9 +2773,9 @@ export function resolveCodexAccountForThreadDetailed( ); if (cooler) { bindModelDetourAffinity(threadId, cooler, now, modelId, quotaScope); - return { status: "selected", accountId: cooler }; + return { status: "selected", accountId: cooler, affinity: { move: "rebound", reason: "model_lane" } }; } - return { status: "selected", accountId: detourEntry.accountId }; + return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "reused", reason: "model_lane" } }; } // The model lane gets the same transient hold as the ordinary one. Without it a // model-scoped request drops its detour pin on three 503s and falls back to an ordinary @@ -2717,13 +2789,13 @@ export function resolveCodexAccountForThreadDetailed( detourEntry.lastUsedAt = now; if (lane !== null && lane !== detourEntry.accountId) { detourEntry.transientDetourAccountId = lane; - return { status: "selected", accountId: lane }; + return { status: "selected", accountId: lane, affinity: { move: "detour", reason: "transient" } }; } // A provider-wide outage soft-avoids every sibling, so there is nowhere to detour. // That is a statement about where this request can go, not about who owns the // conversation: dropping the pin here would rebuild the cold prefix elsewhere for // exactly the failure mode the hold exists to survive. - return { status: "selected", accountId: detourEntry.accountId }; + return { status: "selected", accountId: detourEntry.accountId, affinity: { move: "held", reason: "transient" } }; } // Detour expiry or invalidation must not expire the ordinary task. Drop only // this model lane and select from ordinary/shared state below. @@ -2731,11 +2803,14 @@ export function resolveCodexAccountForThreadDetailed( } } + // Why the binding went away, when it did. Carried to the selection below so the request that + // pays for a cold prefix can say what it paid for. + let releaseReason: CodexAffinityReason | undefined; const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined; if (threadId && entry) { if (isThreadAffinityExpired(entry, now)) { deleteThreadAffinity(threadId, quotaScope); - return { status: "expired", accountId: entry.accountId }; + return { status: "expired", accountId: entry.accountId, affinity: { move: "cleared", reason: "expired" } }; } const generationLive = isThreadAffinityGenerationLive(entry); const selectableForSharedState = generationLive @@ -2779,9 +2854,9 @@ export function resolveCodexAccountForThreadDetailed( promoteActiveCodexAccount(config, cooler); } bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks - return { status: "selected", accountId: cooler }; + return { status: "selected", accountId: cooler, affinity: { move: "rebound", reason: "quota_headroom" } }; } - return { status: "selected", accountId: entry.accountId }; + return { status: "selected", accountId: entry.accountId, affinity: { move: "reused", reason: "healthy" } }; } // Transient trouble on the bound account is a reason to send elsewhere, not a reason to // give up the conversation. Detour this request and KEEP the binding, so recovery is free @@ -2798,21 +2873,33 @@ export function resolveCodexAccountForThreadDetailed( entry.transientDetourAccountId = detour; // Deliberately no promoteActiveCodexAccount and no rebind: this is one request routing // around a blip, not the pool deciding where the conversation now lives. - return { status: "selected", accountId: detour }; + return { status: "selected", accountId: detour, affinity: { move: "detour", reason: "transient" } }; } // No sibling can take it either -- the usual shape of a provider-wide 503. The binding // survives: "cannot send right now" and "forget which account owns this conversation" // are different answers, and conflating them is what the hold was added to stop. - return { status: "selected", accountId: entry.accountId }; + return { status: "selected", accountId: entry.accountId, affinity: { move: "held", reason: "transient" } }; } // A model-only exclusion does not invalidate the shared task binding. Health, // generation, pause, cooldown, and failure evidence still retire it normally. if (!modelScopedSelection || !healthyForSharedAffinity) { + releaseReason = !generationLive + ? "generation" + : quotaRefused + ? "quota_refusal" + : isTransientHoldExpired(entry, now) + ? "transient_hold_expired" + : !isCodexAccountUsable(config, entry.accountId, selectionOptions) + ? "unusable" + : "quota_headroom"; deleteThreadAffinity(threadId, quotaScope); } else { preserveExistingModelScopedAffinity = true; } } + // A release recorded by the outcome path (a 429 clears the pin before the next request even + // arrives) is the reason this request is starting cold, so it outranks having found nothing. + releaseReason ??= consumePendingReleaseReason(threadId); // A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return // before the quota/failover helpers below, so prefer only shared-healthy roster members here; @@ -2853,7 +2940,7 @@ export function resolveCodexAccountForThreadDetailed( // the thing the preference exists to protect. promoteActiveCodexAccount(config, strategyPick); } - return { status: "selected", accountId: strategyPick }; + return { status: "selected", accountId: strategyPick, affinity: affinityAfterRelease(releaseReason) }; } let active = getEffectiveActiveCodexAccountId(config); @@ -2864,7 +2951,7 @@ export function resolveCodexAccountForThreadDetailed( selectionOptions?.nativeMainSelectionOnly === true && selectionOptions.modelEligibleAccountIds !== undefined ) { - return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }; + return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, affinity: affinityAfterRelease(releaseReason) }; } return { status: "none" }; } @@ -2902,13 +2989,13 @@ export function resolveCodexAccountForThreadDetailed( // return main only as a non-mutating sentinel so the caller's atomic claim can // classify maintenance. Do not fall through to the configured-but-ineligible // active account or persist/bind this synthetic selection. - return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }; + return { status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID, affinity: affinityAfterRelease(releaseReason) }; } else if ( hasConfiguredPoolAccount(config, active, selectionOptions) && !isCodexAccountPaused(config, active) && !isCodexAccountPlanExcluded(config, active) ) { - return { status: "selected", accountId: active }; + return { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) }; } else { return { status: "none" }; } @@ -2950,13 +3037,13 @@ export function resolveCodexAccountForThreadDetailed( ); if (!isCodexAccountUsable(config, active, selectionOptions)) { return hasConfiguredPoolAccount(config, active, selectionOptions) - ? { status: "selected", accountId: active } + ? { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) } : { status: "none" }; } if (isCodexAccountPaused(config, active)) return { status: "none" }; if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active, selectionOptions) - ? { status: "selected", accountId: active } + ? { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) } : { status: "none" }; } if (threadId) { @@ -2966,7 +3053,7 @@ export function resolveCodexAccountForThreadDetailed( bindThreadAffinity(threadId, active, now, quotaScope); } } - return { status: "selected", accountId: active }; + return { status: "selected", accountId: active, affinity: affinityAfterRelease(releaseReason) }; } export function recordCodexUpstreamOutcome( @@ -3153,7 +3240,7 @@ export function recordCodexUpstreamOutcome( // The reauth flag carries the same provenance, so a replacement landing after this call cannot // inherit a quarantine that was never about it. markAccountNeedsReauth(accountId, writerGeneration, meta.credentialGeneration); - clearThreadAccountMapForAccount(accountId); + clearThreadAccountMapForAccount(accountId, "quota_refusal"); return; } @@ -3188,7 +3275,7 @@ export function recordCodexUpstreamOutcome( // threads must leave it and new requests should prefer an eligible account. // Reserve remains isolated so a same-account Terra/Luna combo fallback can run. if (quotaScope === "shared" && !meta.fixedAccount) { - clearThreadAccountMapForAccount(accountId); + clearThreadAccountMapForAccount(accountId, "quota_refusal"); notePoolRotationFailure(POOL_KEY_CODEX, accountId); if (getEffectiveActiveCodexAccountId(config) === accountId) { // Same-request 429 retry already picked via excludeAccountId — reuse it so @@ -3234,7 +3321,7 @@ export function recordCodexUpstreamOutcome( }), }); if (!meta.fixedAccount) { - clearThreadAccountMapForAccount(accountId); + clearThreadAccountMapForAccount(accountId, "quota_refusal"); // An independent native quota request may discover an account-wide throttle, // but it still must not advance the shared RR ring or active cursor. The next // shared request observes the cooldown and chooses its own fallback. diff --git a/src/server/request-log.ts b/src/server/request-log.ts index c77db6cbc0..f09cb060b7 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -12,6 +12,7 @@ import { upstreamErrorMessageFromPayload, } from "../lib/errors"; import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths"; +import type { CodexAffinityMove, CodexAffinityReason } from "../codex/routing"; import { readCodexCatalogPath } from "../codex/catalog"; import type { AttemptTierOutcome, OcxProviderConfig, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; @@ -139,7 +140,9 @@ export interface RequestLogContext { errorCode?: string; /** Structured reason from `response.incomplete`; internal-only input to log classification. */ terminalIncompleteReason?: string; - affinity?: "reused" | "new_bind" | "rebound" | "cleared"; + affinity?: CodexAffinityMove; + /** Why the binding was kept, moved, or released (#4546). */ + affinityReason?: CodexAffinityReason; transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ @@ -204,7 +207,9 @@ export interface RequestLogEntry { totalTokens?: number; attempts?: PersistedUsageAttempt[]; /** Codex pool affinity decision for this request (diagnostics for #186). */ - affinity?: "reused" | "new_bind" | "rebound" | "cleared"; + affinity?: CodexAffinityMove; + /** Why that decision was made (#4546): a move is the expensive event, so it names its cause. */ + affinityReason?: CodexAffinityReason; /** Where the upstream terminal/failure was observed. */ transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse"; /** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */ @@ -1080,6 +1085,7 @@ export function addFinalRequestLog( ...(totalTokens !== undefined ? { totalTokens } : {}), ...(attempts !== undefined ? { attempts } : {}), ...(logCtx.affinity ? { affinity: logCtx.affinity } : {}), + ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}), ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}), ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}), ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8aaf92227c..ec1b583ed3 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -4214,6 +4214,13 @@ async function handleResponsesInner( ? `${route.providerName}-${route.codexAccountNamespace}` : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); + // A move is the expensive event: it discards the prefix warmed on the previous account. Record + // it as an event with its cause, so the operator reads it off one line instead of inferring it + // from account labels across many (#4546). + if (authCtx.kind === "pool" && authCtx.affinityDecision) { + logCtx.affinity = authCtx.affinityDecision.move; + logCtx.affinityReason = authCtx.affinityDecision.reason; + } // Seed an account-derived scope before final adapter binding. Cursor never treats it as // authoritative: bindRouteReasoningReplayScope replaces it with the exact route owner or a // per-request fail-closed sentinel after the final provider and credential are known. diff --git a/tests/codex-integration/codex-pool-rotation.test.ts b/tests/codex-integration/codex-pool-rotation.test.ts index 335e5e183e..54cb28f739 100644 --- a/tests/codex-integration/codex-pool-rotation.test.ts +++ b/tests/codex-integration/codex-pool-rotation.test.ts @@ -32,6 +32,7 @@ import { reconcileCodexRoutingHealth, resetCodexRoutingForManualSelection, resolveCodexAccountForThread, + resolveCodexAccountForThreadDetailed, } from "../../src/codex/routing"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; @@ -1499,6 +1500,45 @@ describe("selection order across rotation strategies", () => { expect(served).not.toBe("a"); }); + test("every binding decision records what happened and why (#4546)", () => { + const config = makeThreeAccountConfig({ + accountPoolStrategy: "quota", + autoSwitchThreshold: 80, + activeCodexAccountId: "a", + upstreamFailoverThreshold: 3, + }); + const threadId = "affinity-reason-thread"; + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + const start = Date.now(); + + // A thread with no binding yet is a placement, not a move. + expect(resolveCodexAccountForThreadDetailed(threadId, config, start)).toMatchObject({ + accountId: "a", + affinity: { move: "new_bind", reason: "healthy" }, + }); + // Served by its own healthy account. + expect(resolveCodexAccountForThreadDetailed(threadId, config, start)).toMatchObject({ + affinity: { move: "reused", reason: "healthy" }, + }); + + // A transient streak sends this request elsewhere while the binding stays put. + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + recordCodexUpstreamOutcome(config, "a", 503, { now: start }); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start)).toMatchObject({ + accountId: "b", + affinity: { move: "detour", reason: "transient" }, + }); + + // A quota refusal is the account telling this thread it cannot serve, so the binding goes + // and the record names which cause fired instead of leaving it to be inferred. + recordCodexUpstreamOutcome(config, "a", 429, { now: start }); + expect(resolveCodexAccountForThreadDetailed(threadId, config, start).affinity) + .toMatchObject({ move: "rebound", reason: "quota_refusal" }); + }); + test("a transient block with nowhere to detour keeps the binding", () => { const config = makeThreeAccountConfig({ accountPoolStrategy: "quota", diff --git a/tests/codex-integration/codex-routing.test.ts b/tests/codex-integration/codex-routing.test.ts index 44160ec8b0..b769a74c70 100644 --- a/tests/codex-integration/codex-routing.test.ts +++ b/tests/codex-integration/codex-routing.test.ts @@ -1690,13 +1690,13 @@ describe("codex routing", () => { updateAccountQuota("b", 10); const now = 1_800_000_000_000; expect(resolveCodexAccountForThreadDetailed("expired-detailed", config, now)) - .toEqual({ status: "selected", accountId: "a" }); + .toMatchObject({ status: "selected", accountId: "a" }); expect(resolveCodexAccountForThreadDetailed( "expired-detailed", config, now + CODEX_THREAD_AFFINITY_IDLE_TTL_MS + 1, - )).toEqual({ status: "expired", accountId: "a" }); + )).toMatchObject({ status: "expired", accountId: "a" }); }); test("thread affinity LRU cap evicts the oldest mapping", () => { @@ -2469,7 +2469,7 @@ describe("codex account selection order", () => { now + 1, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); @@ -2516,7 +2516,7 @@ describe("codex account selection order", () => { eligible, modelId, ); - expect(first).toEqual({ status: "selected", accountId: firstPreview }); + expect(first).toMatchObject({ status: "selected", accountId: firstPreview }); expect(["a", "c"]).toContain(firstPreview); expect(previewCodexAccountForRequest( @@ -2534,7 +2534,9 @@ describe("codex account selection order", () => { "shared", eligible, modelId, - )).toEqual(first); + // The resolution now also carries the affinity decision, which legitimately differs + // between a first placement and a later reuse. This case is about the account. + )).toMatchObject({ status: "selected", accountId: firstPreview }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); @@ -2549,7 +2551,7 @@ describe("codex account selection order", () => { "shared", { modelEligibleAccountIds: new Set([other]) }, modelId, - )).toEqual({ status: "selected", accountId: other }); + )).toMatchObject({ status: "selected", accountId: other }); expect(resolveCodexAccountForThreadDetailed( threadId, config, @@ -2557,7 +2559,7 @@ describe("codex account selection order", () => { "shared", { modelEligibleAccountIds: new Set(["a", "b", "c"]) }, modelId, - )).toEqual({ status: "selected", accountId: other }); + )).toMatchObject({ status: "selected", accountId: other }); expect(resolveCodexAccountForThread(threadId, config, now + 6, "shared")).toBe("b"); }); @@ -2588,7 +2590,7 @@ describe("codex account selection order", () => { "shared", { modelEligibleAccountIds: new Set(["a"]) }, modelId, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); // B is the highest tier after the detour exists. Filtering only after tier // selection would drop B without ever exposing healthy C to the picker. config.codexAccountPriorities = { b: 2, c: 1 }; @@ -2618,7 +2620,7 @@ describe("codex account selection order", () => { "shared", eligible, modelId, - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(config.activeCodexAccountId).toBe("c"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); @@ -2659,7 +2661,7 @@ describe("codex account selection order", () => { config, resolveAt, "shared", - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(config.activeCodexAccountId).toBe("c"); expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); }); @@ -2710,7 +2712,7 @@ describe("codex account selection order", () => { "shared", eligible, modelId, - )).toEqual(first); + )).toMatchObject({ status: "selected", accountId: first.accountId }); } expect(config.activeCodexAccountPinned).toBe("b"); }); @@ -2761,7 +2763,7 @@ describe("codex account selection order", () => { "shared", eligible, "gpt-daybreak-blue-latest", - )).toEqual(firstModel); + )).toMatchObject({ status: "selected", accountId: firstModel.accountId }); expect(resolveCodexAccountForThreadDetailed( threadId, config, @@ -2769,7 +2771,7 @@ describe("codex account selection order", () => { "shared", eligible, "gpt-other-account-gated", - )).toEqual(secondModel); + )).toMatchObject({ status: "selected", accountId: secondModel.accountId }); } expect(resolveCodexAccountForThread(threadId, config, now + 5, "shared")).toBe("b"); }); @@ -2801,7 +2803,7 @@ describe("codex account selection order", () => { "shared", eligible, "gated-model-0", - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); for (let index = 1; index <= CODEX_THREAD_AFFINITY_MAX_ENTRIES; index += 1) { expect(resolveCodexAccountForThreadDetailed( threadId, @@ -2829,7 +2831,7 @@ describe("codex account selection order", () => { "shared", eligible, "gated-model-0", - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(resolveCodexAccountForThreadDetailed( threadId, config, @@ -2837,7 +2839,7 @@ describe("codex account selection order", () => { "shared", eligible, "gated-model-0", - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); }, STORE_BUDGET_MS); test("a gated first request binds its actual account without replacing global active", () => { @@ -2852,7 +2854,7 @@ describe("codex account selection order", () => { now, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); expect(resolveCodexAccountForThread("gated-first-task", config, now + 1, "shared")).toBe("a"); @@ -2872,14 +2874,14 @@ describe("codex account selection order", () => { Date.now(), "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(resolveCodexAccountForThreadDetailed( null, config, Date.now() + 1, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "b" }); + )).toMatchObject({ status: "selected", accountId: "b" }); expect(config.activeCodexAccountId).toBe("b"); expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); }); @@ -2906,7 +2908,7 @@ describe("codex account selection order", () => { now + 1, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); @@ -2956,7 +2958,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 4, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "b" }); + )).toMatchObject({ status: "selected", accountId: "b" }); expect(config.activeCodexAccountId).toBe("c"); expect(config.activeCodexAccountPinned).toBe("c"); expect(getEffectiveActiveCodexAccountId(config)).toBe("c"); @@ -2986,7 +2988,7 @@ describe("codex account selection order", () => { now + 1, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3024,7 +3026,7 @@ describe("codex account selection order", () => { resolveAt, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3064,7 +3066,7 @@ describe("codex account selection order", () => { now, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3107,7 +3109,7 @@ describe("codex account selection order", () => { resolveAt, "shared", selectionOptions, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBeUndefined(); expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); @@ -3130,7 +3132,7 @@ describe("codex account selection order", () => { nativeMainSelectionOnly: true, modelEligibleAccountIds: new Set(), }, - )).toEqual({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); + )).toMatchObject({ status: "selected", accountId: MAIN_CODEX_ACCOUNT_ID }); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3160,7 +3162,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, "shared", { modelEligibleAccountIds: new Set(["a", "c"]) }, - )).toEqual({ status: "selected", accountId: "c" }); + )).toMatchObject({ status: "selected", accountId: "c" }); expect(config.activeCodexAccountId).toBe("b"); expect(config.activeCodexAccountPinned).toBe("b"); expect(getEffectiveActiveCodexAccountId(config)).toBe("b"); @@ -3180,7 +3182,7 @@ describe("codex account selection order", () => { Date.now(), "shared", { modelEligibleAccountIds: new Set(["a", "b"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3205,7 +3207,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, "shared", { modelEligibleAccountIds: new Set(["a", "b"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3224,7 +3226,7 @@ describe("codex account selection order", () => { Date.now(), "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); @@ -3249,7 +3251,7 @@ describe("codex account selection order", () => { now + CODEX_TRANSIENT_SOFT_AVOID_MS + 3, "shared", { modelEligibleAccountIds: new Set(["a"]) }, - )).toEqual({ status: "selected", accountId: "a" }); + )).toMatchObject({ status: "selected", accountId: "a" }); expect(config.activeCodexAccountId).toBe("a"); expect(config.activeCodexAccountPinned).toBeUndefined(); }); diff --git a/tests/responses/responses-pool-401-refresh.test.ts b/tests/responses/responses-pool-401-refresh.test.ts index 609b24d772..88dbfc0afc 100644 --- a/tests/responses/responses-pool-401-refresh.test.ts +++ b/tests/responses/responses-pool-401-refresh.test.ts @@ -605,7 +605,7 @@ describe("ordinary pool 401 refresh and replay (#2887)", () => { // reported as expired, which is the behavior the missing handoff produces. // The binding lives under the model's quota scope, so resolution must be asked in that // same scope; a scopeless read looks in the legacy bucket and finds nothing. - expect(resolveCodexAccountForThreadDetailed(THREAD_ID, cfg, Date.now(), "shared")).toEqual({ + expect(resolveCodexAccountForThreadDetailed(THREAD_ID, cfg, Date.now(), "shared")).toMatchObject({ status: "selected", accountId: ACCOUNT_ID, });