diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 305a19946ef..50e1af072e8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -367,6 +367,7 @@ "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", + "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities.test.ts": "cli", @@ -513,6 +514,7 @@ "codex-models-cache-invalidate.test.ts": "codex-integration", "codex-native-residue.test.ts": "codex-integration", "codex-plan.test.ts": "codex-integration", + "codex-pin-drain-projection.test.ts": "codex-integration", "codex-plugins-doctor.test.ts": "codex-integration", "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration", diff --git a/src/cli/account.ts b/src/cli/account.ts index f834024f28c..f3f80fea3c0 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -323,13 +323,36 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise { if (res.status === 0) return proxyUnreachable(res.transportError); if (res.status !== 200) return apiError(res.json, `failed to switch ${name}`, res.status); - if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, type: c.type, activeId }, null, 2)); - else console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`); + // The route reports this only when routing would drop the pin it just recorded, so an + // absent field means the pin survives (#4521). + const pinDrainReason = typeof res.json.pinDrainReason === "string" ? res.json.pinDrainReason : undefined; + if (wantsJson) { + console.log(JSON.stringify({ + ok: true, + provider: name, + type: c.type, + activeId, + ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), + }, null, 2)); + } else { + console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`); + } if (c.type === "codex") { console.error("Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured."); const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active"); - if (active.status === 200 && typeof active.json.autoSwitchThreshold === "number" && active.json.autoSwitchThreshold > 0) { - console.error(`Note: auto-switch (threshold ${active.json.autoSwitchThreshold}%) may override this pin.`); + const threshold = active.status === 200 && typeof active.json.autoSwitchThreshold === "number" + ? active.json.autoSwitchThreshold + : undefined; + if (pinDrainReason !== undefined) { + // "may override" is the right caveat for a pin that is currently fine and could be + // overtaken later. It is the wrong sentence for one the next request will discard, and + // printing only that is what left the operator believing the account was pinned. + const because = pinDrainReason === "quota_threshold" + ? `is at or above the auto-switch threshold${threshold !== undefined ? ` (${threshold}%)` : ""}` + : `cannot currently be selected (${pinDrainReason})`; + console.error(`Note: ${displayId(activeId)} ${because}, so routing releases this pin on its next request.`); + } else if (threshold !== undefined && threshold > 0) { + console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`); } } return 0; diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index b7100528471..ed15ff56330 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -7,7 +7,7 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota"; import { deleteCodexAccount } from "../account-lifecycle"; import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause"; import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority"; -import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing"; +import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing"; import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; @@ -235,7 +235,22 @@ export async function handleCodexAuthAPI( else setCodexAccountPin(runtimeConfig, targetAccountId); resetCodexRoutingForManualSelection(targetAccountId); saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); + // A pin this route accepts can still be dropped by the very next resolve, and saying + // nothing about that is what made the setting look ignored (#4521). The checks above + // refuse an account that cannot be selected at all; this reports the one remaining + // outcome they do not cover, from the same predicate routing releases on, so the two + // cannot drift. Absent means the pin survives — additive for existing clients. + // `appliesImmediately` is unchanged: it answers whether thread affinity was cleared, + // not whether the pin is durable. + const pinDrainReason = body.accountId == null + ? undefined + : codexAccountPinDrainReason(runtimeConfig, targetAccountId); + return jsonResponse({ + ok: true, + activeCodexAccountId: body.accountId, + appliesImmediately: true, + ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}), + }); } if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { diff --git a/src/codex/routing.ts b/src/codex/routing.ts index e1d1ea9e931..0aebeaae229 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -4,7 +4,8 @@ import { codexAccountLogLabel } from "./account-label"; import { isCodexAccountPaused } from "./account-pause"; import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability"; -import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; +import { markAccountNeedsReauth } from "./account-runtime-state"; +import { codexAccountPinDrainReason } from "./routing/pin-drain"; import { POOL_KEY_CODEX, notePoolRotationFailure } from "./pool-rotation"; import { getAccountQuota, isRetiredCodexSparkModel } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; @@ -202,6 +203,8 @@ export { getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, } from "./routing/active-account"; +export { codexAccountPinDrainReason } from "./routing/pin-drain"; +export type { CodexPinDrainReason } from "./routing/pin-drain"; function hasConfiguredPoolAccount( config: OcxConfig, accountId: string, @@ -462,19 +465,7 @@ function releaseDrainedCodexAccountPin( ): void { const pinned = pinnedCodexAccountId(config); if (pinned === undefined) return; - const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned); - if (knownUnavailable) { - clearCodexAccountPin(config); - saveConfigPreservingClaudeCode(config); - return; - } - // Temporary drain deliberately forbids every native-main read. A pin on main - // cannot be classified by credential liveness or quota until the fenced profile - // is readable. Cached reauth and configured pause state were handled above. - if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return; - const drained = !isCodexAccountUsable(config, pinned, selectionOptions) - || !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now); - if (!drained) return; + if (codexAccountPinDrainReason(config, pinned, selectionOptions, now) === undefined) return; clearCodexAccountPin(config); saveConfigPreservingClaudeCode(config); } diff --git a/src/codex/routing/pin-drain.ts b/src/codex/routing/pin-drain.ts new file mode 100644 index 00000000000..961c79202f7 --- /dev/null +++ b/src/codex/routing/pin-drain.ts @@ -0,0 +1,57 @@ +import type { OcxConfig } from "../../types"; +import { isCodexAccountPaused } from "../account-pause"; +import { isAccountNeedsReauth } from "../account-runtime-state"; +import type { CodexAccountUsabilityOptions } from "../account-usability"; +import { isCodexAccountUsable } from "../account-usability"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { hasCodexQuotaHeadroom } from "./selection"; + +/** Why routing would drop a manual pin, or undefined when the pin survives. */ +export type CodexPinDrainReason = "needs_reauth" | "paused" | "unusable" | "quota_threshold"; + +/** + * Would routing release a pin on this account the next time it resolves? + * + * This lives beside selection rather than inside `routing.ts` for the same reason + * {@link ./cache-affinity} does: it is a policy question asked by two callers that must answer + * identically. One is `releaseDrainedCodexAccountPin`, which acts on it. The other is + * `PUT /api/codex-auth/active`, which reports it. + * + * The two-step contradiction in #4521 is what happens when only the first exists. That route + * validates existence, pause and pending validation, answers 200, and says nothing about quota; + * the very next resolve runs this rule and drops the pin. The operator sees a setting accepted + * and then ignored. Reporting it from the same predicate, rather than from a second copy on the + * accepting surface, is what keeps the answer and the action from drifting -- a client-side + * re-derivation of the usage score has to track {@link ./cooldown-math} exactly, including the + * Free/Go plan windows and short-window freshness. + * + * Ordering is load-bearing. Cached reauth and configured pause are classified FIRST, because + * they hold for the main account even while its fenced native profile is unreadable; a + * selection-only caller then makes every later classification answer "no drain", so reading + * reauth after that guard would make a pin on a signed-out main look durable. + * + * This answers only whether the pin survives. It is not an admission check: the caller that + * acts on it releases a preference, and every involuntary release -- quota refusal, failover + * streak, cooldown, lost generation, affinity expiry -- is decided elsewhere and earlier. + */ +export function codexAccountPinDrainReason( + config: OcxConfig, + accountId: string, + selectionOptions?: Pick< + CodexAccountUsabilityOptions, + "nativeMainSelectionOnly" | "isMainAccountTokenLive" + >, + now: number = Date.now(), +): CodexPinDrainReason | undefined { + if (isAccountNeedsReauth(accountId)) return "needs_reauth"; + if (isCodexAccountPaused(config, accountId)) return "paused"; + // Temporary drain deliberately forbids every native-main read. A pin on main cannot be + // classified by credential liveness or quota until the fenced profile is readable. Cached + // reauth and configured pause state were handled above. + if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) { + return undefined; + } + if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable"; + if (!hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)) return "quota_threshold"; + return undefined; +} diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 33e6af3f561..1850366a962 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -333,6 +333,22 @@ newer statement wins. Ordinary round-robin movement inside the capped tier does Without that last rule a pin made before any order existed, which is just an ordinary account switch, would outrank every order set afterwards for as long as the account kept headroom. +Whether a pin is about to be released is one predicate, `codexAccountPinDrainReason` in +`src/codex/routing/pin-drain.ts`, and the surface that accepts a pin evaluates it rather than +keeping a second copy. It lives beside selection rather than in `routing.ts` for the same reason +`routing/cache-affinity.ts` does: two callers ask it and must answer identically, one acting on +the answer and one reporting it. + +`PUT /api/codex-auth/active` still accepts a pin on a drained account -- a usage reading is a +preference and can be stale, so refusing would turn a proactive threshold into a hard capacity limit +-- but it reports `pinDrained` with a `pinDrainReason` of `needs_reauth`, `paused`, `unusable` or +`quota_threshold` when the next resolve would drop what it just recorded. The fields are absent when +the pin survives, so a client that does not know them reads no drain. Without this the route answered +a bare 200 and the operator watched an accepted selection be ignored one request later, which is the +contradiction reported in #4521. Reauth and pause are classified before the native-main fence, +because a selection-only caller makes every later classification answer "no drain" and a pin on a +signed-out main would otherwise read as durable. + Only an actual selection pins. Clearing the active account states that no account is chosen, so it releases the pin instead of recording one against the `__main__` fallback that the same handler uses for its paused check. A pin no effective active account matches is invisible — `pinned` compares the diff --git a/tests/cli/cli-account-pin-drain.test.ts b/tests/cli/cli-account-pin-drain.test.ts new file mode 100644 index 00000000000..baf486d932c --- /dev/null +++ b/tests/cli/cli-account-pin-drain.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { cmdAccount } from "../../src/cli/account"; +import type { AccountDeps } from "../../src/cli/account-api"; + +/** + * #4521: `ocx account use` printed "auto-switch (threshold 80%) may override this pin" + * whether or not the pin was already spent, so the one case that needed a definite sentence + * got the same hedge as the healthy case. The route now reports the drain it evaluated. + */ +const BASE_URL = "http://127.0.0.1:10100"; + +function deps(putJson: Record, thresholdJson: Record): AccountDeps { + return { + baseUrl: BASE_URL, + loadConfigImpl: () => ({ providers: { openai: { adapter: "codex" } } }) as never, + fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + if (path === "/api/codex-auth/active" && (init?.method ?? "GET") === "PUT") { + return new Response(JSON.stringify(putJson), { status: 200 }); + } + if (path === "/api/codex-auth/active") { + return new Response(JSON.stringify(thresholdJson), { status: 200 }); + } + return new Response("{}", { status: 404 }); + }) as unknown as typeof fetch, + }; +} + +async function run(args: string[], accountDeps: AccountDeps): Promise<{ code: number; out: string; err: string }> { + const out: string[] = []; + const err: string[] = []; + const log = console.log; + const error = console.error; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); }; + try { + const code = await cmdAccount(args, accountDeps); + return { code, out: out.join("\n"), err: err.join("\n") }; + } finally { + console.log = log; + console.error = error; + } +} + +describe("account use pin-drain reporting", () => { + test("a reported quota drain is stated, not hedged", async () => { + const result = await run(["use", "openai", "pool_hot"], deps({ + ok: true, + activeCodexAccountId: "pool_hot", + appliesImmediately: true, + pinDrained: true, + pinDrainReason: "quota_threshold", + }, { autoSwitchThreshold: 80 })); + + expect(result.code).toBe(0); + expect(result.err).toContain("is at or above the auto-switch threshold (80%)"); + expect(result.err).toContain("routing releases this pin on its next request"); + expect(result.err).not.toContain("may override this pin"); + }); + + test("a non-quota drain names its own reason", async () => { + const result = await run(["use", "openai", "pool_gone"], deps({ + ok: true, + activeCodexAccountId: "pool_gone", + appliesImmediately: true, + pinDrained: true, + pinDrainReason: "needs_reauth", + }, { autoSwitchThreshold: 80 })); + + expect(result.code).toBe(0); + expect(result.err).toContain("cannot currently be selected (needs_reauth)"); + expect(result.err).not.toContain("auto-switch threshold"); + }); + + test("no reported drain keeps the generic caveat", async () => { + const result = await run(["use", "openai", "pool_cool"], deps({ + ok: true, + activeCodexAccountId: "pool_cool", + appliesImmediately: true, + }, { autoSwitchThreshold: 80 })); + + expect(result.code).toBe(0); + expect(result.err).toContain("auto-switch (threshold 80%) may override this pin"); + expect(result.err).not.toContain("routing releases this pin"); + }); + + test("--json carries the two fields through", async () => { + const result = await run(["use", "openai", "pool_hot", "--json"], deps({ + ok: true, + activeCodexAccountId: "pool_hot", + appliesImmediately: true, + pinDrained: true, + pinDrainReason: "quota_threshold", + }, { autoSwitchThreshold: 80 })); + + expect(result.code).toBe(0); + expect(JSON.parse(result.out)).toMatchObject({ + ok: true, + provider: "openai", + type: "codex", + pinDrained: true, + pinDrainReason: "quota_threshold", + }); + }); +}); diff --git a/tests/codex-integration/codex-pin-drain-projection.test.ts b/tests/codex-integration/codex-pin-drain-projection.test.ts new file mode 100644 index 00000000000..1060c14e0d2 --- /dev/null +++ b/tests/codex-integration/codex-pin-drain-projection.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { + clearAccountNeedsReauth, + clearAccountQuota, + handleCodexAuthAPI, + markAccountNeedsReauth, + updateAccountQuota, +} from "../../src/codex/auth-api"; +import { pinnedCodexAccountId } from "../../src/codex/account-priority"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, + codexAccountPinDrainReason, +} from "../../src/codex/routing"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig } from "../../src/types"; + +/** + * #4521: "Use this account next" was accepted with a bare 200 for an account the very next + * resolve would unpin. The route checks existence, pause and pending validation; the release + * checks quota headroom. Nothing carried the second answer back to the operator, so the + * setting looked ignored one request later. + * + * These pin the reporting contract and the one ordering the extraction of + * {@link codexAccountPinDrainReason} out of the release could have silently lost. + */ +const TEST_DIR = join(import.meta.dir, ".tmp-codex-pin-drain-projection-test"); +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [], + activeCodexAccountId: undefined, + ...overrides, + } as OcxConfig; +} + +function seedAccount(config: OcxConfig, id: string): void { + config.codexAccounts = [ + ...(config.codexAccounts ?? []), + { id, email: `${id}@example.test`, isMain: false }, + ]; + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +async function selectAccount(config: OcxConfig, accountId: string): Promise> { + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + return await resp!.json() as Record; +} + +describe("manual pin drain projection", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + process.env.CODEX_HOME = TEST_DIR; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + }); + + afterEach(() => { + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + }); + + test("an over-threshold selection is accepted and reported as a pin the threshold will release", async () => { + const config = makeConfig(); + seedAccount(config, "pool-pin-hot"); + updateAccountQuota("pool-pin-hot", 90); + + expect(await selectAccount(config, "pool-pin-hot")).toMatchObject({ + ok: true, + activeCodexAccountId: "pool-pin-hot", + appliesImmediately: true, + pinDrained: true, + pinDrainReason: "quota_threshold", + }); + // Reporting the outcome is not refusing the operator: a usage score is a preference and + // the reading can be stale, so the pin is still recorded. + expect(pinnedCodexAccountId(config)).toBe("pool-pin-hot"); + }); + + test("a selection with headroom omits both fields rather than reporting false", async () => { + const config = makeConfig(); + seedAccount(config, "pool-pin-cool"); + updateAccountQuota("pool-pin-cool", 10); + + const body = await selectAccount(config, "pool-pin-cool"); + expect(body).toMatchObject({ + ok: true, + activeCodexAccountId: "pool-pin-cool", + appliesImmediately: true, + }); + // Absent, so a client that does not know the fields reads no drain. + expect(body).not.toHaveProperty("pinDrained"); + expect(body).not.toHaveProperty("pinDrainReason"); + }); + + test("clearing the selection reports nothing, because it releases the pin instead of making one", async () => { + const config = makeConfig(); + seedAccount(config, "pool-pin-cool"); + updateAccountQuota("pool-pin-cool", 10); + await selectAccount(config, "pool-pin-cool"); + + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + const body = await resp!.json() as Record; + expect(body).not.toHaveProperty("pinDrained"); + expect(pinnedCodexAccountId(config)).toBeUndefined(); + }); + + test("a cached reauth is classified before the native-main fence", () => { + const config = makeConfig(); + // A selection-only caller owns the native-main drain fence, and past it every later + // classification answers "no drain". Reading reauth after it would make a pin on a + // signed-out main read as durable, which is the ordering the extraction preserves. + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + expect(codexAccountPinDrainReason(config, MAIN_CODEX_ACCOUNT_ID, { nativeMainSelectionOnly: true })) + .toBe("needs_reauth"); + + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + expect(codexAccountPinDrainReason(config, MAIN_CODEX_ACCOUNT_ID, { nativeMainSelectionOnly: true })) + .toBeUndefined(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 76e216195c1..7687202b9dd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -199,6 +199,7 @@ "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", + "cli-account-pin-drain.test.ts": "cli", "cli-account-pool-verbs.test.ts": "cli", "cli-account.test.ts": "cli", "cli-capabilities.test.ts": "cli", @@ -345,6 +346,7 @@ "codex-models-cache-invalidate.test.ts": "codex-integration", "codex-native-residue.test.ts": "codex-integration", "codex-plan.test.ts": "codex-integration", + "codex-pin-drain-projection.test.ts": "codex-integration", "codex-plugins-doctor.test.ts": "codex-integration", "codex-pool-plan-exclusion.test.ts": "codex-integration", "codex-pool-rotation.test.ts": "codex-integration",