diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b1dbc20861f..e41b3eb9a67 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -314,6 +314,7 @@ "catalog-opencode-go-context-window.test.ts": "codex-integration", "catalog-remote-pull.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", + "catalog-routed-comp-hash.test.ts": "codex-integration", "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index d138c468282..81bfee3ada0 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -783,6 +783,10 @@ export function mergeCatalogEntriesFromObservedState({ } const slug = String(entry.slug); if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; + // The builder no longer copies a template's comp_hash onto routed rows (#5796), but a row + // kept from disk may still carry one. Custom rows, Codex-forward aliases included, never + // reach this loop: they are rebuilt from config. + entry.comp_hash = "opencodex"; const featuredRank = featuredRankOf(slug); entry.priority = featuredRank !== undefined ? featuredRank * priorityStride diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index c6b0a9253a5..8058fc6850d 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -144,6 +144,11 @@ export function deriveEntry( delete e.context_window; delete e.max_context_window; delete e.auto_compact_token_limit; + // Nor its comp_hash (#5796). Codex compacts a thread whenever the recorded value + // changes, and the template is whichever native row a rebuild found first, so an + // inherited value moves with rebuild order. Left unset, normalization gives every + // routed row the same "opencodex" marker. + delete e.comp_hash; } if (typeof e.base_instructions === "string") { // Proxy-neutral: keep the GPT-5/OpenAI disclaimer but never advertise the opencodex proxy diff --git a/structure/catalog.md b/structure/catalog.md index df1aa8747fb..e72549e518f 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -58,7 +58,11 @@ provider-wide fallback. Exact model output limits precede the provider default o explicitly configured canonical `openai/gpt-daybreak-blue-latest` Codex-forward row from the pinned Sol capability metadata while preserving its selector and Daybreak wire identity; this never expands the bare/API-key model lists or rewrites the wire model to `gpt-5.6-sol`; -- clones a native template for routed `provider/model` entries; +- clones a native template for routed `provider/model` entries without its `comp_hash`, and resets + that value on opencodex rows kept from disk while their provider's discovery is degraded, so these + rows carry the fixed `"opencodex"` marker instead of whichever native row a rebuild found first; + Codex compacts a thread whenever that value changes (#5796). Codex-forward aliases keep their + native value and rows written by other tools keep theirs; - forces strict Codex catalog fields required by the current parser; - hides `disabledModels` without blocking direct routing (routed provider ids are excluded; account-qualified native ids hide only that selector row; BARE native slugs hide the bare row diff --git a/tests/codex-integration/catalog-routed-comp-hash.test.ts b/tests/codex-integration/catalog-routed-comp-hash.test.ts new file mode 100644 index 00000000000..d32a22bedeb --- /dev/null +++ b/tests/codex-integration/catalog-routed-comp-hash.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { buildCatalogEntries } from "../../src/codex/catalog"; +import { + buildCatalogEntriesFromObservedState, + CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + mergeCatalogEntriesFromObservedState, +} from "../../src/codex/catalog/sync"; +import type { RawEntry } from "../../src/codex/catalog/parsing"; + +/** + * A routed row is cloned from whichever native row a rebuild picks as the template, and it + * used to keep that row's `comp_hash` (#5796). Codex compacts a thread when consecutive turns + * record different values, so a rebuild that picked another template compacted every active + * routed thread. + */ +describe("catalog routed comp_hash (#5796)", () => { + const routedHash = (compHash: string | undefined) => buildCatalogEntries( + { + slug: "gpt-5.5", + display_name: "gpt-5.5", + description: "Native GPT model", + priority: 1, + visibility: "list", + base_instructions: "You are Codex, a coding agent based on GPT-5.", + ...(compHash === undefined ? {} : { comp_hash: compHash }), + }, + [], + [{ provider: "local", id: "qwen3-coder" }], + ).find(e => e.slug === "local/qwen3-coder")?.comp_hash; + + test("routed rows keep one value whichever native row is the template", () => { + expect(routedHash("3000")).toBe("opencodex"); + expect(routedHash("2911")).toBe("opencodex"); + expect(routedHash(undefined)).toBe("opencodex"); + }); + + test("a row kept from disk during a provider outage drops a copied value", () => { + const merge = (catalogModels: readonly RawEntry[], routedEntries: readonly RawEntry[], degraded: boolean) => + mergeCatalogEntriesFromObservedState({ + catalogModels, routedEntries, baselineCatalogModels: [], baseline: new Map(), featured: [], + wsEnabled: false, template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(["local"]), + degradedProviderNames: new Set(degraded ? ["local"] : []), + legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, + exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, + accountBoundEntries: [], + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, + }); + const fresh = buildCatalogEntriesFromObservedState({ + template: null, gptSlugs: [], goModels: [{ provider: "local", id: "qwen3-coder" }], + featured: [], modelPickerOrder: [], wsEnabled: false, multiAgentMode: "default", + exactComboSlugs: new Set(), accountSelectors: [], suppressedBareNativeSlugs: new Set(), + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, + }); + // A catalog written before the fix, where the routed row still carries a template's value, + // plus a row another tool wrote under the same provider. That one is not ours to change. + const saved = merge([], fresh, false).map(entry => + entry.slug === "local/qwen3-coder" ? { ...entry, comp_hash: "3000" } : entry); + const foreign = { + ...saved.find(entry => entry.slug === "local/qwen3-coder")!, + slug: "local/imported", + description: "Imported model", + }; + const kept = merge([...saved, foreign], [], true); + const hash = (slug: string) => kept.find(entry => entry.slug === slug)?.comp_hash; + expect(hash("local/qwen3-coder")).toBe("opencodex"); + expect(hash("local/imported")).toBe("3000"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 10b04d90c0c..9aaa613cf30 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -140,6 +140,7 @@ "catalog-opencode-go-context-window.test.ts": "codex-integration", "catalog-remote-pull.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", + "catalog-routed-comp-hash.test.ts": "codex-integration", "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration",