diff --git a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md index d4eff0de6f..10d33d01fb 100644 --- a/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md +++ b/devlog/_plan/260906_lane_b_catalog_stack/030_ordering.md @@ -4,7 +4,7 @@ Class: C3 cross-module catalog contract. One future PABCD cycle consumes this do ## Outcome and necessity -Configured canonical `opencode-go` efforts survive generation and retained sync without injected max/ultra. A nonblank bare catalog id in `modelPickerOrder` opts into complete-picker display ordering; exact ids outrank raw/encoded equivalents. Routed-only and empty configurations retain legacy behavior. Display sorting must leave the natural five-candidate subagent roster unchanged. Existing `applyReasoningLevels`, `slugEquivalenceKey`, `SPAWN_PRIORITY_FIELD`, and observed-state merge own these behaviors; reuse them, with no new catalog engine or provider roster. +Configured canonical `opencode-go` efforts survive generation and retained sync without injected max/ultra. A nonblank bare catalog id in `modelPickerOrder` opts into complete-picker display ordering; exact ids outrank raw/encoded equivalents. Routed-only and empty configurations retain legacy behavior. Display sorting must leave OpenCodex's natural-priority guidance candidates unchanged. Native Codex advertisements are a separate consumer and may follow the changed display order. Existing `applyReasoningLevels`, `slugEquivalenceKey`, `SPAWN_PRIORITY_FIELD`, and observed-state merge own these behaviors; reuse them, with no new catalog engine or provider roster. ## Current owners and amendment anchors @@ -47,7 +47,7 @@ Repeat at retained sync. Complete ordering preserves `entry[SPAWN_PRIORITY_FIELD | NEW `tests/codex-integration/catalog-full-picker-order.test.ts` | Bare native id + Go routed ids, then apply twice | Specified complete display order; unchanged stored natural ranks and byte-equivalent repeated result | | Same | Empty, whitespace-only, routed-only, raw slash upstream id plus encoded id | Legacy behavior; no whitespace activation; exact rank wins equivalence and no suffix aliasing | | Same | Start full order, switch to empty/routed-only during provider outage; change featured order, promote/demote; zero/two selectors and nonzero picker index | Healthy and degraded rows agree on both display and spawn rank; second merge is stable; input snapshot unmutated | -| Same plus existing `codex-v2-gate.test.ts` | Change picker only while retaining configured subagent roster; use v2 eligibility | Same five candidates and valid exact Go effort membership | +| Same plus existing `codex-v2-gate.test.ts` | Change picker only while retaining configured subagent roster; use v2 eligibility | Same five OpenCodex guidance candidates and valid exact Go effort membership | | Existing `tests/codex-integration/codex-catalog.test.ts` | Existing normalization/recovery fixtures | Existing native Reserve/exact ladders and account rows retain their contracts; align assertions only for intentional Go tier change | | Existing `tests/test-layout.test.ts`, `tests/test-layout-tooling.test.ts` | NEW file registration | Both explicit layout map and expected fixture contain both file names in codex-integration | @@ -663,3 +663,17 @@ index 114c699eaf..8dba5cfb55 100644 "catalog-oauth-observation.test.ts": "codex-integration", ```` + +## Consuming P refresh + +Parent preparation head is 29f98462c4a63cf217347c26668733169fd65736. Source #3571 remains OPEN at 0a935c5694229760c8c1cd5a62072107d8ae6696, and its full patch passes applicability on this parent. All four non-merge source commits identify voiys . The existing modelPickerOrder field survives config loading through the established root passthrough schema; no new persistence field is introduced. Preserve providerContextCapValues from 020. + +The initial roadmap listed source English/French edits, but six other existing model-ordering guides also contain the legacy native-order contract. MODIFY docs-site/src/content/docs/{ja,ko,ru,tr,zh-cn,zh-tw}/guides/model-ordering.md with the same complete-order opt-in, exact/equivalent matching, unchanged spawn roster and existing-list migration warning. Do not create new locales or alter unrelated routing semantics. The runtime/template output remains separately verified from any native client capture; a synthetic rendering must never be described as an actual client capture. + +Delegation: main carries the final source diff and owns SoT/commits; catalog worker supplies caller-level coverage and a captured generated-list comparison; docs worker owns the six translated guides; independent code reviewer checks priorities/retained paths; remote verifier uses isolated exact-head tests/docs plus a native client capture if the installed client can be run safely with synthetic state. No local test/build/typecheck and no real personal proxy/account calls. Final merge gates remain unchanged. + +## C evidence-driven contract clarification + +The independent native-consumer audit distinguishes three concepts: OpenCodex natural-priority guidance (must remain unchanged), native advertised five (can follow changed display priority), and exact-name override eligibility (not restricted to the advertised five). This preserves the already-recorded #1649 design while correcting the earlier unqualified wording. No wire rewriting or native-client patch is added. The source appendix above remains an immutable record of the original PR and is not a current universal native-advertisement guarantee. + +Native source d2d5b702 (local upstream checkout, not claimed to match binary0.153.4) shows both V1/exposedV2 using native priority; current valid generated before/after data demonstrates the expected displacement. The actual0.153.4 capture proves picker/data consumption only until a separate toolspec capture is obtained. V1 has no OCX preferred-roster injection; V2 guidance is conditional on catalog state. New production-writer fixture failures remain blockers for the natural-guidance criterion and cannot be waived by this wording correction. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md b/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md new file mode 100644 index 0000000000..7f68b1ca11 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/031_ordering_build.md @@ -0,0 +1,9 @@ +# Ordering carry build + +Replacement #3700 carries all four source #3571 commits through `0a935c5694229760c8c1cd5a62072107d8ae6696`, retaining voiys as author and coauthor. It preserves configured canonical OpenCode Go ladders in generation/retention and separates full-picker display order from natural spawn priority. + +Production-writer tests cover both convergence and retained sync, healthy/outage equivalence, refreshed featured ranks, idempotence and the same five eligible candidates. Source review found that the new merge paths lacked the builder's runtime normalization for the existing passthrough modelPickerOrder field. All three boundaries now share the same nonarray/nonstring/blank filtering while preserving significant ID spelling. Malformed-input production-writer cases and remote causal checks verify that repair. English/French source documentation is synchronized with the six other existing ordering guides and the catalog SoT. + +Parent #3695 was admin-merged on dev as `ab6762bdb35db24efbe1ceac77a1f9e5e6139616` after every actual CI producer succeeded. The aggregation-only ci job was still queued and explicitly recorded as an owner-authorized administrative exception; no actual test was bypassed. Independent reviews and remote backend/component/typecheck/docs/browser/red-green evidence passed. Source #3654 and issue #3651 were closed after dev ancestry proof, and #3700 was safely retargeted to dev. + +Final ordering review and exact-head remote/hosted execution are pending at this checkpoint. No local repository tests, typechecks or builds were run. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md b/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md new file mode 100644 index 0000000000..1d1538cf4e --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/032_ordering_repair.md @@ -0,0 +1,11 @@ +# Ordering check repair + +The first remote check of 1c2616bfd failed 14 new production-writer cases; no failing result was treated as a pass. Investigation separated fixture isolation from a production defect. + +The fixture now provides a runnable deterministic Codex command through forced refresh, asserts runtime identity, uses the current featured-roster migration marker, and checks effort arrays without mutating metadata. Full catalog equality and the same five OpenCodex guidance candidates remain required. + +Fresh row derivation could copy opencodex_spawn_priority from a previously ordered native template. Assigning a new featured priority did not replace that inherited private rank, so repeated healthy writes could change the guidance window. Fresh clones now clear that previous row's private marker; retained-row markers and reader behavior are unchanged. Direct dirty-template and repeated real-writer regressions cover the cause. Remote causal confirmation and reruns are required before closing this repair. + +The native-consumer audit also corrected an overbroad explanation: OpenCodex natural-priority guidance and native Codex's advertised five are separate. Native advertisement may follow display priority on V1 and exposed V2; exact-name override eligibility is not limited to that advertisement. This clarification preserves the existing #1649 design and does not waive the failing natural-guidance assertions. Current code comments, configuration reference and eight ordering guides now make the distinction explicit; the original source-diff appendix remains historical evidence. + +No local tests, builds or typechecks were run. Verification must use the repaired committed head and retain red/green, runtime identity and teardown evidence. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md b/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md new file mode 100644 index 0000000000..ea2d9e7110 --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/033_ordering_control.md @@ -0,0 +1,7 @@ +# Matched retained-discovery control + +After the template-rank correction, the direct regression and repeated production-writer guidance cases passed remotely. Ten malformed-order cases still compared a static healthy catalog (14 rows in that snapshot) against a live/degraded catalog (36 rows). Maintained Go metadata augmentation is skipped for liveModels:false and enabled for liveModels:true, so changing that setting admitted additional rows independently of picker-order validity. + +The test now restores identical catalog/cache bytes before a valid-filtered retained control and a malformed retained run. Both use the same live/empty-model/failure settings; only picker order differs. It still compares complete model arrays, the full guidance roster and all original fixture models' exact effort ladders. Healthy valid-versus-malformed equality is retained. No registry model count is hardcoded and no production fallback behavior is changed. + +Previous failed outputs remain evidence. The revised counterfactual requires an exact-head remote rerun before a success claim. No local tests were run. diff --git a/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md b/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md new file mode 100644 index 0000000000..4b220a619d --- /dev/null +++ b/devlog/_plan/260906_lane_b_catalog_stack/034_verification_followup.md @@ -0,0 +1,3 @@ +# Verification follow-up + +The ordering CI run reported an unrelated Lab supervision test failure. A bounded verification prerequisite is reviewed separately from the catalog change. Detailed pre-publication analysis and the implementation plan remain in ignored scratch under the repository security-working-note policy. Product limits and existing assertions are not relaxed. The original ordering branch and failed outputs remain preserved; no success is claimed at this planning checkpoint. 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..ada196c8fc 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,11 +134,47 @@ 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. -`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 -ligne déplacée pour la sélection des sous-agents. `disabledModels` et `selectedModels` de chaque fournisseur +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. 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 + +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 ; le calcul des candidats pour les consignes d’OpenCodex selon les priorités naturelles reste inchangé. + +`modelPickerOrder` préserve le calcul d’OpenCodex qui retient jusqu’à cinq candidats préférés +pour les consignes aux sous-agents, selon leur priorité naturelle. Chaque ligne déplacée conserve +cette priorité séparément de son `priority` natif ; changer uniquement l’ordre du sélecteur ne doit +pas modifier ce calcul. Cela ne restreint pas l’admissibilité d’un modèle désigné par son nom exact : +la liste annoncée n’est pas une liste d’autorisation. Les contraintes d’authentification, de modèle, +d’effort et de backend restent applicables. + +Codex natif utilise le `priority` natif pour annoncer les cinq premiers modèles admissibles et +visibles dans le sélecteur via `spawn_agent`, en V1 et en V2 lorsque les substitutions de modèle +sont exposées. Ces cinq modèles peuvent donc changer avec l’ordre du sélecteur, même si les +candidats préférés d’OpenCodex restent identiques. La V1 ne reçoit aucune injection de liste +préférée d’OpenCodex. La V2 peut recevoir en plus des consignes fondées sur les priorités naturelles +si l’état du catalogue client le permet ; ces consignes ne réordonnent pas la liste annoncée par +l’outil natif. + +`disabledModels` et `selectedModels` de chaque fournisseur restent des champs de visibilité, pas des contrôles d’ordre. Il n’existe aucun paramètre distinct `modelOrder`, `providerOrder` ou de carte de priorité. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 696f631a58..79e74e8e2d 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,10 +133,41 @@ 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. -`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 -sub-agent selection. `disabledModels` and each provider's `selectedModels` remain visibility fields, +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. 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 + +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; OpenCodex's natural-priority guidance candidate calculation is unchanged. + +`modelPickerOrder` preserves OpenCodex's natural-priority calculation of up to five preferred +candidates for subagent guidance. Each moved row retains its natural priority separately from +its native `priority`; changing picker order alone must not change that OpenCodex calculation. +It does not restrict eligibility for an exact-name model override: the native advertised list +is not an allowlist, and existing authentication, model/effort and backend constraints still apply. + +Native Codex uses native `priority` to select the first five eligible picker-visible models +advertised by `spawn_agent` on V1 and on V2 when model overrides are exposed. Those advertised +five may therefore change with picker order, even when OpenCodex's preferred candidates remain +unchanged. V1 receives no OpenCodex preferred-roster injection. V2 may additionally receive +OpenCodex's natural-priority guidance when the client catalog state permits; that guidance does +not reorder the native tool's advertised list. + +`disabledModels` and each provider's `selectedModels` remain visibility fields, not ordering controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index 6108c08771..d9c782b3fb 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -22,6 +22,8 @@ account-qualified native id にはその selector の `i * N + j` が使用さ selector がない場合の priority は次のとおりです。 +以下の優先順位表と例は、ピッカー全体の並び替えを有効にしていない場合のものです。 + | カタログ項目 | Priority | 根拠 | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` から `4`) | `src/codex/catalog/sync.ts` の featured rank map | @@ -112,6 +114,44 @@ account selector がある場合、5 項目の制限は bare native の選択が 場合は 1 つの bare native が複数の selector-qualified 行に展開されるため、設定した選択肢と公開 される行は必ずしも一対一ではありません。 -現在 `OcxConfig` には一般 `modelOrder`、`providerOrder`、priority map 設定はありません。サポートされるソート -フィールドは `subagentModels` です。`disabledModels` と各プロバイダーの `selectedModels` は公開 -フィールドです。そのため残りのピッカー順序を変えるには設定変更ではなくコード動作の変更が必要です。 +`modelPickerOrder` はピッカーの表示順だけを指定します。ルーティング ID +`/` だけを指定した場合、一覧にある非 featured 行は指定順の表示帯 +(`1000 + i`)に並びます。一覧にないルーティング行は通常の優先順位を保ち、この表示帯より前に +残ります。`subagentModels` にも含まれる行は featured の優先順位を保ち、ネイティブ行の位置も変わりません。 +相対的な順序を指定したいルーティング行はすべて一覧に含めてください。 + +ピッカー全体を並び替えるには、`/` を含まない、空でも空白だけでもないカタログ ID +(例:`gpt-5.6-sol`)を含めます。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +指定した行が配列の順序で先頭に並び、未指定の行は本来の優先順位でその後に続きます。 +カタログ ID は完全一致で照合します。`gpt-5.6-sol` と `openai/gpt-5.6-sol` は別の行です。 +同じルーティング ID の未エンコード表記とエンコード済み表記も照合できますが、完全一致が優先されます。 +空の項目と空白だけの項目は無視します。アカウント別の行には selector を含む完全な ID を指定してください。 + +### 移行時の注意:既存の一覧に含まれるネイティブ ID + +以前は `modelPickerOrder` 内の bare native ID が無視されていました。既存の一覧にこのような ID が +あると、今後は featured 行を含むピッカー全体の並び替えが有効になります。従来のルーティング行だけの +動作を保つには、bare ID を取り除いてください。未設定、空、空白だけ、ルーティング ID だけの一覧は +従来どおり動作します。 + +`modelPickerOrder` は、自然な優先順位から最大 5 件の推奨候補を選ぶ OpenCodex の +サブエージェント向けガイダンス計算を保持します。移動した各行の自然な優先順位はネイティブの +`priority` とは別に残り、ピッカー順だけを変えてもこの計算結果は変わりません。 +正確なモデル名を指定する override の利用資格を制限するものでもありません。広告リストは許可リストではなく、 +認証、モデル、effort、バックエンドに関する既存の制約は引き続き適用されます。 + +ネイティブ Codex はネイティブの `priority` に従い、利用可能でピッカーに表示されるモデルの先頭 5 件を +`spawn_agent` に広告します。これは V1 と、モデル override を公開している V2 に当てはまります。 +そのため、OpenCodex の推奨候補が同じでも、ピッカー順を変えると広告される 5 件は変わる場合があります。 +V1 には OpenCodex の推奨候補リストを注入しません。V2 にはクライアントのカタログ状態が許す場合に +自然な優先順位に基づくガイダンスを追加できますが、ネイティブツールの広告リストは並び替えません。 + +`disabledModels` と各プロバイダーの `selectedModels` は +表示の有無を制御するフィールドです。別の `modelOrder`、`providerOrder`、priority map 設定はありません。 diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index 3c960b1840..365ea476fc 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -22,6 +22,8 @@ native id는 해당 selector의 `i * N + j`를 사용합니다. Codex는 계속 selector가 없을 때의 priority는 다음과 같습니다. +아래 우선순위 표와 예시는 선택기 전체 정렬을 켜지 않은 경우를 설명합니다. + | 카탈로그 항목 | Priority | 근거 | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0`부터 `4`) | `src/codex/catalog/sync.ts`의 featured rank map | @@ -111,6 +113,43 @@ account selector가 있으면 bare native 선택이 selector-qualified 그룹으 최대 5개만 사용하세요. account selector가 있으면 bare native 하나가 여러 selector-qualified 행으로 확장될 수 있으므로 설정 항목과 노출 행이 항상 일대일로 대응하지는 않습니다. -현재 `OcxConfig`에는 일반 `modelOrder`, `providerOrder`, priority map 설정이 없습니다. 지원되는 정렬 -필드는 `subagentModels`입니다. `disabledModels`와 각 프로바이더의 `selectedModels`는 노출 -필드입니다. 따라서 나머지 선택기 순서를 바꾸려면 설정 수정이 아니라 코드 동작 변경이 필요합니다. +`modelPickerOrder`는 선택기의 표시 순서만 지정합니다. 라우팅 ID인 `/`만 +넣으면 목록에 있는 비 featured 행이 지정 순서대로 별도 표시 구간(`1000 + i`)에 배치됩니다. +목록에 없는 라우팅 행은 원래 우선순위를 유지하므로 이 구간보다 앞에 남습니다. `subagentModels`에도 +들어 있는 행은 featured 우선순위를 유지하고, 네이티브 행도 원래 위치를 유지합니다. +상대적 순서를 정할 라우팅 행은 모두 목록에 넣어야 합니다. + +선택기 전체를 정렬하려면 `/`가 없는 카탈로그 ID를 하나 이상 넣으세요. `gpt-5.6-sol`처럼 실제 문자가 +있는 bare ID여야 하며, 빈 문자열이나 공백만 있는 항목은 해당하지 않습니다. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +지정한 행이 배열 순서대로 먼저 나오고, 나머지 행은 원래 우선순위대로 뒤에 나옵니다. +카탈로그 ID는 정확히 일치하는 값으로 찾습니다. `gpt-5.6-sol`과 `openai/gpt-5.6-sol`은 서로 다른 행입니다. +같은 라우팅 ID의 원문 표기와 인코딩 표기도 허용하지만, 정확히 일치하는 항목이 우선합니다. +빈 항목과 공백뿐인 항목은 무시합니다. 계정별 행을 지정할 때는 selector가 포함된 전체 ID를 써야 합니다. + +### 마이그레이션 주의: 기존 목록에 들어 있는 네이티브 ID + +이전에는 `modelPickerOrder`의 bare native ID를 무시했습니다. 이제 기존 목록에 이런 ID가 있으면 +featured 행을 포함한 선택기 전체 정렬이 활성화됩니다. 기존 라우팅 전용 동작을 유지하려면 bare ID를 +제거하세요. 미설정 목록, 빈 목록, 공백만 있는 목록, 라우팅 ID만 있는 목록은 기존 동작을 유지합니다. + +`modelPickerOrder`는 자연 우선순위로 최대 5개의 선호 후보를 고르는 OpenCodex의 +서브에이전트 안내용 계산을 보존합니다. 이동한 각 행의 자연 우선순위는 네이티브 `priority`와 별도로 +남으며, 선택기 순서만 바꿔서는 이 계산 결과가 달라지지 않습니다. 정확한 모델 이름으로 override를 +지정할 자격도 제한하지 않습니다. 광고 목록은 허용 목록이 아니며, 기존 인증·모델·effort·백엔드 제약은 +그대로 적용됩니다. + +네이티브 Codex는 네이티브 `priority` 순서에서 사용 가능하고 선택기에 표시되는 모델 중 앞의 5개를 +`spawn_agent`에 광고합니다. V1과 모델 override를 공개하는 V2가 여기에 해당합니다. +따라서 OpenCodex의 선호 후보가 그대로여도 선택기 순서에 따라 광고되는 5개는 달라질 수 있습니다. +V1에는 OpenCodex의 선호 후보 목록을 주입하지 않습니다. V2는 클라이언트 카탈로그 상태가 허용할 때 +자연 우선순위 기반 안내를 추가로 받을 수 있지만, 이 안내가 네이티브 도구의 광고 목록을 재정렬하지는 않습니다. + +`disabledModels`와 각 공급자의 `selectedModels`는 노출 여부를 정하는 필드입니다. +별도의 `modelOrder`, `providerOrder`, priority map 설정은 없습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 600560627d..451bb293dc 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -812,6 +812,25 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 } ``` +## 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. Key these per-provider maps by upstream model ID, not the routed +`opencode-go/` catalog slug. For example, a configured `["high", "max"]` list +remains exactly those two choices; a configured `["high", "xhigh"]` list does not gain `max`. +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 +routed ids. This orders the complete picker while preserving OpenCodex's separate natural-priority +guidance calculation. Native Codex's advertised five follow picker priority and may change; +exact-name override eligibility is not limited to that advertisement. 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. + ## OpenCode Go session and agent messages With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index d5a3683834..290194fcd2 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -25,6 +25,8 @@ selector-групп. Приоритеты без селекторов: +Таблицы приоритетов и пример ниже описывают режим без сортировки всего селектора. + | Запись каталога | Priority | Источник | | --- | ---: | --- | | `subagentModels[i]` | `i` (от `0` до `4`) | Карта рангов избранных в `src/codex/catalog/sync.ts` | @@ -121,7 +123,50 @@ native-выбора в selector-qualified группы. развернуться в несколько selector-qualified строк, поэтому число настроенных вариантов и объявляемых строк не обязательно совпадает. -Общих настроек `modelOrder`, `providerOrder` или карты приоритетов в `OcxConfig` сейчас нет. -Поддерживаемое поле порядка — `subagentModels`; `disabledModels` и `selectedModels` каждого -провайдера — поля видимости. Изменение остальной части порядка селектора потребовало бы изменения -поведения на уровне кода, а не правки конфигурации. +`modelPickerOrder` управляет только порядком отображения в селекторе. Если список содержит лишь +маршрутизируемые ID `/`, указанные строки вне избранных попадают в отдельный +диапазон отображения (`1000 + i`) в порядке списка. Неуказанные маршрутизируемые строки сохраняют +обычный приоритет и остаются перед этим диапазоном. Строки из `subagentModels` сохраняют приоритет +избранных, а нативные строки — обычные позиции. Укажите все маршрутизируемые строки, относительный +порядок которых нужно задать. + +Чтобы сортировать весь селектор, включите хотя бы один непустой ID каталога без `/`, например +`gpt-5.6-sol`. Строка из одних пробелов не включает этот режим. + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +Указанные строки идут первыми в порядке массива, затем неуказанные — по исходному приоритету. +Сопоставление учитывает точный ID каталога: `gpt-5.6-sol` и `openai/gpt-5.6-sol` — разные строки. +Допускаются исходная и закодированная формы одного маршрутизируемого ID, но точное совпадение +имеет приоритет над эквивалентным. Пустые строки и строки из одних пробелов игнорируются. +Для строки конкретного аккаунта укажите полный ID с селектором. + +### Миграция: нативные ID в существующих списках + +Раньше нативные ID без префикса в `modelPickerOrder` игнорировались. Теперь такой ID в существующем +списке включает сортировку всего селектора, включая избранные строки. Удалите ID без префикса, +чтобы сохранить прежнее поведение только для маршрутизируемых строк. Отсутствующий или пустой +список, список из одних пробельных строк и список только с маршрутизируемыми ID работают как раньше. + +`modelPickerOrder` сохраняет расчёт OpenCodex, который выбирает до пяти предпочтительных +кандидатов для рекомендаций субагентам по исходному приоритету. У каждой перемещённой строки +этот приоритет хранится отдельно от нативного `priority`; изменение только порядка селектора +не должно менять результат этого расчёта. Оно также не ограничивает допустимость переопределения +модели по точному имени: объявленный список не является списком разрешений. Существующие +ограничения аутентификации, модели, effort и бэкенда продолжают действовать. + +Нативный Codex использует нативный `priority`, чтобы объявить через `spawn_agent` первые пять +допустимых моделей, видимых в селекторе. Это относится к V1 и к V2 с открытыми переопределениями +моделей. Поэтому объявленные пять моделей могут меняться вместе с порядком селектора, даже если +предпочтительные кандидаты OpenCodex не изменились. В V1 OpenCodex не внедряет список +предпочтительных моделей. V2 может дополнительно получать рекомендации по исходным приоритетам, +если состояние каталога клиента это допускает; эти рекомендации не меняют порядок списка, +объявленного нативным инструментом. + +`disabledModels` и `selectedModels` каждого провайдера +по-прежнему управляют видимостью. Отдельных настроек `modelOrder`, `providerOrder` или карты +приоритетов нет. diff --git a/docs-site/src/content/docs/tr/guides/model-ordering.md b/docs-site/src/content/docs/tr/guides/model-ordering.md index 54a19f22d4..336513225a 100644 --- a/docs-site/src/content/docs/tr/guides/model-ordering.md +++ b/docs-site/src/content/docs/tr/guides/model-ordering.md @@ -28,6 +28,8 @@ görünen ilk beş satırı tanıtır. İlgili seçicisiz öncelikler şunlardır: +Aşağıdaki öncelik tabloları ve örnek, seçicinin tamamını sıralama modu kapalıyken geçerlidir. + | Katalog girdisi | Öncelik | Kaynak | | --- | ---: | --- | | `subagentModels[i]` | `i` (`0` - `4`) | `src/codex/catalog/sync.ts` içindeki öne çıkan sıra haritası | @@ -134,10 +136,48 @@ kimlik kullanın. Hesap seçicileriyle tek bir yalın yerel seçenek birden çok seçici nitelikli katalog satırına genişleyebilir, bu nedenle yapılandırılmış seçimler ve tanıtılan satırlar birebir olmak zorunda değildir. -Şu anda `OcxConfig` içinde genel bir `modelOrder`, `providerOrder` veya öncelik -haritası ayarı yoktur. Desteklenen sıralama alanı `subagentModels`'dır; -`disabledModels` ve her sağlayıcının `selectedModels` alanı görünürlük -alanlarıdır. Kalan seçici sırasını değiştirmek bir yapılandırma düzenlemesinden -ziyade kod düzeyinde bir davranış değişikliği gerektirir. +`modelPickerOrder` yalnızca seçicideki görüntüleme sırasını belirler. Liste yalnızca yönlendirilmiş +`/` kimlikleri içeriyorsa, listelenen ve öne çıkarılmamış satırlar ayrı bir +görüntüleme aralığında (`1000 + i`) liste sırasıyla yer alır. Listelenmeyen yönlendirilmiş satırlar +normal önceliklerini korur ve bu aralıktan önce kalır. `subagentModels` içindeki satırlar öne çıkan +önceliklerini, yerel satırlar da normal konumlarını korur. Göreli sırasını belirlemek istediğiniz +tüm yönlendirilmiş satırları listeleyin. + +Seçicinin tamamını sıralamak için `gpt-5.6-sol` gibi `/` içermeyen en az bir yalın katalog kimliği +ekleyin. Boş veya yalnızca boşluk içeren girdiler bu modu etkinleştirmez. +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` +Listelenen satırlar önce dizi sırasıyla, listelenmeyenler ise ardından doğal öncelik sırasıyla gelir. +Eşleştirme tam katalog kimliğini kullanır: `gpt-5.6-sol` ile `openai/gpt-5.6-sol` farklı satırlardır. +Aynı yönlendirilmiş kimliğin ham ve kodlanmış yazımları da kabul edilir; tam eşleşme, eşdeğer +eşleşmeden önceliklidir. Boş ve yalnızca boşluk içeren girdiler yok sayılır. Hesaba özel satırlar +için seçiciyi içeren tam kimliği yazın. + +### Geçiş uyarısı: mevcut listelerdeki yerel kimlikler + +Önceden `modelPickerOrder` içindeki yalın yerel kimlikler yok sayılıyordu. Mevcut bir listede böyle +bir kimlik bulunması artık öne çıkan satırlar dahil tüm seçicinin sıralanmasını etkinleştirir. +Eski, yalnızca yönlendirilmiş satırlara uygulanan davranışı korumak için yalın kimlikleri kaldırın. +Tanımlanmamış, boş, yalnızca boşluk girdileri içeren veya yalnızca yönlendirilmiş kimliklerden oluşan +listeler önceki davranışlarını korur. + +`modelPickerOrder`, OpenCodex'in alt ajan rehberliği için doğal önceliğe göre en fazla beş tercih +edilen adayı seçen hesaplamasını korur. Taşınan her satırın doğal önceliği, yerel `priority` değerinden +ayrı saklanır; yalnızca seçici sırasını değiştirmek bu hesaplamanın sonucunu değiştirmemelidir. +Tam model adıyla geçersiz kılma uygunluğunu da kısıtlamaz: tanıtılan liste bir izin listesi değildir. +Mevcut kimlik doğrulama, model, effort ve arka uç kısıtlamaları geçerliliğini korur. + +Yerel Codex, `spawn_agent` içinde tanıtılacak beş modeli yerel `priority` sırasındaki uygun ve +seçicide görünür modellerden seçer. Bu, V1 ve model geçersiz kılmalarının sunulduğu V2 için geçerlidir. +Dolayısıyla OpenCodex'in tercih edilen adayları değişmese bile, tanıtılan beş model seçici sırasıyla +birlikte değişebilir. V1'e OpenCodex tercih listesi enjekte edilmez. V2, istemci katalog durumu izin +verdiğinde ek olarak doğal önceliğe dayalı OpenCodex rehberliği alabilir; bu rehberlik yerel aracın +tanıttığı listeyi yeniden sıralamaz. + +`disabledModels` ve her sağlayıcının `selectedModels` alanı +görünürlüğü denetler. Ayrı bir `modelOrder`, `providerOrder` veya öncelik haritası ayarı yoktur. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index 2f229176cd..a07d4cf01f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -20,6 +20,8 @@ priority 为 `i * N + j` 的 selector 行,其中 `j` 是从 0 开始的 select 没有 selector 时的相关 priority 如下: +以下优先级表和示例适用于未开启完整选择器排序的情况。 + | 目录条目 | Priority | 来源 | | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | @@ -106,6 +108,40 @@ subagentModels = [ id 请勿超过五个。存在账户 selector 时,一个裸原生选项可能展开为多个 selector-qualified 行,因此 已配置的选项与公布的行不一定一一对应。 -目前 `OcxConfig` 中没有通用的 `modelOrder`、`providerOrder` 或 priority map 设置。受支持的排序 -字段是 `subagentModels`;`disabledModels` 和各 provider 的 `selectedModels` 都是可见性字段。 -因此,要更改选择器其余部分的顺序,需要修改代码行为,而不是调整配置。 +`modelPickerOrder` 只控制选择器的显示顺序。如果列表只有路由 ID `/`, +其中未置顶的行会按列表顺序进入独立的显示区间(`1000 + i`)。未列出的路由行保留原有优先级, +因此仍排在该区间之前。同时列在 `subagentModels` 中的行保留置顶优先级,原生行也保持原有位置。 +需要控制相对顺序的路由行都应列入列表。 + +要对整个选择器排序,请加入至少一个不含 `/` 的裸目录 ID,例如 `gpt-5.6-sol`。 +空字符串或只有空白的条目不会启用此模式。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +列出的行按数组顺序排在最前面,未列出的行随后按原有优先级排列。匹配使用精确的目录 ID: +`gpt-5.6-sol` 和 `openai/gpt-5.6-sol` 是不同的行。同一路由 ID 的原始写法和编码写法也可匹配, +但精确匹配优先于等价匹配。空条目和只有空白的条目会被忽略。账户限定行必须使用包含 selector 的完整 ID。 + +### 迁移提醒:现有列表中的原生 ID + +以前 `modelPickerOrder` 中的裸原生 ID 会被忽略。现在,现有列表只要包含这样的 ID,就会启用 +整个选择器的排序,包括置顶行。要保持以前只调整路由行的行为,请移除裸 ID。 +未设置、空列表、只有空白条目的列表以及只有路由 ID 的列表都保留原有行为。 + +`modelPickerOrder` 保留 OpenCodex 按原有优先级计算最多五个首选候选项的规则,供子代理指导使用。 +每个移动行的原有优先级与原生 `priority` 分开保存;仅改变选择器顺序不得改变这一计算结果。 +它也不会限制通过精确模型名称指定 override 的资格:公布的列表不是允许列表,现有的认证、模型、 +effort 和后端限制仍然适用。 + +原生 Codex 按原生 `priority` 排序,从符合条件且在选择器中可见的模型中取前五个,公布在 +`spawn_agent` 中。这适用于 V1,以及公开模型 override 的 V2。因此,即使 OpenCodex 的首选候选项 +不变,原生公布的五个模型仍可能随选择器顺序改变。V1 不接收 OpenCodex 注入的首选模型列表。 +V2 在客户端目录状态允许时,可以额外接收基于原有优先级的 OpenCodex 指导;这些指导不会重排 +原生工具公布的列表。 + +`disabledModels` 和各提供商的 `selectedModels` 仍是可见性字段。没有独立的 `modelOrder`、 +`providerOrder` 或优先级映射设置。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md index efd505db85..e0946db620 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md @@ -14,6 +14,8 @@ Codex 的 models-manager 按 `priority` 升序排列選擇器中可見的目錄 因此,opencodex 透過分配更低的 priority 控制置頂位置,而不依賴陣列位置。相關 priority 如下: +以下優先級表與範例適用於未啟用完整選擇器排序的情況。 + | 目錄條目 | Priority | 來源 | | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | @@ -92,10 +94,43 @@ subagentModels = [ ## 更改順序 -自訂開頭模型順序的唯一受支援方式是重新排列 `subagentModels`。你可以在儀表板的 +要調整 `spawn_agent` 候選模型的順序,請重新排列 `subagentModels`。你可以在儀表板的 **Sub-agents** 頁面或 opencodex 設定中修改它。該列表最多接受五個模型,其陣列順序有實際意義。 -目前 `OcxConfig` 中沒有通用的 `modelOrder`、`providerOrder` 或 priority map 設定。受支援的排序 -欄位是 `subagentModels`(`src/types.ts:238-246`);`disabledModels` 和各 provider 的 -`selectedModels` 都是可見性欄位(`src/types.ts:276-282`、`src/types.ts:439-446`)。因此,要更改 -選擇器其餘部分的順序,需要修改程式碼行為,而不是調整設定。 +`modelPickerOrder` 只控制選擇器的顯示順序。如果列表只有路由 ID `/`, +其中未置頂的列會按列表順序進入獨立的顯示區間(`1000 + i`)。未列出的路由列保留原有優先級, +因此仍排在該區間之前。同時列在 `subagentModels` 中的列保留置頂優先級,原生列也維持原有位置。 +需要控制相對順序的路由列都應列入列表。 + +要對整個選擇器排序,請加入至少一個不含 `/` 的裸目錄 ID,例如 `gpt-5.6-sol`。 +空字串或只有空白的項目不會啟用此模式。 + +```json +{ + "modelPickerOrder": ["gpt-5.6-sol", "opencode-go/glm-5.3"] +} +``` + +列出的項目按陣列順序排在最前面,未列出的項目隨後按原有優先級排列。比對使用精確的目錄 ID: +`gpt-5.6-sol` 和 `openai/gpt-5.6-sol` 是不同的列。同一路由 ID 的原始寫法和編碼寫法也可比對, +但精確比對優先於等價比對。空項目和只有空白的項目會被忽略。帳號限定列必須使用包含 selector 的完整 ID。 + +### 遷移提醒:現有列表中的原生 ID + +以前 `modelPickerOrder` 中的裸原生 ID 會被忽略。現在,現有列表只要包含這類 ID,就會啟用 +整個選擇器的排序,包括置頂列。要保留以前只調整路由列的行為,請移除裸 ID。 +未設定、空列表、只有空白項目的列表以及只有路由 ID 的列表都保留原有行為。 + +`modelPickerOrder` 保留 OpenCodex 按原有優先級計算最多五個偏好候選項的規則,供子代理指引使用。 +每個移動列的原有優先級與原生 `priority` 分開儲存;僅改變選擇器順序不得改變這項計算結果。 +它也不會限制以精確模型名稱指定 override 的資格:公佈的列表不是允許清單,既有的驗證、模型、 +effort 與後端限制仍然適用。 + +原生 Codex 按原生 `priority` 排序,從符合條件且在選擇器中可見的模型中取前五個,公佈在 +`spawn_agent` 中。這適用於 V1,以及開放模型 override 的 V2。因此,即使 OpenCodex 的偏好候選項 +不變,原生公佈的五個模型仍可能隨選擇器順序改變。V1 不接收 OpenCodex 注入的偏好模型列表。 +V2 在用戶端目錄狀態允許時,可以額外接收基於原有優先級的 OpenCodex 指引;這些指引不會重排 +原生工具公佈的列表。 + +`disabledModels` 和各供應商的 `selectedModels` 仍是可見性欄位。沒有獨立的 `modelOrder`、 +`providerOrder` 或優先級對應表設定。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 045707d947..e816ca4b72 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", @@ -718,6 +720,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3f5f472baf..3ed5597d31 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -88,15 +88,15 @@ import { export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; // Base for config.modelPickerOrder display priorities (#1649). modelPickerOrder is a DISPLAY-ONLY -// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but never the -// spawn_agent candidate window. The window is derived from SPAWN_PRIORITY_FIELD (the natural -// priority captured before the override), so display order and spawn candidates are decoupled. +// reordering of the Codex model picker: it rewrites a row's Codex-visible `priority` but not +// OpenCodex's natural-priority guidance window. Native Codex advertisements still follow the +// visible priority and can differ from that guidance window. export const PICKER_ORDER_PRIORITY_BASE = 1_000; -// OpenCodex-private catalog field: the spawn_agent candidate priority a row would have WITHOUT +// OpenCodex-private catalog field: the guidance candidate priority a row would have WITHOUT // modelPickerOrder. Codex ignores unknown catalog fields (same as opencodex_catalog_kind), so this -// is invisible to Codex; effectiveSubagentRoster reads it so a display reorder cannot change which -// rows are spawn_agent candidates. Absent on rows modelPickerOrder did not move. +// is invisible to Codex; effectiveSubagentRoster reads it to keep OpenCodex guidance candidates +// independent of display order. It does not freeze native advertisements. Absent on unmoved rows. export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority"; export type SpawnAgentSurface = "v1" | "v2"; @@ -154,7 +154,9 @@ export interface SubagentRosterExclusion { } export interface EffectiveSubagentRoster { + /** OpenCodex's natural-priority guidance projection, not captured native tool text. */ candidates: EffectiveSubagentModel[]; + /** Configured models within that projection; exact-name eligibility is a separate check. */ advertised: EffectiveSubagentModel[]; excluded: SubagentRosterExclusion[]; } @@ -191,8 +193,8 @@ export function effectiveSubagentRoster( .filter(({ entry }) => entry.visibility === "list") .filter(({ entry }) => surface !== "v2" || isEligibleV2SubagentEntry(entry)) .sort((left, right) => { - // Spawn candidates rank by the natural priority (SPAWN_PRIORITY_FIELD when present), so a - // modelPickerOrder display reorder (#1649) can never change candidate membership. Rows the + // OpenCodex guidance candidates rank by natural priority (SPAWN_PRIORITY_FIELD when present), + // so modelPickerOrder does not change this projection. Native tool advertisements differ. Rows the // override did not move fall back to their Codex-visible `priority`. const spawnPriorityOf = (entry: RawEntry): number => { const spawn = entry[SPAWN_PRIORITY_FIELD]; @@ -315,6 +317,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; @@ -328,6 +332,8 @@ export function deriveEntry( } if (template || codexForwardNativeCapabilityAlias) { const e = JSON.parse(JSON.stringify(codexForwardNativeCapabilityAlias ?? template)) as RawEntry; + // A cached template may carry display-order history; each new row owns its natural rank. + delete e[SPAWN_PRIORITY_FIELD]; e.slug = slug; e.display_name = routedDisplayName(slug, model); e.description = desc; @@ -359,7 +365,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 +415,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"]); @@ -516,27 +522,25 @@ export function buildCatalogEntriesFromObservedState({ // catalog stays put across rebuilds. Featured rows keep their existing 0..N-1 band; when // modelPickerOrder is unset the helper is a no-op and every priority below is byte-identical to // 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) - : []; + // this display reorder does not change OpenCodex's guidance candidate calculation. + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); const pickerOrderRank = new Map(pickerOrder.map((slug, i) => [slug, i] as const)); const pickerOrderActive = pickerOrder.length > 0; // The display band reuses the existing high priority tier (>= PICKER_ORDER_PRIORITY_BASE, the // same 1_000+ neighborhood account rows occupy), keeping listed rows visually after the featured - // band. Candidate membership does not depend on this — see SPAWN_PRIORITY_FIELD. + // band. OpenCodex guidance membership does not depend on this — see SPAWN_PRIORITY_FIELD. /** * Priority for a non-featured routed row that is explicitly LISTED in modelPickerOrder. Listed * slugs sort in declared order within the high picker-order display tier * (>= PICKER_ORDER_PRIORITY_BASE). This sets the Codex-visible `priority` only; the caller records - * the row's natural priority in SPAWN_PRIORITY_FIELD so the spawn_agent candidate window is - * unchanged. Returns undefined when the feature is off or the row is not listed, so those rows + * the row's natural priority in SPAWN_PRIORITY_FIELD for OpenCodex's unchanged guidance window. + * Returns undefined when the feature is off or the row is not listed, so those rows * keep their original assignment (default 5 / account 1_000+) untouched. * * Scope: only the generic routed `/` rows call this (see the goModels loop * below). Native passthrough rows and account-qualified native rows keep their own priority - * logic and are intentionally not reordered here — this matches the documented contract on - * OcxConfig.modelPickerOrder (route native ordering through subagentModels instead). + * logic and are intentionally not reordered in this legacy builder pass. The final merge can + * apply complete ordering when the configured list includes a bare id. */ const pickerOrderPriority = (slug: string, altSlug?: string): number | undefined => { if (!pickerOrderActive) return undefined; @@ -658,9 +662,9 @@ export function buildCatalogEntriesFromObservedState({ // Keep the generated account rows together in Codex's priority-sorted flat picker. e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); } - // #1649: modelPickerOrder is a DISPLAY-ONLY override. Record the natural priority spawn_agent - // must keep using, then let modelPickerOrder move only the Codex-visible `priority`. Featured - // rows are never overridden (their rank is authoritative for both display and spawn). + // The legacy routed-only builder pass keeps featured ranks and records natural priority + // before changing non-featured display priority. The final complete-order pass may move + // featured display rows too; OpenCodex guidance continues to use their natural ranks. if (rankHit === undefined) { const pickerPriority = pickerOrderPriority(slug, `${m.provider}/${m.id}`); if (pickerPriority !== undefined) { @@ -779,12 +783,39 @@ export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< unsupportedNativeEntries: "drop", }); +function normalizeModelPickerOrder(order: unknown): string[] { + return Array.isArray(order) + ? order.filter((id): id is string => typeof id === "string" && id.trim().length > 0) + : []; +} + +/** 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)); +} + +/** Complete display ordering retains natural ranks for OpenCodex's separate guidance projection. */ +export function applyFullModelPickerOrder(entries: RawEntry[], order: readonly string[]): void { + const pickerOrder = normalizeModelPickerOrder(order); + 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 = rankOf(String(entry.slug)) ?? pickerOrder.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 accountSelectors?: readonly string[]; readonly wsEnabled: boolean; readonly template: RawEntry | null; readonly disabledModels: ReadonlySet; @@ -817,6 +848,8 @@ export function mergeCatalogEntriesFromObservedState({ routedEntries, baseline, featured, + modelPickerOrder = [], + accountSelectors = [], wsEnabled, template, disabledModels, @@ -975,7 +1008,9 @@ 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 // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); @@ -1060,6 +1095,32 @@ 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. Recompute managed spawn ranks from current config + // before either display-order mode; a saved display override is not current roster authority. + const pickerOrder = normalizeModelPickerOrder(modelPickerOrder); + 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]; finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -1134,7 +1195,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 +1222,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 +1824,8 @@ function writeRetainedCatalogSync({ }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; 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 df765a7853..8b30bb9eb2 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -342,6 +342,8 @@ function prepareCatalog( )), ); const mergedModels = mergeCatalogEntriesFromObservedState({ + modelPickerOrder, + accountSelectors, catalogModels, baselineCatalogModels, routedEntries, diff --git a/src/lab/fabric/producer-isolate.ts b/src/lab/fabric/producer-isolate.ts index 3ab672b9bd..70291adffc 100644 --- a/src/lab/fabric/producer-isolate.ts +++ b/src/lab/fabric/producer-isolate.ts @@ -84,6 +84,11 @@ function killChild(child: ChildProcess): void { export async function runIsolatedFabricProducer(request: IsolateRequest): Promise { const now = request.now ?? (() => Date.now()); let lastActivityAt = now(); + // Budget enforcement must not follow wall-clock adjustments; telemetry still does. + const budgetNow = request.now ?? (() => performance.now()); + const startedAt = request.now ? lastActivityAt : budgetNow(); + const totalDeadline = startedAt + request.totalTimeoutMs; + let inactivityDeadline = startedAt + request.inactivityTimeoutMs; return await new Promise((resolve, reject) => { let child: ChildProcess; @@ -104,48 +109,74 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis let stdoutBuffer = ""; let stderrBytes = 0; let settled = false; + let childClosed = false; let receivedResult: SyntheticPatchV1 | undefined; let killReason: FabricTaskError | undefined; const finish = (fn: () => void) => { - if (settled) return; + // A latched failure owns settlement, but scratch cleanup must wait for close. + if (settled || (killReason && !childClosed)) return; settled = true; clearTimeout(totalTimer); clearTimeout(inactivityTimer); - fn(); + if (killReason) reject(killReason); + else fn(); }; const settleTimeout = (error: FabricTaskError) => { - if (settled) return; + if (settled || killReason) return; killReason = error; - killChild(child); + if (childClosed) finish(() => reject(error)); + else killChild(child); + }; + + const expiredDeadline = (at: number): FabricTaskError | undefined => { + // Choose the earliest deadline, regardless of which timer/data callback ran first. + if (at >= inactivityDeadline && inactivityDeadline <= totalDeadline) { + return new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment"); + } + if (at >= totalDeadline) { + return new FabricTaskError("total timeout exceeded", "timeout", "environment"); + } + return undefined; + }; + + const onInactivityTimeout = () => { + settleTimeout(expiredDeadline(budgetNow()) + ?? new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); }; const armInactivity = () => { clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - settleTimeout(new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); - }, request.inactivityTimeoutMs); + inactivityTimer = setTimeout(onInactivityTimeout, request.inactivityTimeoutMs); }; - let inactivityTimer: ReturnType = setTimeout(() => { - settleTimeout(new FabricTaskError("inactivity timeout exceeded", "inactivity_timeout", "environment")); - }, request.inactivityTimeoutMs); + let inactivityTimer: ReturnType = setTimeout(onInactivityTimeout, request.inactivityTimeoutMs); const totalTimer = setTimeout(() => { - settleTimeout(new FabricTaskError("total timeout exceeded", "timeout", "environment")); + settleTimeout(expiredDeadline(budgetNow()) + ?? new FabricTaskError("total timeout exceeded", "timeout", "environment")); }, request.totalTimeoutMs); const handleProtocolLine = (line: string) => { + if (settled || killReason) return; try { const message = parseProducerProtocolLine(line); - if (message.type === "activity") { - lastActivityAt = now(); - armInactivity(); - return; + if (message.type === "activity" || message.type === "result") { + const at = budgetNow(); + const expired = expiredDeadline(at); + if (expired) { + settleTimeout(expired); + return; + } + if (message.type === "activity") { + lastActivityAt = request.now ? at : now(); + inactivityDeadline = at + request.inactivityTimeoutMs; + armInactivity(); + return; + } } if (message.type === "result") { - if (settled) return; receivedResult = message.patch; finish(() => resolve({ patch: message.patch, lastActivityAt })); return; @@ -176,6 +207,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis }; const consumeStdout = (chunk: string) => { + if (settled || killReason) return; stdoutBuffer += chunk; if (Buffer.byteLength(stdoutBuffer, "utf8") > FABRIC_PRODUCER_PROTOCOL_MAX_BYTES) { settleTimeout(new FabricTaskError("producer protocol output exceeded limit", "budget_exhausted", "environment")); @@ -207,16 +239,50 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis } }); + child.stderr?.on("error", (error) => { + settleTimeout(new FabricTaskError(error.message, "harness_failure", "harness")); + }); + child.on("error", (error) => { finish(() => reject(new FabricTaskError(error.message, "harness_failure", "harness"))); }); child.stdin?.on("error", (error: NodeJS.ErrnoException) => { - if (settled || error.code === "EPIPE") return; + if (settled || killReason || error.code === "EPIPE") return; killChild(child); finish(() => reject(new FabricTaskError(error.message, "harness_failure", "harness"))); }); + child.on("close", (code, signal) => { + childClosed = true; + if (settled) return; + if (killReason) { + finish(() => reject(killReason!)); + return; + } + if (receivedResult) { + finish(() => resolve({ patch: receivedResult!, lastActivityAt })); + return; + } + if (stdoutBuffer.trim()) { + try { + handleProtocolLine(stdoutBuffer.trim()); + if (settled) return; + } catch { + /* fall through */ + } + } + if (signal === "SIGKILL") { + finish(() => reject(new FabricTaskError("total timeout exceeded", "timeout", "environment"))); + return; + } + finish(() => reject(new FabricTaskError( + code === 0 ? "isolated producer returned no result" : `isolated producer exited (${code ?? signal ?? "unknown"})`, + "harness_failure", + "harness", + ))); + }); + const payload = JSON.stringify({ harnessKind: request.harnessKind, executorModulePath: request.executorModulePath, @@ -242,6 +308,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis child.stdin?.write(payload); child.stdin?.end(); } catch (error) { + if (killReason) return; killChild(child); finish(() => reject(new FabricTaskError( error instanceof Error ? error.message : String(error), @@ -250,35 +317,6 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis ))); return; } - - child.on("close", (code, signal) => { - if (settled) return; - if (killReason) { - finish(() => reject(killReason!)); - return; - } - if (receivedResult) { - finish(() => resolve({ patch: receivedResult!, lastActivityAt })); - return; - } - if (stdoutBuffer.trim()) { - try { - handleProtocolLine(stdoutBuffer.trim()); - if (receivedResult) return; - } catch { - /* fall through */ - } - } - if (signal === "SIGKILL") { - finish(() => reject(new FabricTaskError("total timeout exceeded", "timeout", "environment"))); - return; - } - finish(() => reject(new FabricTaskError( - code === 0 ? "isolated producer returned no result" : `isolated producer exited (${code ?? signal ?? "unknown"})`, - "harness_failure", - "harness", - ))); - }); }); } diff --git a/src/types/config.ts b/src/types/config.ts index 34e2d385d6..a0e095d403 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -419,17 +419,15 @@ 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. Exact catalog ids take precedence over equivalent raw/encoded + * routed ids; empty entries are ignored. The separate natural priority used by + * OpenCodex guidance is preserved. Native Codex's advertised five follow display + * priority and may change; exact-name override eligibility is not restricted by that list. + * Unset or empty leaves catalog priorities unchanged. */ modelPickerOrder?: string[]; /** diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 2b067f0b4c..b06c00a47e 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -243,11 +243,23 @@ advertises) but `expose_spawn_agent_model_overrides` on V2 (default `true`; when is omitted *and* the `model`/`reasoning_effort` schema fields are removed). And V2's `hide_spawn_agent_metadata` defaults true, which removes `service_tier`. -`modelPickerOrder` (#1649) deliberately does **not** feed this window: it rewrites only the -Codex-visible `priority` while `SPAWN_PRIORITY_FIELD` preserves the natural priority the roster -sorts by, so a display reorder can never change candidate membership. That divergence from -upstream's own ordering is the feature's purpose, not a defect — -`tests/codex-integration/codex-catalog-model-picker-order.test.ts` pins it. +`modelPickerOrder` (#1649) separates **OpenCodex guidance** from native advertisement. +`SPAWN_PRIORITY_FIELD` preserves the natural priority used by `effectiveSubagentRoster`, so +OpenCodex's preferred/guidance candidate calculation stays independent of display order. +Native Codex ignores that private field: its advertised five on V1 and exposed V2 follow the +native `priority` and may change when the picker is reordered. Exact-name override lookup is +not restricted to those five advertised rows. V1 receives no OpenCodex preferred-roster +injection; V2 can additionally receive natural-priority guidance when its catalog state permits. +The helper tests pin guidance behavior, not native tool-description equivalence. + +A nonblank bare id in `modelPickerOrder` opts into complete-picker display ordering. Exact +ids take precedence over raw/encoded equivalents; routed-only and empty lists keep the legacy +ordering behavior. This does not change the separate `opencodex_spawn_priority` contract. +Retained rows recompute their natural ranks from the current featured roster and account-selector +stride before display order is applied, so a discovery outage cannot preserve an obsolete +featured or picker rank. Canonical `opencode-go` rows retain their configured reasoning ladder +both when generated and when merged from retained catalog state; synthetic max/ultra choices +are not added to that provider's declared ladder. Full derivation with per-line citations: `devlog/_plan/260816_codexrs_multiagent_v2_and_history_perf/013_five_cap_v1_vs_v2.md`. 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..985b7760f9 --- /dev/null +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -0,0 +1,427 @@ +import { routedSlug } from "../../src/providers/slug-codec"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import type { OcxConfig } from "../../src/types"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { convergeCodexCatalog } from "../../src/codex/convergence"; +import { loadBundledCodexCatalog, resetCatalogRuntimeStateForTests, syncCatalogModels } from "../../src/codex/catalog"; +import type { RawCatalog, RawEntry } from "../../src/codex/catalog/parsing"; +import { clearModelCache, markModelsFetchFailure } from "../../src/codex/model-cache"; +import { loadPersistedCodexRuntime, resetCodexRuntimeResolveCacheForTests, resolveCodexRuntime } from "../../src/codex/runtime"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { effectiveSubagentRoster } 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[] = [ + { 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("full picker helper treats a null passthrough order as absent", () => { + const rows = [{ slug: "gpt-5.5", priority: 9 }, { slug: "opencode-go/model", priority: 0 }]; + const before = structuredClone(rows); + // Production callers coalesce null; the exported boundary must also tolerate it directly. + applyFullModelPickerOrder(rows, null as unknown as readonly string[]); + expect(rows).toEqual(before); +}); + + +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("fresh routed rows do not inherit a previously ordered native template's guidance rank", () => { + const ids = ["fresh-a", "fresh-b", "fresh-c", "fresh-d", "fresh-e", "fresh-f"]; + const slugs = ids.map(id => routedSlug("opencode-go", id)); + const featured = slugs.slice(0, 5); + const order = ["gpt-5.5", slugs[5]!, ...featured.toReversed()]; + const template = deriveEntry(null, "gpt-5.5", "Previously ordered native", 0); + template[SPAWN_PRIORITY_FIELD] = 9; + const previousTemplate = structuredClone(template); + const rows = buildCatalogEntriesFromObservedState({ + template, + gptSlugs: ["gpt-5.5"], + goModels: ids.map(id => ({ + provider: "opencode-go", id, + reasoningEfforts: ["high", "xhigh"], defaultReasoningEffort: "xhigh", + })), + featured, modelPickerOrder: order, + wsEnabled: false, multiAgentMode: "v2", multiAgentV2Enabled: true, + exactComboSlugs: new Set(), accountSelectors: [], + suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + }); + + const featuredRows = featured.map(slug => rows.find(row => row.slug === slug)!); + expect(featuredRows.map(row => row[SPAWN_PRIORITY_FIELD] ?? row.priority)).toEqual([0, 1, 2, 3, 4]); + const expectedCandidates = featured.map(model => ({ model, efforts: ["high", "xhigh"] })); + const before = effectiveSubagentRoster(featured, "v2", rows); + expect(before.candidates).toEqual(expectedCandidates); + expect(before.advertised).toEqual(expectedCandidates); + + applyFullModelPickerOrder(rows, order); + expect(effectiveSubagentRoster(featured, "v2", rows)).toEqual(before); + expect(rows.toSorted((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(template).toEqual(previousTemplate); +}); + + +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]); +}); + +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 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[], 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", + exactComboSlugs: new Set(), accountSelectors, suppressedBareNativeSlugs: new Set(), + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false, + }); + 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, + 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: "suppress" }, + }); + const fullOrder = ["gpt-5.6-sol", slug]; + const previous = merge([], fresh(fullOrder, before), fullOrder, false, before); + const saved = structuredClone(previous); + 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, after)).toEqual(degraded); + 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); +}); + +describe("picker ordering through production catalog writers", () => { + const ids = ["ordering-a", "ordering-b", "ordering-c", "ordering-d", "ordering-e", "ordering-f"]; + const slugs = ids.map(id => routedSlug("opencode-go", id)); + const configuredEfforts = ["high", "xhigh"]; + const envKeys = ["CODEX_HOME", "OPENCODEX_HOME", "CODEX_CLI_PATH"] as const; + let previousEnv: Array; + let previousFetch: typeof fetch; + let root: string; + let codexHome: string; + let catalogPath: string; + let fetchCalls: number; + let runtimeCommand: string; + + // Same executable-fixture protocol as codex-convergence-account-selectors.test.ts: + // a forced resolver refresh must receive the same version and catalog as a warm read. + function createRuntimeFixture(catalog: RawCatalog): string { + const script = join(root, "fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) {', + ' console.log("codex-cli 0.145.0");', + '} else {', + ` process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + '}', + ].join("\n")); + if (process.platform === "win32") { + const command = join(root, "fixture-codex.cmd"); + writeFileSync(command, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + return command; + } + const command = join(root, "fixture-codex"); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(command, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(command, 0o755); + return command; + } + + function assertRuntimeIdentity(): void { + const resolved = resolveCodexRuntime({ discoverAlternatives: false }); + expect(resolved.runtime.command).toBe(runtimeCommand); + expect(resolved.runtime.version).toBe("0.145.0"); + const persisted = loadPersistedCodexRuntime(); + expect(persisted?.command).toBe(runtimeCommand); + expect(persisted?.selectedVersion).toBe("0.145.0"); + } + + beforeEach(() => { + previousEnv = envKeys.map(key => process.env[key]); + previousFetch = globalThis.fetch; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-picker-writers-"))); + codexHome = join(root, "codex"); + const opencodexHome = join(root, "ocx"); + mkdirSync(codexHome); + mkdirSync(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync(join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n[features]\nmulti_agent_v2 = true\n'); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + const native = deriveEntry(null, "gpt-5.5", "Native fixture", 9); + const catalog = { models: [native] }; + runtimeCommand = createRuntimeFixture(catalog); + process.env.CODEX_CLI_PATH = runtimeCommand; + // Resolve the real fixture executable before admission captures runtime provenance. + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); + assertRuntimeIdentity(); + writeFileSync(catalogPath, JSON.stringify(catalog)); + fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("catalog writer fixture must not make a network request"); + }) as typeof fetch; + }); + + afterEach(() => { + try { + const database = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + } finally { + globalThis.fetch = previousFetch; + envKeys.forEach((key, index) => { + const value = previousEnv[index]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + removeTreeWithRetry(root); + } + }); + + function config(featured = slugs.slice(0, 5), order: string[] = []): OcxConfig { + return { + port: 10100, + defaultProvider: "opencode-go", + multiAgentMode: "v2", + subagentModels: featured, + subagentModelsVersion: SUBAGENT_MODELS_VERSION, + modelPickerOrder: order, + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + "opencode-go": { + adapter: "openai-chat", baseUrl: "https://catalog-fixture.invalid/v1", authMode: "key", + apiKey: "ordering-fixture-key", liveModels: false, models: [...ids], + modelReasoningEfforts: Object.fromEntries(ids.map(id => [id, [...configuredEfforts]])), + modelDefaultReasoningEfforts: Object.fromEntries(ids.map(id => [id, "xhigh"])), + }, + }, + }; + } + + async function writeCatalog(writer: "convergence" | "retained", next: OcxConfig, degraded = false): Promise { + assertRuntimeIdentity(); + const requestedRoster = [...next.subagentModels!]; + saveConfig(next); + const saved = loadConfig(); + expect(saved.subagentModelsVersion).toBe(SUBAGENT_MODELS_VERSION); + expect(saved.subagentModels).toEqual(requestedRoster); + expect(next.subagentModels).toEqual(requestedRoster); + if (degraded) { + // Preserve disk rows without a discovery cache; built-in metadata augmentation still runs. + clearModelCache("opencode-go"); + markModelsFetchFailure("opencode-go"); + } + if (writer === "convergence") { + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(next), { + action: "converge", scope: "catalog", reason: "management-mutation", mode: "explicit", deadlineMs: 5_000, + }); + expect(result.catalogRefresh).toMatchObject({ status: "committed", degraded }); + } else { + const result = await syncCatalogModels(next); + expect(result.path).toBe(catalogPath); + expect(result.skippedReason).toBeUndefined(); + } + assertRuntimeIdentity(); + expect(fetchCalls).toBe(0); + return (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; + } + + function roster(rows: RawEntry[], featured: string[]) { + const beforeAssertions = JSON.stringify(rows); + const result = effectiveSubagentRoster(featured, "v2", rows); + expect(result.candidates.map(candidate => candidate.model)).toEqual(featured); + expect(result.candidates).toHaveLength(5); + expect(result.advertised).toEqual(result.candidates); + for (const candidate of result.candidates) expect(candidate.efforts).toEqual(configuredEfforts); + for (const slug of slugs) { + const row = rows.find(row => row.slug === slug); + expect(row).toBeDefined(); + expect(row!.default_reasoning_level).toBe("xhigh"); + const levels = row!.supported_reasoning_levels; + expect(Array.isArray(levels)).toBe(true); + expect((levels as Array<{ effort: string }>).map(level => level.effort)).toEqual(configuredEfforts); + } + // Full catalog comparisons below must still compare untouched metadata, not matcher nodes. + expect(JSON.stringify(rows)).toBe(beforeAssertions); + return result; + } + + for (const writer of ["convergence", "retained"] as const) { + const filteredOrder = ["gpt-5.5", slugs[5]!, ...slugs.slice(0, 5).reverse(), " gpt-5.5 "]; + const malformedOrders: Array<{ label: string; input: unknown; filtered: string[] }> = [ + { label: "string scalar", input: "gpt-5.5", filtered: [] }, + { label: "number scalar", input: 7, filtered: [] }, + { label: "boolean scalar", input: true, filtered: [] }, + { label: "object", input: { 0: "gpt-5.5", length: 1 }, filtered: [] }, + { + label: "mixed array", + input: [null, 7, "", " \t", filteredOrder[0], false, filteredOrder[1], {}, ...filteredOrder.slice(2)], + // Significant surrounding whitespace remains part of the original spelling. + filtered: filteredOrder, + }, + ]; + + test.each(malformedOrders)(`${writer} tolerates $label passthrough order in healthy and retained discovery`, async ({ input, filtered }) => { + const control = config(slugs.slice(0, 5), filtered); + const expected = await writeCatalog(writer, control); + if (filtered.length > 0) { + // Trimming the final nonblank string would incorrectly override the native rank. + expect(expected.find(row => row.slug === "gpt-5.5")?.priority).toBe(0); + } + const expectedRoster = roster(expected, control.subagentModels!); + // Model configuration is passthrough at runtime; exercise the writers, not the normalizer. + const malformed = Object.assign(config(control.subagentModels), { modelPickerOrder: input }); + const actual = await writeCatalog(writer, malformed); + expect(actual).toEqual(expected); + expect(roster(actual, control.subagentModels!)).toEqual(expectedRoster); + + const priorCatalog = readFileSync(catalogPath); + const cachePath = join(codexHome, "models_cache.json"); + const priorCache = existsSync(cachePath) ? readFileSync(cachePath) : null; + const restoreSeed = () => { + writeFileSync(catalogPath, priorCatalog); + if (priorCache === null) rmSync(cachePath, { force: true }); + else writeFileSync(cachePath, priorCache); + }; + const retainedControl = config(control.subagentModels, filtered); + const retainedMalformed = Object.assign(config(control.subagentModels), { modelPickerOrder: input }); + for (const candidate of [retainedControl, retainedMalformed]) { + candidate.providers["opencode-go"]!.liveModels = true; + candidate.providers["opencode-go"]!.models = []; + } + // Both sides activate identical Go metadata augmentation and failure/cooldown state. + // Only the malformed order differs; a static healthy catalog is not this counterfactual. + restoreSeed(); + const expectedRetained = await writeCatalog(writer, retainedControl, true); + const expectedRetainedRoster = roster(expectedRetained, control.subagentModels!); + expect(expectedRetainedRoster).toEqual(expectedRoster); + restoreSeed(); + const retained = await writeCatalog(writer, retainedMalformed, true); + expect(retained).toEqual(expectedRetained); + expect(roster(retained, control.subagentModels!)).toEqual(expectedRetainedRoster); + }, 30_000); + + test(`${writer} applies full display order without changing five eligible Go candidates`, async () => { + const initial = config(); + const before = roster(await writeCatalog(writer, initial), initial.subagentModels!); + // Bring the sixth routed model above every featured model in the display. + const order = ["gpt-5.5", slugs[5]!, ...slugs.slice(0, 5).reverse()]; + const ordered = config(initial.subagentModels, order); + const rows = await writeCatalog(writer, ordered); + expect(rows.filter(row => order.includes(String(row.slug))) + .sort((a, b) => Number(a.priority) - Number(b.priority)).map(row => row.slug)).toEqual(order); + expect(roster(rows, ordered.subagentModels!)).toEqual(before); + expect(roster(await writeCatalog(writer, ordered), ordered.subagentModels!)).toEqual(before); + }, 30_000); + + test(`${writer} refreshes retained outage ranks after a full-picker and featured-roster change`, async () => { + const previous = await writeCatalog(writer, config(slugs.slice(0, 5), ["gpt-5.5", ...slugs])); + for (const row of previous) { + if (slugs.includes(String(row.slug))) row.ordering_retained_fixture = true; + } + // Promote the formerly excluded sixth model, demote the first, and clear full ordering. + const featured = slugs.slice(1).reverse(); + const next = config(featured, [slugs[0]!]); + const healthy = await writeCatalog(writer, next); + const expectedRoster = roster(healthy, featured); + writeFileSync(catalogPath, JSON.stringify({ models: previous })); + next.providers["opencode-go"]!.liveModels = true; + next.providers["opencode-go"]!.models = []; + const retained = await writeCatalog(writer, next, true); + expect(roster(retained, featured)).toEqual(expectedRoster); + for (const slug of slugs) { + const actual = retained.find(row => row.slug === slug)!; + const expected = healthy.find(row => row.slug === slug)!; + expect(actual.ordering_retained_fixture).toBe(true); + expect(actual.priority).toBe(expected.priority); + expect(actual[SPAWN_PRIORITY_FIELD]).toBe(expected[SPAWN_PRIORITY_FIELD]); + } + expect(await writeCatalog(writer, next, true)).toEqual(retained); + }, 30_000); + } +}); 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..80bf27d4bd 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", @@ -555,6 +557,7 @@ "lab-evidence-sanitization.test.ts": "lab", "lab-fabric-outcome-validation.test.ts": "lab", "lab-fabric-persistence-boundary.test.ts": "lab", + "lab-fabric-producer-deadline.test.ts": "lab", "lab-fabric-task.test.ts": "lab", "lab-installation-salt-cache.test.ts": "lab", "lab-ledger-mutation-lock.test.ts": "lab", diff --git a/tests/lab/lab-fabric-producer-deadline.test.ts b/tests/lab/lab-fabric-producer-deadline.test.ts new file mode 100644 index 0000000000..f4895b90cf --- /dev/null +++ b/tests/lab/lab-fabric-producer-deadline.test.ts @@ -0,0 +1,423 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as childProcess from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { setImmediate as nextTurn } from "node:timers"; +import { runIsolatedFabricProducer } from "../../src/lab/fabric/producer-isolate"; +import type { IsolatedProducerResult } from "../../src/lab/fabric/producer-protocol"; +import { FabricTaskError, type FabricTaskRunResult, type SyntheticPatchV1 } from "../../src/lab/fabric/types"; +import { runFabricSyntheticPatchTaskForRoute } from "../../src/lab/fabric/executor"; +import { createLabDestination } from "../../src/lab/live/destination"; +import { fabricCorrectPatchExecutor, fabricMockRoute } from "../helpers/fabric-task-test"; + +// Hand-written valid fixture: an always-reject supervisor must fail the controls. +const PATCH: SyntheticPatchV1 = { + schemaVersion: 1, + operations: [{ op: "replace", path: "src/value.txt", contentUtf8: "after\n" }], +}; +const RESULT = JSON.stringify({ type: "result", patch: PATCH }); +const ACTIVITY = '{"type":"activity"}\n'; +const START = 1_000; +const IDLE_MS = 100; +const TOTAL_MS = 250; + +class DeadlineChild extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly signals: Array = []; + closed = false; + + kill(signal?: NodeJS.Signals | number): boolean { + this.signals.push(signal); + return true; // Buffered data can arrive after kill; only the test emits close. + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.emit("close", 0, null); + } +} + +type CapturedTimer = { callback: () => void; delay: number; cleared: boolean }; +type Outcome = + | { status: "pending" } + | { status: "resolved"; value: T } + | { status: "rejected"; error: unknown }; + +// Drain promise adoption and stream nextTicks, without sleeping or advancing time. +const drain = () => new Promise((resolve) => nextTurn(resolve)); + +function installTimers(restorers: Array<() => void>) { + const timers: CapturedTimer[] = []; + const handles = new Map, CapturedTimer>(); + const setSpy = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, delay: number) => { + const timer = { callback, delay, cleared: false }; + // Only timer identity/unref are consumed by this supervisor; no real handle. + const handle = { unref() { return this; } } as unknown as ReturnType; + timers.push(timer); + handles.set(handle, timer); + return handle; + }) as typeof setTimeout); + restorers.push(() => setSpy.mockRestore()); + const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation((handle) => { + const timer = handles.get(handle as ReturnType); + if (timer) timer.cleared = true; + }); + restorers.push(() => clearSpy.mockRestore()); + return timers; +} + +type ExpectedFailure = + | [code: "inactivity_timeout" | "timeout"] + | [code: "harness_failure", attribution: "harness", message: string]; + +type Harness = { + child: DeadlineChild; + timers: CapturedTimer[]; + at: (time: number) => void; + result: (newline?: boolean) => void; + pending: () => Promise; + failure: (...expected: ExpectedFailure) => Promise; + success: (lastActivityAt?: number) => Promise; +}; + +async function withProducer(body: (h: Harness) => Promise, totalTimeoutMs = TOTAL_MS) { + const scratchRoot = mkdtempSync(join(tmpdir(), "ocx-fabric-deadline-")); + const child = new DeadlineChild(); + const originals = { spawn: childProcess.spawn, set: globalThis.setTimeout, clear: globalThis.clearTimeout }; + const restorers: Array<() => void> = []; + let time = START; + let outcome: Outcome = { status: "pending" }; + try { + // Repository namespace-spy precedent; never delegates to the original spawn. + const spawnSpy = spyOn(childProcess, "spawn").mockImplementation(() => child as unknown as childProcess.ChildProcess); + restorers.push(() => spawnSpy.mockRestore()); + const timers = installTimers(restorers); + void runIsolatedFabricProducer({ + scratchRoot, harnessKind: "deterministic_correct", totalTimeoutMs, + inactivityTimeoutMs: IDLE_MS, now: () => time, + }).then( + (value) => { outcome = { status: "resolved", value }; }, + (error: unknown) => { outcome = { status: "rejected", error }; }, + ); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.results[0]?.value).toBe(child); + expect(child.stdout.listenerCount("data")).toBe(1); + expect(child.listenerCount("close")).toBe(1); + expect(timers.map(({ delay }) => delay).sort((a, b) => a - b)).toEqual([IDLE_MS, totalTimeoutMs]); + await body({ + child, timers, at: (value) => { time = value; }, + result: (newline = true) => { child.stdout.write(RESULT + (newline ? "\n" : "")); }, + pending: async () => { await drain(); expect(outcome.status).toBe("pending"); }, + failure: async (...expected) => { + const [code] = expected; + const attribution = code === "harness_failure" ? expected[1] : "environment"; + const message = code === "harness_failure" ? expected[2] + : code === "inactivity_timeout" ? "inactivity timeout exceeded" : "total timeout exceeded"; + child.close(); + await drain(); + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("producer did not reject after close"); + expect(outcome.error).toBeInstanceOf(FabricTaskError); + expect(outcome.error).toMatchObject({ code, attribution, message }); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + }, + success: async (lastActivityAt = START) => { + await drain(); + expect(outcome).toEqual({ status: "resolved", value: { patch: PATCH, lastActivityAt } }); + expect(child.signals).toEqual([]); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + }, + }); + expect(spawnSpy).toHaveBeenCalledTimes(1); + } finally { + // Always reap the fake before removing its scratch, including failed assertions. + try { + child.close(); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + } finally { + for (const restore of restorers.reverse()) restore(); + rmSync(scratchRoot, { recursive: true, force: true }); + expect(childProcess.spawn).toBe(originals.spawn); + expect(globalThis.setTimeout).toBe(originals.set); + expect(globalThis.clearTimeout).toBe(originals.clear); + } + } +} + +describe("isolated fabric producer deadline admission", () => { + test("idle timer then buffered result cannot settle before child close", async () => { + await withProducer(async (h) => { + h.at(1_100); + h.timers[0]!.callback(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.pending(); + h.result(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + for (const time of [1_100, 1_101]) { + test(`result at ${time} rejects even when no timer callback ran`, async () => { + await withProducer(async (h) => { + h.at(time); + h.result(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("inactivity_timeout"); + }); + }); + } + + test("late activity and result in the same chunk cannot renew idle", async () => { + await withProducer(async (h) => { + h.at(1_101); + h.child.stdout.write(ACTIVITY + RESULT + "\n"); + await h.pending(); + expect(h.timers).toHaveLength(2); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("inactivity_timeout"); + }); + }); + + test("total deadline is fixed despite accepted activity", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_180]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + expect(h.child.signals).toEqual([]); + } + h.at(1_250); + h.result(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + await h.failure("timeout"); + }); + }); + + test("a delayed total callback chooses the earlier elapsed idle deadline", async () => { + await withProducer(async (h) => { + h.at(1_251); + h.timers[1]!.callback(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + test("inactivity wins an exact deadline tie even if total callback runs first", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_150]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + } + h.at(1_250); + h.timers[1]!.callback(); + await h.pending(); + await h.failure("inactivity_timeout"); + }); + }); + + test("first timeout survives later timers, protocol and process/stream errors", async () => { + await withProducer(async (h) => { + h.at(1_100); + h.timers[0]!.callback(); + await h.pending(); + h.at(1_251); + const laterEvents = [ + () => h.timers[1]!.callback(), + () => h.child.stdout.write('{"type":"error","code":"sandbox_violation","message":"later protocol error","attribution":"harness"}\n'), + () => h.child.stdout.write("not-json\n"), + () => h.child.stdout.emit("error", new Error("later stdout error")), + () => h.child.stderr.emit("error", new Error("later stderr error")), + () => h.child.stdin.emit("error", new Error("later stdin error")), + () => h.child.emit("error", new Error("later child error")), + () => h.child.stdout.write(ACTIVITY), + () => h.result(), + () => h.timers[0]!.callback(), + ]; + for (const event of laterEvents) { + event(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + } + expect(h.timers).toHaveLength(2); + await h.failure("inactivity_timeout"); + }); + }); + + test("valid result just before idle boundary succeeds", async () => { + await withProducer(async (h) => { + h.at(1_099); + h.result(); + await h.success(); + }); + }); + + test("stderr failure stays authoritative until close across later data, errors and timers", async () => { + await withProducer(async (h) => { + h.child.stderr.emit("error", new Error("first stderr read failure")); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + h.at(1_251); + const laterEvents = [ + () => h.child.stdout.write(ACTIVITY + RESULT + "\n"), + () => h.child.stdout.write('{"type":"error","code":"sandbox_violation","message":"later protocol error","attribution":"harness"}\n'), + () => h.child.stderr.emit("error", new Error("second stderr error")), + () => h.child.stdout.emit("error", new Error("later stdout error")), + () => h.child.stdin.emit("error", new Error("later stdin error")), + () => h.child.emit("error", new Error("later child error")), + () => h.timers[0]!.callback(), + () => h.timers[1]!.callback(), + ]; + for (const event of laterEvents) { + event(); + await h.pending(); + expect(h.child.signals).toEqual(["SIGKILL"]); + } + expect(h.timers).toHaveLength(2); + await h.failure("harness_failure", "harness", "first stderr read failure"); + }); + }); + + test("valid activity renews idle and reports its accepted timestamp", async () => { + await withProducer(async (h) => { + h.at(1_090); + h.child.stdout.write(ACTIVITY); + await h.pending(); + expect(h.timers).toHaveLength(3); + expect(h.timers[0]!.cleared).toBe(true); + expect(h.timers[1]!.cleared).toBe(false); + h.at(1_189); + h.result(); + await h.success(1_090); + }); + }); + + test("valid result just before the fixed total deadline succeeds", async () => { + await withProducer(async (h) => { + for (const time of [1_090, 1_180]) { + h.at(time); + h.child.stdout.write(ACTIVITY); + await h.pending(); + } + h.at(1_249); + h.result(); + await h.success(1_180); + }); + }); + + for (const closeAt of [1_099, 1_100, 1_101]) { + test(`unterminated result is admitted at close time ${closeAt}`, async () => { + await withProducer(async (h) => { + h.at(1_099); + h.result(false); + await h.pending(); + h.at(closeAt); + h.child.close(); + if (closeAt < 1_100) await h.success(); + else await h.failure("inactivity_timeout"); + }); + }); + } +}); + +test("trusted route keeps scratch until stderr-failed child closes, then cleans it", async () => { + const configDir = mkdtempSync(join(tmpdir(), "ocx-fabric-consumer-deadline-")); + const child = new DeadlineChild(); + const originals = { spawn: childProcess.spawn, set: globalThis.setTimeout, clear: globalThis.clearTimeout }; + const restorers: Array<() => void> = []; + const proxyNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]; + const proxyEnv = proxyNames.map((name) => [name, process.env[name]] as const); + const outer: { outcome: Outcome } = { outcome: { status: "pending" } }; + try { + for (const name of proxyNames) delete process.env[name]; + // Resolve through the existing destination contract before capturing producer timers. + const destination = await createLabDestination({ + baseUrl: "https://api.example.com/v1", labRunApproval: true, configDir, + resolve: async () => [{ address: "93.184.216.34", family: 4 }], + }); + const spawnSpy = spyOn(childProcess, "spawn").mockImplementation(() => child as unknown as childProcess.ChildProcess); + restorers.push(() => spawnSpy.mockRestore()); + const timers = installTimers(restorers); + void runFabricSyntheticPatchTaskForRoute({ + routeContext: fabricMockRoute(), destination, configDir, now: () => START, + patchExecutor: fabricCorrectPatchExecutor(), + }).then( + (value) => { outer.outcome = { status: "resolved", value }; }, + (error: unknown) => { outer.outcome = { status: "rejected", error }; }, + ); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.mock.results[0]?.value).toBe(child); + const scratchRoot = spawnSpy.mock.calls[0]?.[2]?.env?.OCX_FABRIC_SCRATCH_ROOT; + expect(typeof scratchRoot).toBe("string"); + if (!scratchRoot) throw new Error("producer spawn omitted its scratch root"); + expect(child.listenerCount("close")).toBe(1); + expect(timers).toHaveLength(2); + expect(existsSync(scratchRoot)).toBe(true); + await drain(); + expect(outer.outcome.status).toBe("pending"); + + child.stderr.emit("error", new Error("consumer stderr failure")); + const assertPendingScratch = async () => { + await drain(); + expect(outer.outcome.status).toBe("pending"); + expect(child.closed).toBe(false); + expect(child.signals).toEqual(["SIGKILL"]); + expect(existsSync(scratchRoot)).toBe(true); + expect(readFileSync(join(scratchRoot, "src/value.txt"), "utf8")).toBe("before\n"); + }; + await assertPendingScratch(); + const afterFailure = [ + () => child.stdout.write(ACTIVITY + RESULT + "\n"), + () => child.stderr.emit("error", new Error("later stderr failure")), + () => timers[0]!.callback(), + () => timers[1]!.callback(), + ]; + for (const event of afterFailure) { + event(); + await assertPendingScratch(); + } + child.close(); + await drain(); + expect(outer.outcome.status).toBe("resolved"); + if (outer.outcome.status !== "resolved") throw new Error("route did not settle after child close"); + expect(outer.outcome.value).toMatchObject({ + executionAuthority: "trusted_route", + outcome: { + outcome: "inconclusive", + failure: { class: "harness_failure", code: "harness_failure", attribution: "harness", retryable: false }, + verifier: { passed: false, reason: "harness_failure" }, + usage: { outputBytes: 0, patchOperations: 0, filesTouched: 0 }, + }, + }); + expect(existsSync(scratchRoot)).toBe(false); + expect(timers.every(({ cleared }) => cleared)).toBe(true); + expect(spawnSpy).toHaveBeenCalledTimes(1); + } finally { + try { + child.close(); + await drain(); + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + } finally { + for (const restore of restorers.reverse()) restore(); + for (const [name, value] of proxyEnv) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(configDir, { recursive: true, force: true }); + expect(childProcess.spawn).toBe(originals.spawn); + expect(globalThis.setTimeout).toBe(originals.set); + expect(globalThis.clearTimeout).toBe(originals.clear); + } + } +});