From 8cc5bb636563cee0500b4e75ab50cea82a9555b3 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 03:18:34 +0200 Subject: [PATCH 1/4] fix(catalog): preserve Go efforts and support native-first picker order --- .../content/docs/fr/guides/model-ordering.md | 27 ++++++++-- .../src/content/docs/guides/model-ordering.md | 24 +++++++-- .../docs/reference/configuration/providers.md | 15 ++++++ scripts/test-layout/layout.json | 2 + src/codex/catalog/sync.ts | 25 +++++++-- src/codex/convergence.ts | 1 + src/types/config.ts | 18 +++---- .../catalog-full-picker-order.test.ts | 52 +++++++++++++++++++ .../catalog-go-exact-efforts.test.ts | 39 ++++++++++++++ tests/codex-integration/codex-catalog.test.ts | 10 ++-- tests/codex-integration/codex-v2-gate.test.ts | 5 +- tests/fixtures/test-layout-expected.json | 2 + 12 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 tests/codex-integration/catalog-full-picker-order.test.ts create mode 100644 tests/codex-integration/catalog-go-exact-efforts.test.ts diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index cac2b0667c..c4b658d7b6 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -23,7 +23,7 @@ priorités `i * N + j`, où `j` est la position du sélecteur en base zéro ; un sont déplacées hors de ces groupes de sélecteurs. Codex continue de n’annoncer que les cinq premières lignes visibles dans le sélecteur. -Les priorités sans sélecteur pertinentes sont : +Sans ordre global du sélecteur, les priorités sans sélecteur pertinentes sont : | Entrée du catalogue | Priorité | Source | | --- | --- : | --- | @@ -134,8 +134,29 @@ au-delà de ce bloc mis en avant : Les lignes routées indiquées apparaissent dans l’ordre configuré. Une ligne absente du tableau conserve sa priorité normale et reste donc devant la bande d’affichage de `modelPickerOrder` ; indiquez toutes les lignes routées dont vous souhaitez contrôler l’ordre relatif. Une ligne également présente dans -`subagentModels` conserve sa priorité de mise en avant. `modelPickerOrder` ne réorganise ni les lignes -natives non qualifiées ni celles qualifiées par un compte ; utilisez `subagentModels` pour celles-ci. +`subagentModels` conserve sa priorité de mise en avant. Une liste contenant uniquement des identifiants +routés conserve la position normale des lignes natives. + +Pour ordonner tout le sélecteur, incluez un identifiant natif non qualifié : + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Les lignes indiquées apparaissent d’abord dans l’ordre du tableau, puis les lignes absentes +selon leur priorité naturelle. La correspondance est exacte : `gpt-5.6-sol` et +`openai/gpt-5.6-sol` désignent deux lignes distinctes. Pour une ligne qualifiée par un compte, +indiquez son identifiant complet, sélecteur inclus. + +### Migration : identifiants natifs dans les listes existantes + +Auparavant, les identifiants natifs dans `modelPickerOrder` étaient ignorés. Une liste +existante contenant un identifiant natif non qualifié ordonne désormais tout le sélecteur, +y compris les lignes mises en avant. Supprimez ces identifiants pour conserver l’ancien +comportement limité aux lignes routées. Les listes absentes, vides ou uniquement routées +conservent leur comportement ; les priorités des candidats sous-agents ne changent pas. `modelPickerOrder` ne modifie jamais l’ensemble des candidats de `spawn_agent`. Il change uniquement la priorité visible par Codex dans le sélecteur, tandis qu’OpenCodex conserve la priorité naturelle de chaque diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 696f631a58..5bcf221477 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -23,7 +23,7 @@ priorities `i * N + j`, where `j` is the selector's zero-based position; a route rows are moved outside those selector groups. Codex still advertises only the first five picker-visible rows. -The relevant no-selector priorities are: +Without complete-picker ordering, the relevant no-selector priorities are: | Catalog entry | Priority | Source | | --- | ---: | --- | @@ -133,8 +133,26 @@ featured block: Listed routed rows appear in the configured order. A routed row omitted from the array keeps its normal priority, so it remains ahead of the `modelPickerOrder` display band; list every routed row whose relative position you want to control. A row also present in `subagentModels` keeps its -featured priority. Bare native and account-qualified native rows are not reordered by -`modelPickerOrder`; use `subagentModels` for those rows. +featured priority. With a routed-only list, native rows keep their normal positions. + +To order the complete picker, include a bare native id: + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Listed rows appear first in array order, followed by unlisted rows in natural priority +order. Matching uses exact catalog ids: `gpt-5.6-sol` and `openai/gpt-5.6-sol` are separate +rows. Account-qualified rows need their exact selector-qualified id in the list. + +### Migration note: native ids in existing orders + +Previously, native ids in `modelPickerOrder` were ignored. An existing list containing +a bare native id now activates complete-picker ordering, including featured rows. +Remove bare native ids to keep the previous routed-only behavior. Unset, empty and +routed-only lists retain their behavior; subagent candidate priorities are unchanged. `modelPickerOrder` never changes the `spawn_agent` candidate set. It changes only the Codex-visible picker priority while opencodex retains each moved row's natural priority for diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 600560627d..e60245c4de 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -840,3 +840,18 @@ or expiry does not extend the history-recovery contract. Sender and recipient on Go Responses are context for the receiving model, not a new machine-readable routing protocol. Tool routing continues to use the existing collaboration contracts. + +## OpenCode Go reasoning efforts + +Go catalog rows preserve their configured reasoning efforts exactly, including during +catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. +Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted +upstream values. Omen Alpha accepts `low`, `high`, and `max`; Muse Spark 1.3 Contributor +accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). +A configured subset can exclude the lower tiers. Other providers retain their existing behavior. + +For a native-first picker, include native ids in `modelPickerOrder` followed by the +routed ids. This orders the complete picker while preserving the separate subagent +candidate priorities. Routed-only orders keep their previous behavior. See the +[ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). +`modelDisplayNames` on a provider controls readable labels without changing wire ids. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 045707d947..b29295d3da 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -255,6 +255,8 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3f5f472baf..897b24834f 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -315,6 +315,8 @@ export function deriveEntry( contextCap?: NativeContextLimitsInput, ): RawEntry { const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); + // Go exposes model-specific upstream enums; synthetic tiers mislead subagent overrides. + const preserveExactReasoning = preserveExact || model?.provider === "opencode-go"; const codexForwardNativeCapabilityAlias = model?.codexForwardNativeCapabilityAlias === true ? upstreamNativeEntry(model.id) : null; @@ -359,7 +361,7 @@ export function deriveEntry( e, model?.reasoningEfforts, model?.defaultReasoningEffort, - preserveExact || codexForwardNativeCapabilityAlias !== null, + preserveExactReasoning || codexForwardNativeCapabilityAlias !== null, ); // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned // native tool/search/responses-lite contract while preserving the routed slug and wire id. @@ -409,7 +411,7 @@ export function deriveEntry( }; if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); - applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); + applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExactReasoning); } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); @@ -779,12 +781,24 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< unsupportedNativeEntries: "drop", }); +/** A picker order containing native ids orders the whole list, without changing spawn ranks. */ +export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { + if (!order.some(slug => !slug.includes("/"))) return; + const rank = new Map(order.map((slug, index) => [slug, index])); + for (const entry of entries) { + const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; + entry[SPAWN_PRIORITY_FIELD] = natural; + entry.priority = rank.get(String(entry.slug)) ?? order.length + Number(natural); + } +} + export interface ObservedCatalogMergeInput { readonly catalogModels: readonly RawEntry[]; readonly baselineCatalogModels: readonly RawEntry[]; readonly routedEntries: readonly RawEntry[]; readonly baseline: ReadonlyMap; readonly featured: readonly string[]; + readonly modelPickerOrder?: readonly string[]; readonly wsEnabled: boolean; readonly template: RawEntry | null; readonly disabledModels: ReadonlySet; @@ -817,6 +831,7 @@ export function mergeCatalogEntriesFromObservedState({ routedEntries, baseline, featured, + modelPickerOrder = [], wsEnabled, template, disabledModels, @@ -976,6 +991,8 @@ export function mergeCatalogEntriesFromObservedState({ return finished; } const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); + // Recompute spawn rank from current featured models, not a prior picker override. + delete preserved[SPAWN_PRIORITY_FIELD]; // Older natives kept from disk still need the mock top tiers (max + ultra always // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); @@ -1134,7 +1151,7 @@ export function mergeCatalogEntriesFromObservedState({ // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. - if (!exactCombo && !reserveProjection) { + if (!exactCombo && !reserveProjection && !String(e.slug ?? "").startsWith("opencode-go/")) { const levels = Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels as Array<{ effort?: string }> : []; @@ -1161,6 +1178,7 @@ export function mergeCatalogEntriesFromObservedState({ multiAgentV2Enabled, { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, ); + applyFullModelPickerOrder(versionedEntries, modelPickerOrder); for (const entry of versionedEntries) { const kind = entry.opencodex_catalog_kind; if (trustedAccountBoundNativeCatalogSlug(entry) === undefined @@ -1762,6 +1780,7 @@ function writeRetainedCatalogSync({ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, catalogModels: catalogModelsForMerge, baselineCatalogModels: baselineCatalog?.models ?? [], routedEntries: goEntries, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index df765a7853..d1884bca71 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -342,6 +342,7 @@ function prepareCatalog( )), ); const mergedModels = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, catalogModels, baselineCatalogModels, routedEntries, diff --git a/src/types/config.ts b/src/types/config.ts index 34e2d385d6..50e542927a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -419,17 +419,13 @@ export interface OcxConfig { /** One-time featured-roster upgrade marker; later user ordering is preserved. */ subagentModelsVersion?: number; /** - * Optional full picker ordering for the Codex model catalog, independent of the - * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of - * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort - * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog - * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and - * account-qualified native rows are not reordered (order native rows via `subagentModels`). - * Listed routed rows appear in array order; rows not listed keep their normal display order. - * `subagentModels`-featured rows keep their top position. When unset or empty, catalog - * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent - * candidate set is derived from each row's natural priority and is provably unaffected, even - * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). + * Display-only order for the Codex picker, independent of subagentModels. + * Routed-only lists order non-featured routed rows; featured and native rows keep + * their normal positions. Including a bare native id opts into ordering the complete + * picker: listed ids appear first in array order, followed by unlisted rows in their + * natural priority order. Values are exact catalog ids. The separate natural spawn + * priority is preserved, so display order does not change subagent candidates. + * Unset or empty leaves catalog priorities unchanged. */ modelPickerOrder?: string[]; /** diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts new file mode 100644 index 0000000000..cf5c235cf5 --- /dev/null +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; +import { applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; + +test("native-first picker order preserves Go subagent ranks and is repeatable", () => { + const rows: any[] = [ + { slug: "opencode-go/glm-5.3", priority: 0 }, + { slug: "gpt-5.6-sol", priority: 9 }, + { slug: "gpt-6-astra", priority: 9 }, + ]; + const order = ["gpt-6-astra", "gpt-5.6-sol", "opencode-go/glm-5.3"]; + applyFullModelPickerOrder(rows, order); + expect([...rows].sort((a,b) => a.priority-b.priority).map(r => r.slug)).toEqual(order); + expect(rows.map(r => r[SPAWN_PRIORITY_FIELD])).toEqual([0,9,9]); + const once = structuredClone(rows); + applyFullModelPickerOrder(rows, order); + expect(rows).toEqual(once); +}); + +test("existing routed-only ordering retains its behavior", () => { + const rows: any[] = [{ slug: "opencode-go/glm-5.3", priority: 1000 }]; + applyFullModelPickerOrder(rows, ["opencode-go/glm-5.3"]); + expect(rows).toEqual([{ slug: "opencode-go/glm-5.3", priority: 1000 }]); +}); + + +test("sync refreshes native spawn rank when featured models change", () => { + const sol = deriveEntry(null, "gpt-5.6-sol", "Sol", 105); + const order = ["gpt-5.6-sol"]; + applyFullModelPickerOrder([sol], order); + expect(sol[SPAWN_PRIORITY_FIELD]).toBe(105); + + const baseline = new Map([["gpt-5.6-sol", 9]]); + const promoted = mergeCatalogEntriesForSync([sol], [], baseline, ["gpt-5.6-sol"], false); + applyFullModelPickerOrder(promoted, order); + expect(promoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(0); + + const demoted = mergeCatalogEntriesForSync(promoted, [], baseline, ["opencode-go/glm-5.3"], false); + applyFullModelPickerOrder(demoted, order); + expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); +}); + + +test("bare native ids and routed slugs match exactly, without suffix aliases", () => { + const rows: any[] = [ + { slug: "openai/gpt-5.6-sol", priority: 2 }, + { slug: "gpt-5.6-sol", priority: 9 }, + { slug: "other/gpt-5.6-sol", priority: 3 }, + ]; + applyFullModelPickerOrder(rows, ["gpt-5.6-sol", "openai/gpt-5.6-sol"]); + expect(rows.map(row => row.priority)).toEqual([1, 0, 5]); + expect(rows.map(row => row[SPAWN_PRIORITY_FIELD])).toEqual([2, 9, 3]); +}); diff --git a/tests/codex-integration/catalog-go-exact-efforts.test.ts b/tests/codex-integration/catalog-go-exact-efforts.test.ts new file mode 100644 index 0000000000..5fa4da816b --- /dev/null +++ b/tests/codex-integration/catalog-go-exact-efforts.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { deriveEntry, mergeCatalogEntriesForSync } from "../../src/codex/catalog/sync"; + +for (const template of [null, { slug: "gpt-5.6-sol", supported_reasoning_levels: [{ effort: "ultra" }] }]) { + test(`Go preserves exact configured efforts (${template ? "template" : "fallback"})`, () => { + for (const [id, efforts] of [ + ["glm-5.3", ["high", "max"]], + ["glm-5.3-flash", ["high", "max"]], + ["omen-alpha", ["high", "max"]], + ["deepseek-v4-flash-vision-exp", ["high", "max"]], + ["muse-spark-1.3-contributor", ["high", "xhigh"]], + ] as const) { + const entry = deriveEntry(template, `opencode-go/${id}`, "Go", 1, { + provider: "opencode-go", id, reasoningEfforts: [...efforts], defaultReasoningEffort: efforts[1], + }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual([...efforts]); + expect(entry.default_reasoning_level).toBe(efforts[1]); + } + }); +} + +test("other providers retain their existing virtual tiers", () => { + const entry = deriveEntry(null, "other/model", "Other", 1, { + provider: "other", id: "model", reasoningEfforts: ["high"], + }); + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "max", "ultra"]); +}); + +test("sync does not reintroduce max for Muse", () => { + const muse = deriveEntry(null, "opencode-go/muse-spark-1.3-contributor", "Muse", 1, { + provider: "opencode-go", id: "muse-spark-1.3-contributor", + reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", + }); + for (const [disk, fresh] of [[[muse], []], [[], [muse]]]) { + const entries = mergeCatalogEntriesForSync(disk, fresh, new Map(), [], false); + const entry = entries.find(e => e.slug === muse.slug)!; + expect(entry.supported_reasoning_levels.map((level: { effort: string }) => level.effort)).toEqual(["high", "xhigh"]); + } +}); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 37d8c69798..cc394a548d 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -5445,11 +5445,11 @@ describe("Codex catalog routed normalization", () => { const expected = [ { slug: "deepseek/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, { slug: "deepseek/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max", "ultra"] }, - { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, - { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, - { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, + { slug: "opencode-go/deepseek-v4-flash", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/deepseek-v4-pro", efforts: ["low", "high", "max"] }, + { slug: "opencode-go/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "opencode-go/glm-5.1", efforts: ["low", "medium", "high", "xhigh", "max"] }, + { slug: "opencode-go/glm-5", efforts: ["low", "medium", "high", "xhigh", "max"] }, { slug: "zai/glm-5.2", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, { slug: "zai/glm-5.2[1m]", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, { slug: "zhipu-bigmodel/glm-4.6", efforts: ["low", "medium", "high", "xhigh", "max", "ultra"] }, diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 8d3e2d9dc5..6e8b6a18c1 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -100,14 +100,13 @@ function installModeHintRuntime(supported = true): string { describe("catalog ultra (always-on)", () => { const routed = [{ id: "glm-5.2", provider: "opencode-go", reasoningEfforts: ["low", "medium", "high", "xhigh"] }]; - test("routed + old natives always advertise mock max AND ultra", () => { + test("Go keeps declared efforts while old natives retain mock tiers", () => { const entries = buildCatalogEntries(template(), ["gpt-5.5"], routed as never, [], false); const native = entries.find(e => e.slug === "gpt-5.5")!; const glm = entries.find(e => e.slug === "opencode-go/glm-5.2")!; expect(efforts(native)).toContain("ultra"); expect(efforts(native)).toContain("max"); - expect(efforts(glm)).toContain("ultra"); - expect(efforts(glm)).toContain("max"); // mock max: adapters/wire clamp keep it honest + expect(efforts(glm)).toEqual(["low", "medium", "high", "xhigh"]); }); test("gpt-5.6-sol keeps native ultra + max; luna has max but no native ultra (upstream ladder)", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7e7dd7d126..bd419c743f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -92,6 +92,8 @@ "bun-stream-caps.test.ts": "lib", "cancel-body-on-abort.test.ts": "server", "catalog-cursor-search.test.ts": "codex-integration", + "catalog-full-picker-order.test.ts": "codex-integration", + "catalog-go-exact-efforts.test.ts": "codex-integration", "catalog-input-modality-enum.test.ts": "codex-integration", "catalog-llamacpp-capabilities.test.ts": "codex-integration", "catalog-oauth-observation.test.ts": "codex-integration", From 8d8136daa7eb4d9ded27bcb716fab73deb7bdebd Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 03:44:33 +0200 Subject: [PATCH 2/4] fix(codex): reset retained picker order after provider outages --- .../docs/reference/configuration/providers.md | 5 ++- src/codex/catalog/sync.ts | 28 +++++++++++++ src/codex/convergence.ts | 1 + .../catalog-full-picker-order.test.ts | 41 ++++++++++++++++++- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e60245c4de..93ca9308b5 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -846,8 +846,9 @@ contracts. Go catalog rows preserve their configured reasoning efforts exactly, including during catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted -upstream values. Omen Alpha accepts `low`, `high`, and `max`; Muse Spark 1.3 Contributor -accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). +upstream values. Key these per-provider maps by upstream model ID, not the routed +`opencode-go/` catalog slug. Omen Alpha (`omen-alpha`) accepts `low`, `high`, +and `max`; Muse Spark 1.3 Contributor (`muse-spark-1.3-contributor`) accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). A configured subset can exclude the lower tiers. Other providers retain their existing behavior. For a native-first picker, include native ids in `modelPickerOrder` followed by the diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 897b24834f..6c141702b6 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -799,6 +799,7 @@ export interface ObservedCatalogMergeInput { readonly baseline: ReadonlyMap; readonly featured: readonly string[]; readonly modelPickerOrder?: readonly string[]; + readonly accountSelectors?: readonly string[]; readonly wsEnabled: boolean; readonly template: RawEntry | null; readonly disabledModels: ReadonlySet; @@ -832,6 +833,7 @@ export function mergeCatalogEntriesFromObservedState({ baseline, featured, modelPickerOrder = [], + accountSelectors = [], wsEnabled, template, disabledModels, @@ -1077,6 +1079,31 @@ export function mergeCatalogEntriesFromObservedState({ // remain outside provider ownership and survive unless a fresh row replaces their exact slug. return !isOcxAuthoredRoutedEntry(entry); }); + // Retained rows bypass the builder: undo a prior display override before applying the + // current routed-only order. Fresh rows already carry the current order and must stay intact. + if (!modelPickerOrder.some(slug => !slug.includes("/"))) { + const pickerRank = new Map(modelPickerOrder.map((slug, index) => [slug, index])); + const priorityStride = Math.max(accountSelectors.length, 1); + for (const entry of preservedRoutedEntries) { + const natural = entry[SPAWN_PRIORITY_FIELD]; + if (typeof natural === "number") { + entry.priority = natural; + delete entry[SPAWN_PRIORITY_FIELD]; + } + const slug = String(entry.slug); + if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; + const featuredRank = rank.get(slug); + if (featuredRank !== undefined) { + entry.priority = featuredRank * priorityStride; + continue; + } + const pickerIndex = pickerRank.get(slug); + if (pickerIndex !== undefined) { + entry[SPAWN_PRIORITY_FIELD] = typeof entry.priority === "number" ? entry.priority : 5; + entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; + } + } + } let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -1781,6 +1808,7 @@ function writeRetainedCatalogSync({ : []; catalog.models = mergeCatalogEntriesFromObservedState({ modelPickerOrder, + accountSelectors, catalogModels: catalogModelsForMerge, baselineCatalogModels: baselineCatalog?.models ?? [], routedEntries: goEntries, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index d1884bca71..8b30bb9eb2 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -343,6 +343,7 @@ function prepareCatalog( ); const mergedModels = mergeCatalogEntriesFromObservedState({ modelPickerOrder, + accountSelectors, catalogModels, baselineCatalogModels, routedEntries, diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index cf5c235cf5..dea1b286c8 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; +import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; test("native-first picker order preserves Go subagent ranks and is repeatable", () => { const rows: any[] = [ @@ -50,3 +50,42 @@ test("bare native ids and routed slugs match exactly, without suffix aliases", ( expect(rows.map(row => row.priority)).toEqual([1, 0, 5]); expect(rows.map(row => row[SPAWN_PRIORITY_FIELD])).toEqual([2, 9, 3]); }); + +test.each([ + { order: [] as string[] }, + { order: ["opencode-go/glm-5.3"] }, + { order: ["other/model", "opencode-go/glm-5.3"] }, +])("degraded discovery clears previous full ordering for %j", ({ order }) => { + for (const accountSelectors of [[], ["account-a", "account-b"]]) { + const slug = "opencode-go/glm-5.3"; + const fresh = (modelPickerOrder: string[]) => buildCatalogEntriesFromObservedState({ + template: null, gptSlugs: [], + goModels: [{ id: "glm-5.3", provider: "opencode-go", name: "GLM 5.3", reasoningEfforts: ["high", "max"] }], + featured: [], modelPickerOrder, wsEnabled: false, multiAgentMode: "default", + exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, + }); + const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: string[], degraded: boolean) => + mergeCatalogEntriesFromObservedState({ + catalogModels, routedEntries, modelPickerOrder, accountSelectors, + baselineCatalogModels: [], baseline: new Map(), featured: [], wsEnabled: false, + template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(["opencode-go"]), + degradedProviderNames: new Set(degraded ? ["opencode-go"] : []), + legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, + exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, + accountBoundEntries: [], + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "silent" }, + }); + const fullOrder = ["gpt-5.6-sol", slug]; + const previous = merge([], fresh(fullOrder), fullOrder, false); + const saved = structuredClone(previous); + const healthy = merge(previous, fresh(order), order, false); + const degraded = merge(previous, [], order, true); + const row = (entries: Record[]) => entries.find(entry => entry.slug === slug)!; + expect(row(degraded).priority).toBe(row(healthy).priority); + expect(row(degraded)[SPAWN_PRIORITY_FIELD]).toBe(row(healthy)[SPAWN_PRIORITY_FIELD]); + expect(merge(degraded, [], order, true)).toEqual(degraded); + expect(previous).toEqual(saved); + } +}); From 78d05fa58a3f7c9c332eabedad66c28a1e7772c8 Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 04:00:20 +0200 Subject: [PATCH 3/4] fix(codex): normalize picker orders and retain slug compatibility --- .../content/docs/fr/guides/model-ordering.md | 4 ++- .../src/content/docs/guides/model-ordering.md | 4 ++- src/codex/catalog/sync.ts | 21 ++++++++++----- src/types/config.ts | 3 ++- .../catalog-full-picker-order.test.ts | 27 ++++++++++++++----- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index c4b658d7b6..64408efa53 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -148,7 +148,9 @@ Pour ordonner tout le sélecteur, incluez un identifiant natif non qualifié : Les lignes indiquées apparaissent d’abord dans l’ordre du tableau, puis les lignes absentes selon leur priorité naturelle. La correspondance est exacte : `gpt-5.6-sol` et `openai/gpt-5.6-sol` désignent deux lignes distinctes. Pour une ligne qualifiée par un compte, -indiquez son identifiant complet, sélecteur inclus. +indiquez son identifiant complet, sélecteur inclus. Les formes brute et encodée du même +identifiant routé sont acceptées, avec priorité aux correspondances exactes. Les entrées +vides sont ignorées. ### Migration : identifiants natifs dans les listes existantes diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 5bcf221477..352c8ddb12 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -145,7 +145,9 @@ To order the complete picker, include a bare native id: Listed rows appear first in array order, followed by unlisted rows in natural priority order. Matching uses exact catalog ids: `gpt-5.6-sol` and `openai/gpt-5.6-sol` are separate -rows. Account-qualified rows need their exact selector-qualified id in the list. +rows. Raw and encoded spellings of the same routed id are also accepted, with exact +matches taking precedence. Empty entries are ignored. Account-qualified rows need +their selector-qualified id in the list. ### Migration note: native ids in existing orders diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 6c141702b6..a1252d7caf 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -781,14 +781,22 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< unsupportedNativeEntries: "drop", }); +/** Preserve exact-id precedence while accepting the existing raw/encoded slug spellings. */ +function modelPickerRank(order: readonly string[]): (slug: string) => number | undefined { + const exact = new Map(order.map((slug, index) => [slug, index])); + const equivalent = new Map(order.map((slug, index) => [slugEquivalenceKey(slug), index])); + return slug => exact.get(slug) ?? equivalent.get(slugEquivalenceKey(slug)); +} + /** A picker order containing native ids orders the whole list, without changing spawn ranks. */ export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { - if (!order.some(slug => !slug.includes("/"))) return; - const rank = new Map(order.map((slug, index) => [slug, index])); + const pickerOrder = order.filter(slug => slug.length > 0); + if (!pickerOrder.some(slug => !slug.includes("/"))) return; + const rankOf = modelPickerRank(pickerOrder); for (const entry of entries) { const natural = entry[SPAWN_PRIORITY_FIELD] ?? entry.priority ?? 9; entry[SPAWN_PRIORITY_FIELD] = natural; - entry.priority = rank.get(String(entry.slug)) ?? order.length + Number(natural); + entry.priority = rankOf(String(entry.slug)) ?? pickerOrder.length + Number(natural); } } @@ -1081,8 +1089,9 @@ export function mergeCatalogEntriesFromObservedState({ }); // Retained rows bypass the builder: undo a prior display override before applying the // current routed-only order. Fresh rows already carry the current order and must stay intact. - if (!modelPickerOrder.some(slug => !slug.includes("/"))) { - const pickerRank = new Map(modelPickerOrder.map((slug, index) => [slug, index])); + const pickerOrder = modelPickerOrder.filter(slug => slug.length > 0); + if (!pickerOrder.some(slug => !slug.includes("/"))) { + const rankOf = modelPickerRank(pickerOrder); const priorityStride = Math.max(accountSelectors.length, 1); for (const entry of preservedRoutedEntries) { const natural = entry[SPAWN_PRIORITY_FIELD]; @@ -1097,7 +1106,7 @@ export function mergeCatalogEntriesFromObservedState({ entry.priority = featuredRank * priorityStride; continue; } - const pickerIndex = pickerRank.get(slug); + const pickerIndex = rankOf(slug); if (pickerIndex !== undefined) { entry[SPAWN_PRIORITY_FIELD] = typeof entry.priority === "number" ? entry.priority : 5; entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; diff --git a/src/types/config.ts b/src/types/config.ts index 50e542927a..2593dba84f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -423,7 +423,8 @@ export interface OcxConfig { * Routed-only lists order non-featured routed rows; featured and native rows keep * their normal positions. Including a bare native id opts into ordering the complete * picker: listed ids appear first in array order, followed by unlisted rows in their - * natural priority order. Values are exact catalog ids. The separate natural spawn + * natural priority order. Exact catalog ids take precedence over equivalent raw/encoded + * routed ids; empty entries are ignored. The separate natural spawn * priority is preserved, so display order does not change subagent candidates. * Unset or empty leaves catalog priorities unchanged. */ diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index dea1b286c8..c57596628a 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -1,3 +1,4 @@ +import { routedSlug } from "../../src/providers/slug-codec"; import { expect, test } from "bun:test"; import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, applyFullModelPickerOrder, deriveEntry, mergeCatalogEntriesForSync, SPAWN_PRIORITY_FIELD } from "../../src/codex/catalog/sync"; @@ -53,19 +54,22 @@ test("bare native ids and routed slugs match exactly, without suffix aliases", ( test.each([ { order: [] as string[] }, + { order: ["", "opencode-go/glm-5.3"] }, + { order: [""] }, + { order: ["opencode-go/team/model"], modelId: "team/model" }, { order: ["opencode-go/glm-5.3"] }, { order: ["other/model", "opencode-go/glm-5.3"] }, -])("degraded discovery clears previous full ordering for %j", ({ order }) => { +])("degraded discovery clears previous full ordering for %j", ({ order, modelId = "glm-5.3" }) => { for (const accountSelectors of [[], ["account-a", "account-b"]]) { - const slug = "opencode-go/glm-5.3"; - const fresh = (modelPickerOrder: string[]) => buildCatalogEntriesFromObservedState({ + const slug = routedSlug("opencode-go", modelId); + const fresh = (modelPickerOrder: readonly string[]) => buildCatalogEntriesFromObservedState({ template: null, gptSlugs: [], - goModels: [{ id: "glm-5.3", provider: "opencode-go", name: "GLM 5.3", reasoningEfforts: ["high", "max"] }], + goModels: [{ id: modelId, provider: "opencode-go", displayName: "GLM 5.3", reasoningEfforts: ["high", "max"] }], featured: [], modelPickerOrder, wsEnabled: false, multiAgentMode: "default", exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, }); - const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: string[], degraded: boolean) => + const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean) => mergeCatalogEntriesFromObservedState({ catalogModels, routedEntries, modelPickerOrder, accountSelectors, baselineCatalogModels: [], baseline: new Map(), featured: [], wsEnabled: false, @@ -75,7 +79,7 @@ test.each([ legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true, accountBoundEntries: [], - policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "silent" }, + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, }); const fullOrder = ["gpt-5.6-sol", slug]; const previous = merge([], fresh(fullOrder), fullOrder, false); @@ -89,3 +93,14 @@ test.each([ expect(previous).toEqual(saved); } }); + + +test("full ordering ignores empty entries and accepts raw upstream ids with slashes", () => { + const slug = routedSlug("vendor", "team/model"); + const rows = [{ slug, priority: 1000 }, { slug: "gpt-5.6-sol", priority: 9 }]; + applyFullModelPickerOrder(rows, ["", "gpt-5.6-sol", "vendor/team/model"]); + expect(rows.map(row => row.priority)).toEqual([1, 0]); + const exact = [{ slug, priority: 5 }]; + applyFullModelPickerOrder(exact, ["gpt-5.6-sol", slug, "vendor/team/model"]); + expect(exact[0]!.priority).toBe(1); +}); From 09acfba64596011c308f0d9cbac070123bb9faeb Mon Sep 17 00:00:00 2001 From: voiys Date: Sat, 5 Sep 2026 15:10:54 +0200 Subject: [PATCH 4/4] fix(codex): refresh retained spawn ranks during discovery outages --- .../docs/reference/configuration/providers.md | 3 +- src/codex/catalog/sync.ts | 54 +++++++++---------- .../catalog-full-picker-order.test.ts | 23 ++++---- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 93ca9308b5..b17533a91c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -847,8 +847,9 @@ Go catalog rows preserve their configured reasoning efforts exactly, including d catalog sync. OpenCodex does not append synthetic `max` or `ultra` choices to these rows. Use `modelReasoningEfforts` and `modelDefaultReasoningEfforts` for each model's accepted upstream values. Key these per-provider maps by upstream model ID, not the routed -`opencode-go/` catalog slug. Omen Alpha (`omen-alpha`) accepts `low`, `high`, +`opencode-go/` catalog slug. For example, Omen Alpha (`omen-alpha`) accepts `low`, `high`, and `max`; Muse Spark 1.3 Contributor (`muse-spark-1.3-contributor`) accepts `minimal`, `low`, `medium`, `high`, and `xhigh` (Go endpoint validation, 2026-09-05). +See the [OpenCode Go model list](https://opencode.ai/docs/go/#models) for the current roster. A configured subset can exclude the lower tiers. Other providers retain their existing behavior. For a native-first picker, include native ids in `modelPickerOrder` followed by the diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index a1252d7caf..7b0be6e19b 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -520,7 +520,7 @@ export function buildCatalogEntriesFromObservedState({ // before. The spawn_agent candidate window is derived separately from SPAWN_PRIORITY_FIELD, so // this display reorder cannot change which rows are spawn candidates. const pickerOrder = Array.isArray(modelPickerOrder) - ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.length > 0) + ? modelPickerOrder.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : []; const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); const pickerOrderActive = pickerOrder.length > 0; @@ -790,7 +790,7 @@ function modelPickerRank(order: readonly string[]): (slug: string) => number | u /** A picker order containing native ids orders the whole list, without changing spawn ranks. */ export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { - const pickerOrder = order.filter(slug => slug.length > 0); + const pickerOrder = order.filter(slug => slug.trim().length > 0); if (!pickerOrder.some(slug => !slug.includes("/"))) return; const rankOf = modelPickerRank(pickerOrder); for (const entry of entries) { @@ -1000,7 +1000,7 @@ export function mergeCatalogEntriesFromObservedState({ finished.priority = nativePriority(slug, upstream.priority); return finished; } - const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); + const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); // Recompute spawn rank from current featured models, not a prior picker override. delete preserved[SPAWN_PRIORITY_FIELD]; // Older natives kept from disk still need the mock top tiers (max + ultra always @@ -1087,30 +1087,30 @@ export function mergeCatalogEntriesFromObservedState({ // remain outside provider ownership and survive unless a fresh row replaces their exact slug. return !isOcxAuthoredRoutedEntry(entry); }); - // Retained rows bypass the builder: undo a prior display override before applying the - // current routed-only order. Fresh rows already carry the current order and must stay intact. - const pickerOrder = modelPickerOrder.filter(slug => slug.length > 0); - if (!pickerOrder.some(slug => !slug.includes("/"))) { - const rankOf = modelPickerRank(pickerOrder); - const priorityStride = Math.max(accountSelectors.length, 1); - for (const entry of preservedRoutedEntries) { - const natural = entry[SPAWN_PRIORITY_FIELD]; - if (typeof natural === "number") { - entry.priority = natural; - delete entry[SPAWN_PRIORITY_FIELD]; - } - const slug = String(entry.slug); - if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; - const featuredRank = rank.get(slug); - if (featuredRank !== undefined) { - entry.priority = featuredRank * priorityStride; - continue; - } - const pickerIndex = rankOf(slug); - if (pickerIndex !== undefined) { - entry[SPAWN_PRIORITY_FIELD] = typeof entry.priority === "number" ? entry.priority : 5; - entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; - } + // Retained rows bypass the builder. Recompute managed spawn ranks from current config + // before either display-order mode; a saved display override is not current roster authority. + const pickerOrder = modelPickerOrder.filter(slug => slug.trim().length > 0); + const fullPickerOrder = pickerOrder.some(slug => !slug.includes("/")); + const rankOf = modelPickerRank(pickerOrder); + const featuredRankOf = modelPickerRank(featured); + const priorityStride = Math.max(accountSelectors.length, 1); + for (const entry of preservedRoutedEntries) { + const natural = entry[SPAWN_PRIORITY_FIELD]; + if (typeof natural === "number") { + entry.priority = natural; + delete entry[SPAWN_PRIORITY_FIELD]; + } + const slug = String(entry.slug); + if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue; + const featuredRank = featuredRankOf(slug); + entry.priority = featuredRank !== undefined + ? featuredRank * priorityStride + : (accountSelectors.length > 0 ? 1_000 : 0) + 5; + if (featuredRank !== undefined || fullPickerOrder) continue; + const pickerIndex = rankOf(slug); + if (pickerIndex !== undefined) { + entry[SPAWN_PRIORITY_FIELD] = entry.priority; + entry.priority = PICKER_ORDER_PRIORITY_BASE + pickerIndex * priorityStride; } } let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index c57596628a..1dbda27090 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -54,25 +54,30 @@ test("bare native ids and routed slugs match exactly, without suffix aliases", ( test.each([ { order: [] as string[] }, + { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], after: ["opencode-go/glm-5.3"] }, + { order: ["gpt-5.6-sol", "opencode-go/glm-5.3"], before: ["opencode-go/glm-5.3"], after: [] }, + { order: ["gpt-5.6-sol", "opencode-go/team/model"], modelId: "team/model", before: ["other/model", "opencode-go/team/model"], after: ["opencode-go/team/model", "other/model"] }, + { order: ["", "opencode-go/glm-5.3"] }, + { order: [" ", "opencode-go/glm-5.3"] }, { order: [""] }, { order: ["opencode-go/team/model"], modelId: "team/model" }, { order: ["opencode-go/glm-5.3"] }, { order: ["other/model", "opencode-go/glm-5.3"] }, -])("degraded discovery clears previous full ordering for %j", ({ order, modelId = "glm-5.3" }) => { +])("degraded discovery refreshes ranks and remains stable for %j", ({ order, modelId = "glm-5.3", before = [], after = [] }) => { for (const accountSelectors of [[], ["account-a", "account-b"]]) { const slug = routedSlug("opencode-go", modelId); - const fresh = (modelPickerOrder: readonly string[]) => buildCatalogEntriesFromObservedState({ + const fresh = (modelPickerOrder: readonly string[], featured: readonly string[] = []) => buildCatalogEntriesFromObservedState({ template: null, gptSlugs: [], goModels: [{ id: modelId, provider: "opencode-go", displayName: "GLM 5.3", reasoningEfforts: ["high", "max"] }], - featured: [], modelPickerOrder, wsEnabled: false, multiAgentMode: "default", + featured, modelPickerOrder, wsEnabled: false, multiAgentMode: "default", exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, }); - const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean) => + const merge = (catalogModels: Record[], routedEntries: Record[], modelPickerOrder: readonly string[], degraded: boolean, featured: readonly string[] = []) => mergeCatalogEntriesFromObservedState({ catalogModels, routedEntries, modelPickerOrder, accountSelectors, - baselineCatalogModels: [], baseline: new Map(), featured: [], wsEnabled: false, + baselineCatalogModels: [], baseline: new Map(), featured, wsEnabled: false, template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), gatheredProviderNames: new Set(["opencode-go"]), degradedProviderNames: new Set(degraded ? ["opencode-go"] : []), @@ -82,14 +87,14 @@ test.each([ policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, }); const fullOrder = ["gpt-5.6-sol", slug]; - const previous = merge([], fresh(fullOrder), fullOrder, false); + const previous = merge([], fresh(fullOrder, before), fullOrder, false, before); const saved = structuredClone(previous); - const healthy = merge(previous, fresh(order), order, false); - const degraded = merge(previous, [], order, true); + const healthy = merge(previous, fresh(order, after), order, false, after); + const degraded = merge(previous, [], order, true, after); const row = (entries: Record[]) => entries.find(entry => entry.slug === slug)!; expect(row(degraded).priority).toBe(row(healthy).priority); expect(row(degraded)[SPAWN_PRIORITY_FIELD]).toBe(row(healthy)[SPAWN_PRIORITY_FIELD]); - expect(merge(degraded, [], order, true)).toEqual(degraded); + expect(merge(degraded, [], order, true, after)).toEqual(degraded); expect(previous).toEqual(saved); } });