diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 2372c599e3..884b9e46a5 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -494,6 +494,8 @@ export interface ObservedCatalogMergeInput { readonly openaiContextCap?: NativeContextLimitsInput; /** Exact display-only labels for bare native OpenAI models. */ readonly nativeDisplayNames?: Readonly>; + /** Pristine installed-catalog multi-agent pins; see MultiAgentModeOptions.nativeDefaults. */ + readonly nativeMultiAgentDefaults?: ReadonlyMap; } /** @@ -529,6 +531,7 @@ export function mergeCatalogEntriesFromObservedState({ policy, openaiContextCap, nativeDisplayNames, + nativeMultiAgentDefaults, }: ObservedCatalogMergeInput): RawEntry[] { // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. @@ -886,7 +889,7 @@ export function mergeCatalogEntriesFromObservedState({ applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), multiAgentMode, multiAgentV2Enabled, - { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, + { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection, nativeDefaults: nativeMultiAgentDefaults }, ); applyFullModelPickerOrder(versionedEntries, modelPickerOrder); for (const entry of versionedEntries) { @@ -937,6 +940,7 @@ export function mergeCatalogEntriesForSync( ), openaiContextCap?: NativeContextLimitsInput, keepNativeChatGptOnV1 = false, + nativeMultiAgentDefaults?: ReadonlyMap, ): RawEntry[] { // Retained for source compatibility with the original helper contract. Raw provider ids must // not suppress same-named native rows; actual admitted combo entries own that decision now. @@ -973,6 +977,7 @@ export function mergeCatalogEntriesForSync( accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + nativeMultiAgentDefaults, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index e64f5de3ae..78a5ab93e6 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -658,6 +658,14 @@ export interface MultiAgentModeOptions { * so they can still spawn Grok/Claude — ChatGPT encrypts v2 NEW_TASK bodies. */ keepNativeChatGptOnV1?: boolean; + /** + * Pristine installed-catalog pins keyed by bare native slug. When provided, the + * backup — not the bundled snapshot — is authoritative for the rows it contains, + * and a preserved live/native row outside it keeps the pin it already carries: + * an absent baseline entry cannot distinguish a stale forced stamp from a + * legitimate user- or provider-preserved pin, so the non-destructive read wins. + */ + nativeDefaults?: ReadonlyMap; } /** Catalog rows that run on the ChatGPT backend (encrypt v2 child tasks). */ @@ -738,13 +746,32 @@ export function applyMultiAgentMode( && hasNativeOpenAiCapabilityMetadata(routedNativeSlug) ? routedNativeSlug : undefined; + const accountBoundNativeSlug = trustedAccountBoundNativeCatalogSlug(entry); + const nativeLookupSlug = accountBoundNativeSlug ?? slug; + // The baseline is built from bare native slugs only, so "absent from the + // baseline" is not evidence about a routed row — it is guaranteed. Only a + // native row can carry a pin the baseline legitimately failed to mention; + // a routed row keeps the documented default-mode normalization. + const isNativeCatalogEntry = accountBoundNativeSlug !== undefined || !slug.includes("/"); + const hasNativeDefault = !nativeAlias + && codexForwardCapabilityAlias === undefined + && options.nativeDefaults?.has(nativeLookupSlug) === true; const upstreamPin = nativeAlias ? nativeMultiAgentVersion(slug) : codexForwardCapabilityAlias ? nativeMultiAgentVersion(codexForwardCapabilityAlias) - : UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug)?.multi_agent_version; + : hasNativeDefault + ? options.nativeDefaults?.get(nativeLookupSlug) + : UPSTREAM_NATIVE_ENTRIES.get(nativeLookupSlug)?.multi_agent_version; if (typeof upstreamPin === "string") { entry.multi_agent_version = upstreamPin; + } else if (options.nativeDefaults !== undefined + && !nativeAlias + && codexForwardCapabilityAlias === undefined + && isNativeCatalogEntry + && !hasNativeDefault + && typeof entry.multi_agent_version === "string") { + continue; } else if (v2FeatureEnabled) { entry.multi_agent_version = "v2"; } else { @@ -919,3 +946,19 @@ export function readNativeBaseline(catalogPath: string): Map { } return out; } + +/** + * Extract the pristine baseline's per-slug multi-agent pins. A bare native row that + * carried no pin maps to null so "baseline says unpinned" stays distinguishable + * from "baseline never contained this row". + */ +export function nativeMultiAgentDefaults( + models: readonly Readonly>[] | null | undefined, +): Map { + const out = new Map(); + for (const entry of models ?? []) { + if (typeof entry.slug !== "string" || entry.slug.includes("/")) continue; + out.set(entry.slug, typeof entry.multi_agent_version === "string" ? entry.multi_agent_version : null); + } + return out; +} diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index df2a491086..52b80ee4c2 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -33,6 +33,7 @@ import { readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline, + nativeMultiAgentDefaults, } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { @@ -452,6 +453,7 @@ function writeRetainedCatalogSync({ // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. const baselineCatalog = readCatalogBackup(catalogPath); const baseline = readNativeBaseline(catalogPath); + const nativePinBaseline = nativeMultiAgentDefaults(baselineCatalog?.models); const gatheredProviderNames = new Set( Object.entries(config.providers ?? {}) .filter(([, prov]) => prov.disabled !== true) @@ -523,6 +525,7 @@ function writeRetainedCatalogSync({ suppressedBareNativeSlugs, openaiContextCap, nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, + nativeMultiAgentDefaults: nativePinBaseline, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index a8bb4b611b..806ef48115 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -36,6 +36,7 @@ import { catalogHasRoutedEntries, findSupportedNativeTemplate, legacyCatalogBackupPath, + nativeMultiAgentDefaults, parseCatalogJson, type RawCatalog, type RawEntry, @@ -373,6 +374,7 @@ function prepareCatalog( suppressedBareNativeSlugs, openaiContextCap, nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, + nativeMultiAgentDefaults: nativeMultiAgentDefaults(baselineCatalogModels), policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 7b9ce035ae..a7fb5cd818 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -20,6 +20,7 @@ import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, } from "../../src/codex/catalog/sync"; +import { nativeMultiAgentDefaults } from "../../src/codex/catalog/parsing"; import { getAgentsEnabled, getAgentsMaxDepth, @@ -2065,5 +2066,100 @@ describe("3-state multi-agent mode", () => { // gpt-5.5 has no upstream pin — cleared (codex flag decides) expect(native.multi_agent_version).toBeUndefined(); }); + + test("mode default prefers pristine-baseline pins over the bundled snapshot", () => { + // The installed pristine backup is authoritative for the rows it contains: a + // baseline pin wins even when the bundled snapshot pins a different value, and + // a baseline row with no pin still gets stale forced-stamp cleanup. + const diskSol = { ...template(), slug: "gpt-5.6-sol", display_name: "GPT-5.6 Sol", multi_agent_version: "v2" }; + const diskLuna = { ...template(), slug: "gpt-5.6-luna", display_name: "GPT-5.6 Luna", multi_agent_version: "v2" }; + const diskNative = { ...template(), slug: "gpt-5.5", display_name: "gpt-5.5", multi_agent_version: "v2" }; + const merged = mergeCatalogEntriesForSync( + [diskSol as never, diskLuna as never, diskNative as never], + [], new Map(), [], false, new Set(), null, new Set(), new Set(), "default", + new Set(), false, true, [], new Set(), new Set(), undefined, false, + new Map([ + ["gpt-5.6-sol", "v1"], + ["gpt-5.6-luna", "v1"], + ["gpt-5.5", null], + ]), + ); + // Baseline says v1 — applied instead of the bundled snapshot's v2 pin. + expect(merged.find(e => e.slug === "gpt-5.6-sol")?.multi_agent_version).toBe("v1"); + expect(merged.find(e => e.slug === "gpt-5.6-luna")?.multi_agent_version).toBe("v1"); + // Baseline contains the row with no pin — stale forced stamp is cleared. + expect(merged.find(e => e.slug === "gpt-5.5")?.multi_agent_version).toBeUndefined(); + }); + + test("mode default preserves pins on live native rows outside the pristine baseline", () => { + // A preserved on-disk row the pristine backup never contained may carry a + // user- or provider-preserved pin newer than our bundled snapshot. It was not + // stamped by us, so default mode must not delete it. + const liveNative = { ...template(), slug: "custom-native", display_name: "Custom Native", multi_agent_version: "v2" }; + // A routed row is never in the bare-native baseline, so its absence proves + // nothing; default mode still clears its stale pin. + const staleRouted = { ...template(), slug: "provider/model", display_name: "Routed", multi_agent_version: "v1" }; + const merged = mergeCatalogEntriesForSync( + [liveNative as never, staleRouted as never], + [], new Map(), [], false, new Set(), null, new Set(), new Set(), "default", + new Set(), false, true, [], new Set(), new Set(), undefined, false, + new Map([["gpt-5.6-sol", "v2"]]), + ); + expect(merged.find(e => e.slug === "custom-native")?.multi_agent_version).toBe("v2"); + expect(merged.find(e => e.slug === "provider/model")?.multi_agent_version).toBeUndefined(); + }); + + test("mode default keys baseline pins by trusted account-bound slugs only", () => { + // hasNativeDefault resolves the lookup slug through + // trustedAccountBoundNativeCatalogSlug, so an account-bound clone tracks its + // bound native's pristine pin: the backup's "v1" beats both the bundled + // snapshot's "v2" and a stale stamp on the clone, and a baseline row with no + // pin still clears the clone's stale stamp. + const boundSol = { + ...template(), + slug: "team/gpt-5.6-sol", + display_name: "team / GPT-5.6 Sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + multi_agent_version: "v2", + }; + const boundNative = { + ...template(), + slug: "team/gpt-5.5", + display_name: "team / gpt-5.5", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + multi_agent_version: "v2", + }; + // An untrusted slashed row must not key the baseline by its post-slash part: + // "external/gpt-5.6-sol" is not the native "gpt-5.6-sol" row, so its preserved + // pin survives instead of being rewritten to the baseline's "v1". + const foreignRouted = { + ...template(), + slug: "external/gpt-5.6-sol", + display_name: "External Sol", + multi_agent_version: "v2", + }; + const merged = mergeCatalogEntriesForSync( + [foreignRouted as never], [], new Map(), [], false, + new Set(), null, new Set(), new Set(), "default", + new Set(), false, true, [boundSol as never, boundNative as never], + new Set(), new Set(), undefined, false, + new Map([["gpt-5.6-sol", "v1"], ["gpt-5.5", null]]), + ); + expect(merged.find(e => e.slug === "team/gpt-5.6-sol")?.multi_agent_version).toBe("v1"); + expect(merged.find(e => e.slug === "team/gpt-5.5")?.multi_agent_version).toBeUndefined(); + expect(merged.find(e => e.slug === "external/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); + + // The baseline extractor itself never indexes slashed rows, so account-bound + // or routed rows inside a backup cannot alias a bare native slug. + const defaults = nativeMultiAgentDefaults([ + { slug: "gpt-5.6-sol", multi_agent_version: "v1" }, + { slug: "team/gpt-5.6-sol", multi_agent_version: "v2" }, + { slug: "gpt-5.5" }, + ]); + expect(defaults.get("gpt-5.6-sol")).toBe("v1"); + expect(defaults.has("team/gpt-5.6-sol")).toBe(false); + expect(defaults.has("gpt-5.5")).toBe(true); + expect(defaults.get("gpt-5.5")).toBeNull(); + }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index 799da73731..716b586605 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -41,7 +41,7 @@ "tests/codex-integration/codex-reset-credit-recovery.test.ts": 2135, "tests/codex-integration/codex-routing.test.ts": 3443, "tests/codex-integration/codex-shim.test.ts": 2388, - "tests/codex-integration/codex-v2-gate.test.ts": 2069, + "tests/codex-integration/codex-v2-gate.test.ts": 2111, "tests/providers/cursor/cursor-blob.test.ts": 3657, "tests/providers/kiro/kiro-adapter.test.ts": 2050, "tests/providers/kiro/kiro-stream.test.ts": 2258,