diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md new file mode 100644 index 0000000000..179bdb55d4 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/000_plan.md @@ -0,0 +1,225 @@ +# Unconditional `max`/`ultra`, and the stale clamp that hides them + +## Reader summary + +Two catalog defects share one shape: OpenCodex decides what the Codex client may +render, and in both cases it decides "nothing" for the wrong reason. First, a +persisted effort-clamp diagnostic keeps `max` and `ultra` hidden after the Codex +binary is upgraded in place, because the diagnostic is matched to the current +runtime by **path alone** — the recorded version is never compared when the path +is unchanged, which is the normal Windows auto-update case. Second, the two +presentation fields the Codex client renders as cards (`availability_nux`, +`upgrade`) survive end to end on pin-backed native rows but are discarded where +the account roster is parsed (`model-entitlements.ts` keeps only the slug), so a +card whose copy lives only on the account endpoint can never appear through the +proxy. (An earlier draft of this paragraph said "deleted at four sites and +carried at none"; the explorer pass disproved it — see +`020_architecture_dispositions.md`.) This unit makes the clamp +diagnostic version-aware, exempts `max` and `ultra` from the observed-runtime +intersection entirely, and — pending a probe — lets the account roster contribute +the presentation copy it currently discards. The per-consumer ladder projection in +the first draft was withdrawn by maintainer ruling; the delete-site registry was +withdrawn because the explorer pass showed native rows already keep the fields. + +Evidence: `010_evidence.md`. Upstream issues: #4204 (efforts), #4213 (cards). + +## Loop spec + +| Field | Content | +| --- | --- | +| Loop archetype | Satisfy-spec. Both defects have a decidable correct behaviour; this is not an open-ended optimization. | +| Trigger | User request: make `max`/`ultra` reachable regardless of the CLI version recorded on disk, and make the card fields usable as boilerplate for other presentation surfaces. | +| Goal | A Codex client that supports an effort sees it offered; a presentation field that upstream populates survives to the client; adding the next such field is a registry entry, not a new code path. | +| Non-goals | Proxying `wham/workspace-messages`. Changing image generation. Authoring card copy that upstream does not ship. Pushing to any remote. Clamping rungs other than `max`/`ultra` — the rest of the ladder keeps its current observed-runtime behaviour. | +| Verifier | `bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` — RUN, exit 0, 42 pass, and it reads `src/codex/runtime.ts` (`codex-runtime.test.ts:37` imports it; `:612` calls `effortClampAppliesToRuntime`). **That pair is sufficient for Phase 1 and NOT sufficient for Phase 2** — `catalog-go-exact-efforts.test.ts` pins construction, never the clamp. The gate that observes Phase 2 is `bun test tests/codex-integration/codex-catalog.test.ts tests/codex-integration/reserve-catalog.test.ts tests/clients/client-catalog-compatibility.test.ts` (see `030_test_impact.md` for the exact test names that must invert). NOT RUN at plan time — inverting them is B's work, so a pass today would prove nothing. Phase 3 has no gate until its test exists; that acceptance row is human review. | +| Stop condition | ~~Plan-only~~ Superseded 2026-09-11 (second session): the user authorised the full PABCD cycle. This cycle ends at D with Phases 1, 2 and 4 built and verified. Phase 3 stays withdrawn (GAP-2). | +| Memory artifact | This unit directory: `devlog/_plan/260911_catalog_presentation_and_effort_projection/`. | +| Expected terminal outcomes | Success: Phases 1, 2 and 4 land on `codex/260911-clamp-expiry` with their gates green. Resolved: GAP-1 (CLAMP-04 ships — see `050_revalidation.md`). Unresolved: GAP-2/GAP-3 in `040_open_gaps.md` are not closed by this cycle. Blocked: Phase 3 stays blocked on a live account-roster probe that only the user can authorise. | +| Escalation condition | The account-roster probe needs a real ChatGPT token; main does not take it. CLAMP-04 changes the meaning of an active clamp and inverts diagnostic tests the plan currently promises to keep green — that contradiction goes to the user, not to B. Reserve deletion is no longer an escalation: CLAMP-05 decided it. | + +## Phase map + +Ordered by build dependency. Each phase closes with something independently +verifiable. + +### Phase 1 — Foundation: the clamp diagnostic must expire + +`effortClampAppliesToRuntime` (`src/codex/runtime.ts:431`) returns `true` as soon as +the recorded path equals the resolved runtime path, before it ever looks at the +version. An in-place upgrade therefore keeps a clamp alive forever. This machine +is in exactly that state: the diagnostic records `0.135.0` for a path that now +reports `0.154.0`, and that binary's own bundled catalog contains `max` and +`ultra`. + +Change: when the diagnostic and the runtime both carry a version and those +versions differ, the diagnostic does not apply — regardless of path equality. A +missing version on either side stays conservative and keeps the current +path-match behaviour, because an unknown version is not evidence of an upgrade. + +This narrows nothing and weakens no clamp: it invalidates an observation that is +provably about a different binary. It is also the phase the other two depend on, +because a stale diagnostic would mask whatever Phase 2 computes. + +Accept criteria: + +- Given a diagnostic at version A and a runtime at the same path at version B (A ≠ B), `effortClampAppliesToRuntime` returns `false`. +- Given the same path and the same version, it still returns `true`. +- Given a diagnostic with a null version, current behaviour is unchanged. +- Activation scenario for the guard: construct the two-version case in the test and assert `ocx status` composes without the clamp warning — the observable effect is the absent `Catalog clamp removed` line at `src/cli/status.ts:277`. + +Files: `src/codex/runtime.ts` (`effortClampAppliesToRuntime`), `tests/codex-integration/codex-runtime.test.ts` (extend near line 612). + +### Phase 2 — Core: `max` and `ultra` stop being clampable + +**Maintainer ruling, 2026-09-11.** Emit `max` and `ultra` unconditionally. The +consumer-projection design in the first draft of this plan is withdrawn, and the +#4204 review constraint it was written against is superseded by the person who +wrote it. Rationale on the record: enough time has passed that the CLI versions +which genuinely lack the two rungs are effectively unsupported, so a clamp that +exists to protect them costs more than it buys. + +What the clamp does today: the ladder comes from `codex debug models --bundled` +of the resolved runtime (`src/codex/catalog/effort.ts:331` → +`src/codex/catalog/bundled.ts:239`), alternative-runtime discovery is off for that +call (`bundled.ts:261` defaults `discoverAlternatives` to `false`; +`runtime.ts:603` breaks out of the candidate loop when it is `false`), so the +persisted binary decides the ladder for the whole machine. + +Mechanism, per CLAMP-01/03 in `020_architecture_dispositions.md` and the A-audit +correction (reviewer blocker 1, folded): the single predicate site is the +keep-filter inside `clampEntryToCodexSupportedEfforts` — `effort.ts:357`, where +`kept` is built with `supported.has(...)`. A rung survives when it is in +`supported` **or** it is `max`/`ultra`. The same predicate gates BOTH default-repair +blocks — the Reserve branch's own repair at `effort.ts:363-367` (which returns +before the shared block) and the shared block at `effort.ts:378`. One predicate, +three places. No new module, no signature change at `sync.ts:1945` or +`convergence.ts:382`, no `bundled.ts` discovery change, no consumer binding. + +Explicitly rejected: re-adding the rungs after the clamp via +`ensureUltraReasoningLevel` (`effort.ts:300`). It no-ops on an empty ladder, and it +would leave `removedEfforts` naming rungs that were put back — a diagnostic that +lies. Also rejected: a floor allowlist, which would strip `none`/`minimal` that +current CLIs do parse. + +**Emission and admission stay separate (CLAMP-02).** +`supportedCodexReasoningEffortsFromObservedCatalog` (`effort.ts:313`) keeps +reporting what it observes, and `catalogEffortCompatibility` (`effort.ts:409`, +`src/client/catalog-compatibility.ts:47`) keeps refusing a hub catalog an old +runtime cannot parse. Making observation lie would reintroduce #4207: a hub client +on a leftover 0.135 CLI would write the file and then crash reading it. + +**Reserve (CLAMP-05).** `requiresExactReserveEfforts` (`effort.ts:344`) deletes a +row whose ladder empties (the `omitted`/`splice` at `effort.ts:466,472` are pure +effects of the emptied ladder — they get NO special case). The keep falls out of +the `:357` filter: when `max`/`ultra` are the sole survivors `kept` is non-empty, +so the row is kept with exactly those rungs. `{xhigh}` vs `{medium}` still +deletes; `{low,high}` vs `{medium,high}` still yields `{high}`. + +Phase 1 is not made redundant by this: the diagnostic still exists for other rungs, +and a same-version leftover listing only `max`/`ultra` would keep warning without +the CLAMP-04 filter. + +**Landing constraint (A-audit blocker 2, folded).** Phases 1 and 4 must not land +without Phase 2 in the same diff: `liveRemovedEfforts` already hides rungs that +`clampEntryToCodexSupportedEfforts` still removes, so landing 1+4 alone makes +`ocx status`/`ocx doctor` report "no clamp" while the next sync still strips the +rungs. One branch, one landing. + +Accept criteria: + +- With a fixture runtime whose bundled catalog stops at `xhigh`, a native row ends the sync carrying `max` and `ultra`. +- A genuinely absent rung that is NOT `max`/`ultra` is still removed — asserted explicitly, so this is provably an exemption and not a disabled clamp. +- `default_reasoning_level: "ultra"` is no longer rewritten to `xhigh` when the ladder kept `ultra` (`effort.ts:378`). +- `catalogEffortCompatibility` still reports `unsupportedEfforts: ["max"]` against an old-CLI ladder — the #4207 gate is unchanged. +- Activation scenario for the reserve branch: a reserve fixture whose source ladder is `max`/`ultra`-only against an observed `{medium}`; the observable effect is that the row appears in the written catalog instead of being spliced out, while the existing `{xhigh}` vs `{medium}` fixture still produces an omitted row. + +Files: `src/codex/catalog/effort.ts` (only). Unchanged by design: `sync.ts:1945`, `convergence.ts:382`, `bundled.ts`, `src/client/catalog-compatibility.ts`. + +### Phase 3 — Integration: keep the account roster's presentation fields + +**The first draft had this backwards.** The four `delete` sites are not why no card +appears — a pin-backed native row already carries both fields end to end, and +`tests/codex-integration/codex-catalog.test.ts:7398` pins exactly that. The carrier +exists. Full derivation in `020_architecture_dispositions.md`. + +The real loss is upstream. `src/codex/model-entitlements.ts` fetches +`https://chatgpt.com/backend-api/codex/models`, and `parseAccountModels` (`:536-546`) +keeps **only the slug** — `supported_in_api` and `visibility` are read as filters and +every other field, presentation included, is dropped on the floor. The set is then +used as an allowlist for account-gated natives (currently just Daybreak). So Astra's +row is always the pin or the bundled catalog, and both carry +`availability_nux: null`. + +Change: let the account roster contribute presentation fields for a native slug it +already authorises, through one small descriptor that names which fields may cross +that boundary and in which direction the account roster wins over the pin. That +descriptor is the reusable piece the user asked for — the next presentation field +becomes an entry rather than a new merge path. + +**Blocked on evidence, by design.** Whether the account roster carries copy the pin +lacks is unverified and needs a live ChatGPT token. If it does not, Phase 3 is +withdrawn rather than built — there would be nothing to carry, and a descriptor +with no producer is the ghost state `PLAN-FIELD-CHAIN-01` exists to prevent. + +PLAN-FIELD-CHAIN-01 for the descriptor: + +| Stage | Path | +| --- | --- | +| Creation | account roster response parsed at `src/codex/model-entitlements.ts:536`; today the only producer, and it currently produces nothing | +| Serialization | none — both fields already exist in the catalog JSON written by `sync.ts`; no new wire shape | +| Deserialization | `src/codex/catalog/parsing.ts` entry normalization; `ensureStrictCatalogFields` already routes by `isRouted` | +| Consumers | the merge in `sync.ts` (`finishUpstreamNativeEntry`, `:257`), plus the four existing sanitizers which stay as they are — `metadata.ts:567` (Daybreak capability alias), `sync.ts:354` (template clone), `parsing.ts:613` (routed), `reserve.ts:36` (Reserve). **N/A by design:** none of them gains a registry lookup, because each is already correct. | + +Accept criteria: + +- A native slug whose account-roster row carries `availability_nux` ends the sync carrying it, overriding a `null` pin. +- A routed row, the Daybreak capability alias, and a Reserve projection still lose the field — asserted per row kind, not once, so the fix is proved not to have widened. +- `parseAccountModels`' existing filtering (`supported_in_api !== true`, `visibility === "hide"`) is unchanged; a hidden row contributes no copy. +- Activation scenario: a fixture roster carrying copy for one native slug and nothing for another; the observable effect is one row with a message and one still `null` in the written catalog. + +Files: `src/codex/model-entitlements.ts`, `src/codex/catalog/sync.ts`, new test under `tests/codex-integration/` (needs an entry in both `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`, or a name matching the `codex-integration` regex seed). + +### Phase 4 — Hardening: say what happened + +`ocx doctor` currently suggests "set CODEX_CLI_PATH to a newer Codex binary" while +the selected binary is already newer — the advice is generated from the stale +diagnostic. Once Phase 1 lands, the message must distinguish "this runtime really +lacks the rung" from "a previous runtime lacked it". `doctor.ts:1180` does not call +`effortClampAppliesToRuntime` at all — it warns on any non-empty `removedEfforts` — +so status and doctor can disagree about the same file. Accept criterion: given one +leftover diagnostic, `ocx status` and `ocx doctor` reach the same verdict. Docs-site +follows only if Phase 2 changes user-visible behaviour, which it does: `max` and +`ultra` now appear on runtimes that previously hid them. + +Files: `src/cli/doctor.ts:1181`, `src/cli/status.ts:250`, `src/server/management/config-routes.ts:283`, `docs-site/`. + +## Scope boundary + +IN: `src/codex/catalog/effort.ts` (Phase 2), `src/codex/runtime.ts` (Phases 1 and 4), `src/cli/{status,doctor}.ts` and `src/server/management/config-routes.ts` (Phase 4), and — only if the Phase 3 probe succeeds — `src/codex/model-entitlements.ts` and `src/codex/catalog/sync.ts`. Matching tests, docs-site. + +OUT, and explicitly unchanged by design: `src/codex/catalog/{bundled,parsing,metadata,reserve}.ts`, `src/client/catalog-compatibility.ts` and `catalogEffortCompatibility` (CLAMP-02), `supportedCodexReasoningEffortsFromObservedCatalog`, `nativeEffortClamp` and the wire-clamp layer, `src/server/index.ts` route allowlist, `src/server/images.ts`, `src/lab/`, the `wham` client surface, and hand-authored `upstream-models.json` copy. There is no new presentation-field module; that idea was withdrawn. + +## PLAN-BYPASS-NAMED-01 + +The thing being enforced is the CLAMP-02 boundary: emission may exempt the two +rungs, hub admission may not. + +- Tier: E2 — repository tests. +- Executing surface: `tests/clients/client-catalog-compatibility.test.ts` plus `bun run test` in CI. +- Known bypass path: a contributor who adds the exemption to `supportedCodexReasoningEffortsFromObservedCatalog` instead of to `clampEntryToCodexSupportedEfforts` gets the same visible outcome locally and silently reopens #4207. The compatibility tests would catch that specific move; a new code path that recomputes the supported set elsewhere would not be caught at all. +- Residual risk: a local `ocx sync` on a leftover pre-0.14x CLI can now write a catalog that CLI cannot parse. Accepted by the ruling; hub clients still fail closed. +- Wording: **early warning**, not enforcement. Final layer: none. + +## Consultation record + +Architect consultation completed on `xai/grok-4.6` through the connected hub: +proposal (CLAMP-01..06) in `020_architecture_dispositions.md`, main dispositions in +the same file, reflection check returned **MISALIGNED** with five findings. +Findings 1 and 4 — the document contradicting its own loop-spec and scope — are +resolved in this revision. Findings 2, 3 and 5 are recorded unresolved in +`040_open_gaps.md`. Two explorers ran alongside on disjoint questions; their output +is `030_test_impact.md` and the Phase 3 correction in `020`. + +This plan has **not** passed an independent A audit. The reflection is the +architect checking its own proposal against main's rewrite; it does not substitute +for A. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md new file mode 100644 index 0000000000..b25bf214d4 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/010_evidence.md @@ -0,0 +1,134 @@ +# Evidence + +Collected 2026-09-11 on the reporting Windows machine. Read-only except for +`bun install`, which populated `node_modules` so the verifier could run. + +## 1. The clamp diagnostic outlives the binary it describes + +`~/.opencodex/codex-runtime-clamp.json`: + +```json +{ + "version": 1, + "updatedAt": "2026-09-10T12:01:09.525Z", + "runtimePath": "C:\\Users\\\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe", + "runtimeVersion": "0.135.0", + "removedEfforts": ["max", "ultra"], + "affectedModels": ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", + "anthropic/claude-fable-5-1", "anthropic/claude-opus-4-6", "anthropic/claude-opus-5"] +} +``` + +The binary at that exact path now reports `codex-cli 0.154.0`, and its own +bundled catalog carries both rungs: + +``` +$ codex debug models --bundled +... "slug":"gpt-6-astra" ... "supported_reasoning_levels":[ + {"effort":"low"...},{"effort":"medium"...},{"effort":"high"...}, + {"effort":"xhigh"...},{"effort":"max"...},{"effort":"ultra"...}] +``` + +`ocx status` nevertheless reports: + +``` +Codex version: 0.154.0 +Catalog clamp: active +Removed efforts: max, ultra +``` + +and `ocx doctor`: + +``` +ok Selected runtime: ...\codex.exe (0.154.0, source=configured) +!! max and ultra were removed during catalog sync. + Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync. +``` + +The advice is impossible to follow — the selected binary is already the newer one. + +## 2. Why: path equality short-circuits the version check + +`src/codex/runtime.ts:431` + +```ts +export function effortClampAppliesToRuntime(diagnostic, runtime): boolean { + if (!diagnostic || diagnostic.removedEfforts.length === 0) return false; + if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true; // <-- returns before version is read + return Boolean(diagnostic.runtimeVersion && runtime.version + && diagnostic.runtimeVersion === runtime.version); +} +``` + +The version comparison on the last line is only reachable when the paths +**differ**. An in-place upgrade — which is how the Windows Codex install updates — +keeps the path identical, so the stale diagnostic is treated as current forever. +Consumers: `src/cli/status.ts:250`, `src/server/management/config-routes.ts:283`. + +## 3. Where the effort ladder is derived + +- `src/codex/catalog/effort.ts:331` `codexSupportedReasoningEfforts` → `loadBundledCodexCatalog` +- `src/codex/catalog/bundled.ts:225` runs `debug models --bundled` +- `src/codex/catalog/bundled.ts:261` passes `discoverAlternatives: deps.discoverAlternatives ?? false` +- `src/codex/runtime.ts:603` `if (deps.discoverAlternatives === false) break;` — candidate search stops after the persisted entry +- `src/codex/runtime.ts:582` the persisted command is pushed first with source `configured` +- Applied at `src/codex/catalog/sync.ts:1945` and `src/codex/convergence.ts:382` + +So the machine-wide ladder is whatever the persisted binary reports, with no +notion of which client will render it. + +## 4. Card fields: deleted everywhere, carried nowhere — OPEN QUESTION + +Delete sites: + +| Path | Row kind | Fields | +| --- | --- | --- | +| `src/codex/catalog/metadata.ts:567` | alias | `availability_nux` | +| `src/codex/catalog/sync.ts:354` | routed | `upgrade = null`, `availability_nux` | +| `src/codex/catalog/parsing.ts:613` | routed | `availability_nux`, `upgrade` | +| `src/codex/catalog/reserve.ts:36` | reserve projection | `availability_nux` | + +Client side, for reference (openai/codex at submodule HEAD): + +- `codex-rs/protocol/src/openai_models.rs:409,410` — `ModelInfo.availability_nux`, `ModelInfo.upgrade` +- `codex-rs/tui/src/app/startup_prompts.rs:203,211` — NUX selection and a four-show cap +- `codex-rs/app-server/src/models.rs:31` — `upgrade` / `upgrade_info` forwarded to the desktop app + +**Unresolved.** `src/codex/data/upstream-models.json:928` has `availability_nux: null` +for `gpt-6-astra`, and so does the live `debug models --bundled` output above. Only +`gpt-5.6-sol` and `gpt-5.5` carry copy in the bundled catalog. That means the +bundled catalog may simply not be where Astra's announcement lives — the account +endpoint `backend-api/codex/models?client_version=...` is the other candidate, and +it has not been probed. **Phase 3 builds a carrier; whether there is anything to +carry for Astra is not yet established.** Probing it needs a live ChatGPT account +token and is a user decision, not an agent one. + +Separately, the `workspace-messages` channel (`headline` / `announcement`, +`codex-rs/backend-client/src/client.rs:651`) has zero references in this +repository. It is out of scope here and is gated client-side on +`auth.uses_codex_backend()` (`account_processor.rs:1307`), which is false for +`AuthMode::ApiKey` (`codex-rs/protocol/src/auth.rs:61`) — the mode produced by +`env_key` injection at `src/codex/inject.ts:327`. + +## 5. Verifier, actually run + +``` +$ bun install +103 packages installed + +$ bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts +42 pass, 0 fail, 160 expect() calls # exit 0 +``` + +Reads the change target: `tests/codex-integration/codex-runtime.test.ts:37` imports +`../../src/codex/runtime`; line 612 calls `effortClampAppliesToRuntime` directly. + +A first attempt failed with `Cannot find module 'zod/v4'` before `bun install` — +recorded because "the verifier passed" would otherwise be unverifiable. + +## 6. Not established + +- Whether Astra carries announcement copy on the account catalog endpoint (§4). +- Which consumer should own the shared catalog when Desktop and CLI disagree. +- Whether any non-Windows install reproduces §1; the in-place-upgrade shape was only observed here. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md new file mode 100644 index 0000000000..3c82aacaa0 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/020_architecture_dispositions.md @@ -0,0 +1,49 @@ +# Architecture consultation and main dispositions + +Architect: `xai/grok-4.6` via the connected hub, read-only, dispatched 2026-09-11. +Two explorers ran alongside it on disjoint questions (test impact; presentation-field +flow). Verdict on the first draft of `000_plan.md`: **MISALIGNED**. Main accepts most +of it; the dispositions below are what actually governs B. + +## Decisions + +| ID | Proposal | Main disposition | +| --- | --- | --- | +| CLAMP-01 | Keep the clamp; a rung survives if it is in `supported` **or** it is `max`/`ultra`. Apply the same predicate to the default-repair block (`effort.ts:378`), which today would still rewrite `default_reasoning_level: "ultra"` even on a ladder that kept `ultra`. Do not post-patch with `ensureUltraReasoningLevel` — it is a construction helper, no-ops on empty ladders, and would make `removedEfforts` report rungs that were put back. | **Accepted.** This is the mechanism. The default-repair catch is a real defect the plan missed. | +| CLAMP-02 | Do **not** union `max`/`ultra` into `supportedCodexReasoningEffortsFromObservedCatalog` (`effort.ts:313`), and do not touch `catalogEffortCompatibility` (`effort.ts:409`, `src/client/catalog-compatibility.ts:47`). Emission and hub admission are different questions; hub download stays fail-closed per #4207. | **Accepted, and promoted to a scope boundary.** A hub client on a leftover 0.135 CLI must keep refusing rather than writing a catalog it cannot parse. | +| CLAMP-03 | One predicate change in `clampEntryToCodexSupportedEfforts` (`effort.ts:348`). No new module, no signature change at `sync.ts:1945` or `convergence.ts:382`, no consumer binding in `runtime.ts`, no `bundled.ts` discovery change. | **Accepted.** Strictly smaller than the plan's Phase 2. | +| CLAMP-04 | Keep `codex-runtime-clamp.json`. After CLAMP-01 a sync whose only removals were `max`/`ultra` persists `null` and unlinks. Until that sync, filter the two rungs out of "active clamp" in a single helper that **both** `effortClampAppliesToRuntime` and `doctor.ts` call — today `ocx doctor` (`doctor.ts:1180`) never calls the helper `ocx status` uses (`status.ts:249`), so the two can disagree about the same file. | **Accepted.** This explains the observed contradiction in `010_evidence.md` §1 and is a second, independent defect. Phase 1 stays: it is necessary for non-`max`/`ultra` rungs and insufficient alone, because a same-version leftover listing only those two would still warn. | +| CLAMP-05 | Keep reserve exactness. `{xhigh}` vs `{medium}` still deletes the row; `{low,high}` vs `{medium,high}` still yields `{high}`. Only the case where `max`/`ultra` are the sole survivors changes: the row is **kept** instead of spliced out (`effort.ts:466,472`). | **Accepted.** This closes the escalation the plan left open. Add the focused reserve test; do not weaken the existing xhigh-vs-medium omission test. | +| CLAMP-06 | Drop the per-consumer projection. One shared `$CODEX_HOME/opencodex-catalog.json`, no consumer key in the diagnostic, no second catalog. Desktop/CLI disagreement is resolved by always offering the two rungs. | **Accepted.** The "who owns the shared file" blocker in the first draft is obsolete. | + +Residual risk accepted with CLAMP-06: a leftover pre-0.14x CLI reading the shared +file locally can fail to parse it. Hub clients still fail closed. Local `ocx sync` +does not, and that is the stated cost of the ruling. + +## Correction to Phase 3 — the premise was wrong + +The first draft assumed the four `delete` sites were why no card appears. The +explorer pass disproves it. **Pin-backed native rows already keep both fields end +to end** — `upstreamNativeEntry` deletes only `minimal_client_version`, +`finishUpstreamNativeEntry` (`sync.ts:257`) does not touch them, and +`ensureStrictCatalogFields` strips them only when `isRouted === true` +(`parsing.ts:609`). There is a test pinning exactly this: +`tests/codex-integration/codex-catalog.test.ts:7398` — "a native row keeps its own +eligibility metadata" — and `:3529` asserts Sol's `availability_nux` is defined. + +So the carrier exists. Three native-looking kinds still lose the field, each for a +defensible reason: the Daybreak capability alias (`metadata.ts:567`), older natives +not in `UPSTREAM_NATIVE_ENTRIES` when OpenCodex has to synthesize the row +(`deriveEntry`), and Reserve. + +**The actual gap is upstream of all four sites.** `src/codex/model-entitlements.ts` +does fetch `https://chatgpt.com/backend-api/codex/models`, but `parseAccountModels` +(`:536-546`) keeps nothing except the slug — every presentation field in that +response is discarded, and the set is used only as an allowlist for account-gated +natives (currently just Daybreak). Astra's row therefore comes from the pin or the +bundled catalog, both of which carry `availability_nux: null`. + +That reframes the open question in `010_evidence.md` §4. It is no longer "does the +carrier exist" but "does the account roster carry copy the pin does not, and should +it override the pin". The probe still needs a live account token and is still a user +decision. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md new file mode 100644 index 0000000000..c476f80df3 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/030_test_impact.md @@ -0,0 +1,57 @@ +# Test impact of the `max`/`ultra` exemption + +From the explorer pass (`xai/grok-4.6`, read-only). This is the list B inverts, and +it is the reason the loop-spec verifier row now names three files the first draft +did not. + +## Must invert — these encode the behaviour being removed + +`tests/codex-integration/codex-catalog.test.ts`, `describe("Codex reasoning-effort capability clamp")` at `:7027`: + +| Test | Line | Why it inverts | +| --- | --- | --- | +| the observed-state clamp is pure with respect to frozen runtime evidence | 7051 | expects `removedEfforts: ["max","ultra"]` and a default rewritten to `xhigh` | +| strips max and ultra when the installed Codex ladder stops at xhigh | 7077 | the exemption is precisely this case | +| falls back to the conservative universal ladder when every advertised effort is unsupported | 7095 | a `max`/`ultra`-only row must no longer collapse to `low/medium/high` | +| repairs an unsupported max default to the highest surviving xhigh rung | 7107 | `effort.ts:378`, the default-repair block CLAMP-01 also changes | + +`tests/codex-integration/codex-runtime.test.ts`: + +| Test | Line | Why it inverts | +| --- | --- | --- | +| clamp diagnostics include unsupported default_reasoning_level changes | 1005 (listed as 940 before the new Phase 1 tests shifted it) | runs the live clamp and expects `ultra` → `high` with `"ultra"` in `removedEfforts` | + +## Must keep passing — assert these explicitly, they are the proof it is an exemption + +- `preserves max and ultra when the installed Codex ladder includes them` — `codex-catalog.test.ts:7086` +- `is a no-op when the installed Codex binary cannot be probed` — `codex-catalog.test.ts:7115` +- `final clamp omits incompatible Reserve in-place without inventing efforts` — `reserve-catalog.test.ts:239`; `{xhigh}` vs `{medium}` still deletes the row. Fails only if the whole clamp is disabled, which is the mistake this plan is trying not to make. +- `partial effort intersection keeps only source efforts and a surviving default` — `reserve-catalog.test.ts:252` +- The four `#4207` cases in `tests/clients/client-catalog-compatibility.test.ts:37,51,76,97` — they do not mutate the catalog, and they fail **only** if CLAMP-02 is violated by also treating the two rungs as always compatible. They are the regression gate for the hub boundary. +- Runtime tests that seed `persistEffortClamp` themselves and therefore do not depend on live stripping: `codex-runtime.test.ts:601, 624, 815, 842` — line numbers verified stale by the A reviewer (the Phase 1 test insertions shifted them; the tests are found by name, not line). + +## Out of scope — a different layer, do not touch + +The wire clamp (`nativeEffortClamp`, `effort.ts:52`, consumed at +`src/server/responses/core.ts:2582`) still maps `max`/`ultra` down for natives that +only mock those rungs. Catalog advertisement and wire honesty are deliberately +split, as `structure/03_catalog-and-subagents.md:339` already records. Affected +suites that must stay green unchanged: `codex-v2-gate.test.ts:1821`, +`effort-policy.test.ts:434`, `reasoning-effort.test.ts:887`, +`openai-responses-passthrough.test.ts:559`, `claude-model-info.test.ts:63`, +`vision-reasoning-contract.test.ts:193`. + +Likewise the construction-side exactness suites — `catalog-go-exact-efforts.test.ts`, +`codex-v2-gate.test.ts:111-126`, the none-only and combo ladder pins in +`codex-catalog.test.ts` — fail only if "unconditional" is misread as "always **add** +`max`/`ultra`". It is not: Go rows, Luna, combo rows, and none-only custom ladders +keep their exact ladders. Anything that grows Muse to include `max` or Luna to +include `ultra` is a defect, not the feature. + +## New test file placement + +`tests/test-layout.test.ts:20` forbids a root-level file that resolves to a migrated +domain. A new file needs matching entries in `scripts/test-layout/layout.json` +`explicit` and `tests/fixtures/test-layout-expected.json`; the `codex-integration` +regex seed already matches an `effort-*.test.ts` name until those exist +(`layout.json:34`). diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md new file mode 100644 index 0000000000..783687138d --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/040_open_gaps.md @@ -0,0 +1,69 @@ +# Unresolved after the reflection check + +The architect reflection returned MISALIGNED with five findings. Two were document +coherence and are fixed in `000_plan.md`. These three are real and are **not** +closed. B does not start Phase 3, and does not implement CLAMP-04, until they are. + +## GAP-1 — CLAMP-04 contradicts tests that `030_test_impact.md` promises to keep green + +CLAMP-04 says a leftover diagnostic listing only `max`/`ultra` should stop counting +as an active clamp. But `tests/codex-integration/codex-runtime.test.ts:601` and +`:624` seed `persistEffortClamp` with exactly `removedEfforts: ["max","ultra"]` and +`["max"]` and then assert the diagnostic **is** active. `030_test_impact.md` lists +both as must-keep-passing. Both cannot be true. + +Two more files assert the same leftover shape live and are missing from `030` +entirely: `tests/cli/cli-status-json.test.ts:376` and +`tests/config/settings-stream-mode.test.ts:145`. + +Phase 1's accept criteria also require same-path-same-version to still return +`true` — which is precisely the leftover file on the reporting machine. + +**Disposition: RESOLVED 2026-09-11 — CLAMP-04 ships.** The user authorised the full +cycle with the working tree already carrying the CLAMP-04 implementation +(`liveRemovedEfforts` in `src/codex/runtime.ts`, doctor/status/config-routes aligned +to it, and the four seed-test rows inverted to `["xhigh"]`). That is the recorded +decision; see `050_revalidation.md`. + +Independent of that choice: `src/cli/doctor.ts:1180` did not call +`effortClampAppliesToRuntime`, so doctor and status could disagree about one file. +**Historical as of 2026-09-11** — `doctor.ts:1182-1189` now calls the shared +predicate; recorded in `050_revalidation.md`. + +## GAP-2 — Phase 3 names a consumer that cannot consume + +The reflection is right that the field chain skips a stage. The roster result is a +`ReadonlySet` on `CodexModelEntitlementSnapshot.modelsByAccount`; there is no +presentation payload anywhere until that cache shape changes. +`finishUpstreamNativeEntry` (`sync.ts:257`) clones the pin and takes no roster data, +so naming it as the consumer describes a path that does not exist. The missing +stages are fetch → snapshot shape → sync plumbing, and the plan names none of them. + +Worse for the stated goal: **Astra is not account-gated.** +`ACCOUNT_GATED_NATIVE_OPENAI_MODELS` (`src/codex/catalog/native-models.ts:50`) is +Daybreak alone, and `availableAccountGatedNativeModels` +(`model-entitlements.ts:1074`) filters only that set. So "a native slug the roster +already authorises" excludes the one model this whole thread is about. Overlaying +roster copy onto Astra is a new use of `/models`, not a descriptor on an existing +allowlist. + +And Daybreak — the one slug the roster does authorise — is a capability alias whose +`availability_nux` is deleted at `metadata.ts:567`. Phase 3 asserts the alias still +loses the field while also asserting the roster wins over the pin. Merge order is +unspecified, so those two accept rows can contradict each other. + +**Disposition: Phase 3 is withdrawn from the executable plan** and reduced to a +question: does `backend-api/codex/models?client_version=0.154.0` return +`availability_nux` for `gpt-6-astra` under a real account? If no, the whole phase +dies and the answer to "why is there no Astra card" is simply that upstream has not +shipped copy for it. If yes, Phase 3 is re-planned from the snapshot shape up, not +patched into `finishUpstreamNativeEntry`. + +## GAP-3 — citation nits + +- `020` cites `codex-catalog.test.ts:7398` for the native-keeps-eligibility pin; `:7398` is the comment, the test is `:7400`. +- `020` cites Sol's `availability_nux` assertion at `:3529`; it is `:3530`. +- `030` lists `client-catalog-compatibility.test.ts:97` as a fourth `#4207` case; it is an assertion inside the test at `:83`. + +Left uncorrected in place deliberately — the reflection is the record, and rewriting +the numbers without re-reading the files would be the same class of error. diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md new file mode 100644 index 0000000000..b034e7de09 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/050_revalidation.md @@ -0,0 +1,96 @@ +# P revalidation — second session, 2026-09-11 + +Session `01a08e7d-be48-72f0-9063-fb3f26ea2eb8` (hook-bound, CODEX_THREAD_ID verified +against the SessionStart line) resumed this unit after the first session stopped at +plan-only. This document is the P-phase revalidation record for the resumed cycle. + +## What changed since the plan was written + +1. **The user authorised the full cycle.** The first session's stop condition was + plan-only; the user then instructed this session to proceed (`진행해줘`) and to + use `xai/grok-4.6` subagents without a cap. `000_plan.md` loop spec updated. +2. **Phases 1 and 4 are already implemented, uncommitted**, on branch + `codex/260911-clamp-expiry` (base: `dev` @ `babb76449`). `git diff` shows: + - `src/codex/runtime.ts` — `liveRemovedEfforts` + `UNCLAMPABLE_REASONING_EFFORTS`; + `effortClampAppliesToRuntime` is now version-aware on the same-path branch + (Phase 1) and inert when only `max`/`ultra` are named (CLAMP-04). + - `src/cli/doctor.ts`, `src/cli/status.ts`, `src/server/management/config-routes.ts` + — all three surfaces now read the same predicate (Phase 4 core). + - Tests inverted per GAP-1: `codex-runtime.test.ts` (601-region seeds changed to + `["xhigh"]`, three new tests), `cli-status-json.test.ts:396,421`, + `settings-stream-mode.test.ts:163,190`. + The authorship of this diff is not recorded anywhere this session can see — no + other cxc session file exists and the ledger has no B entry. Treated as user- + authorised work in progress and adopted as this cycle's B baseline. +3. **GAP-1 resolved: CLAMP-04 ships.** The tree is the decision. See `040_open_gaps.md`. +4. **Phase 3 stays withdrawn** (GAP-2). The account-roster probe needs a live ChatGPT + token; not attempted. + +## Remaining B scope (revalidated against `030_test_impact.md`) + +- `src/codex/catalog/effort.ts` — the CLAMP-01 predicate at the keep-filter + (`clampEntryToCodexSupportedEfforts`, :357), gated default-repair at BOTH + `:363-367` (Reserve branch) and `:378` (shared block). The CLAMP-05 reserve keep + falls out of the filter — `:466,472` are effects, NO splice special-case + (A-audit round 2 correction). **Not yet implemented** — the tree diff does + not touch `effort.ts`, so until B lands it, `liveRemovedEfforts` is a forward + reference and status/doctor would under-report a clamp that sync still applies. +- Test inversions still pending: `codex-catalog.test.ts` :7051, :7077, :7095, :7107; + `codex-runtime.test.ts:1005`; reserve keep-case added near `reserve-catalog.test.ts:239`. +- Must-stay-green: `reserve-catalog.test.ts:239,252`, + `client-catalog-compatibility.test.ts:37,51,76,97` (CLAMP-02 / #4207 gate), + `codex-catalog.test.ts:7086,7115`. +- Docs-site: Phase 2 changes user-visible behaviour, so a docs note is owed (Phase 4). + +## Verifier re-run + +`bun test tests/codex-integration/codex-runtime.test.ts tests/codex-integration/catalog-go-exact-efforts.test.ts` +— attempted at P; queued behind a concurrent `bun run test:changed` (pid 11292, +started 12:20:56 by a process outside this session). Result recorded in C with the +fresh run. The verifier command exists and reads the target (unchanged from `010_evidence.md` §5). + +## Collision note + +A `bun run test:changed` run owned by another process is active in this working +tree. This session re-checks `git status`/`git diff` before every B edit and does +not revert hunks it did not write. +## A-audit round 1 synthesis (2026-09-11, reviewer `xai/grok-4.6` "Tesla") + +VERDICT: FAIL, two blockers. Both accepted, none rebutted. + +1. **Reserve keep must fall out of the `:357` keep-filter, not a `:466/:472` splice + exception.** Correct — a splice special-case would leave an empty-ladder Reserve + row. `000_plan.md` Phase 2 mechanism and CLAMP-05 paragraphs rewritten: the + predicate site is the filter at `effort.ts:357`; the Reserve default-repair is + `:363-367` (not `:378`, which is unreachable for Reserve because of the early + return); `:466/:472` are pure effects. +2. **Phases 1+4 must not land without Phase 2.** Correct — `liveRemovedEfforts` + already hides rungs the clamp still removes. Recorded as a landing constraint in + `000_plan.md`: one branch, one landing. + +Reviewer-verified facts folded into `030_test_impact.md`: the default-repair test +lives at `codex-runtime.test.ts:1005` (not 940); the must-keep persist-seed line +numbers are stale and those tests are located by name. + +Verifier baselines the reviewer ran fresh: Phase 1 pair 45 pass / 0 fail; Phase 2 +gate trio 346 pass / 0 fail (pre-CLAMP-01 baseline — inverting them is B's work). + +## B-phase discoveries (2026-09-11) + +- **030's invert list missed one test.** `bun run test:changed` caught + `codex-convergence-account-selectors.test.ts:916` ("convergence clamps native, routed, + and account rows to observed runtime support") still expecting `max`/`ultra` stripped. + Inverted: the four rows now assert the surviving-rungs invariant (observed ∪ + {max,ultra}), and the full-ladder routed row proves the exemption ran (ladder and + `ultra` default verbatim). The generic invariant replaced a blanket `toContain` because + account-projection rows legitimately ship narrow ladders (`["medium","max"]`) — the + exemption preserves, never adds. +- **Pre-existing environmental failures, proved on base.** `test:changed` also failed + cursor-integration-status (gateway `apiKeyMode`), update-pnpm ×3 (EFAULT / POSIX shims + on Windows), and a 5s bearer-admission timeout. A pristine worktree at the merge base + (`babb76449`) fails the same five, so they are not this diff's. Worktree removed after + the check. +- **codexclaw tooling issue filed.** `cxc session current`/`session bind` cannot resolve + the native session cwd on this desktop install (CODEX_THREAD_ID is set and matches the + SessionStart binding): https://github.com/lidge-jun/codexclaw/issues/134 +*** End Patch diff --git a/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md b/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md new file mode 100644 index 0000000000..0691a81241 --- /dev/null +++ b/devlog/_plan/260911_catalog_presentation_and_effort_projection/060_done.md @@ -0,0 +1,54 @@ +# Done — catalog presentation and effort projection (cycle 1) + +2026-09-11, session `01a08e7d-be48-72f0-9063-fb3f26ea2eb8`. Reader: someone who was +not in the loop. + +## Conclusion + +Phases 1, 2 and 4 landed on `codex/260911-clamp-expiry` and every gate that observes +them is green. Phase 3 (account-roster presentation fields) stays withdrawn: whether +`backend-api/codex/models` carries card copy the pin lacks is still unverified, and +the probe needs a live ChatGPT token, which is the user's call. + +## What changed + +- `fddbb7fad` — the code. `src/codex/runtime.ts` exports the single + `UNCLAMPABLE_REASONING_EFFORTS` set (`max`/`ultra`); `effortClampAppliesToRuntime` is + version-aware on the same-path branch (in-place Windows upgrades no longer keep a + stale diagnostic alive) and inert when only exempt rungs are named; + `src/codex/catalog/effort.ts:357` keeps those rungs in the observed-runtime + intersection and `:378` stops repairing an exempt default; the Reserve keep falls + out of the filter with no splice special-case; `ocx status`, `ocx doctor` and + `/api/settings` read one shared predicate. +- `docs(devlog)` commit — this unit (000-060). + +## Evidence + +- Focused gate: `bun test` on the 8 affected files — 503 pass / 0 fail, exit 0. +- `bun run typecheck` — exit 0. +- `bun run test:changed` — 14080 pass; the 4 unique failures (cursor `apiKeyMode`, + pnpm ×3, one 5s bearer timeout) were reproduced on a pristine worktree at merge + base `babb76449`, so they pre-date this diff. +- Activation observed live on the reporting machine: repo build prints `Catalog + clamp: inactive` and no doctor warning against the real leftover 0.135.0 + diagnostic at the unchanged binary path (now 0.154.0). +- Audit: three rounds with the same independent reviewer (`xai/grok-4.6`), + FAIL → FAIL → PASS; synthesis in `050_revalidation.md`. + +## What did not improve (LOOP-PESSIMIST-01) + +- The installed proxy (2.50.0) still ships the old behaviour; this machine's warning + clears only for a build that includes this branch. +- The four environmental test failures are untouched — they are not this unit's, + but they are also nobody's right now. +- The Astra card question is narrowed, not answered: bundled and pin both carry + `availability_nux: null` for `gpt-6-astra`, so if upstream ships copy at all it + lives on the account roster endpoint. If the probe shows nothing there either, + the honest answer to #4213 is that upstream has not shipped the copy. + +## Next + +- Push + PR to `dev` — needs explicit user approval (DEV-GIT-PUSH-01). PR text must + fill the template; no GUI surface changed, so no screenshot obligation. +- The account-roster probe (Phase 3 re-plan trigger) — user-authorized token only. +- A summary comment on #4213 with the catalog-field evidence — offered, not requested. diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 6f9b756789..b30240bf0e 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -254,6 +254,17 @@ On the wire, routed adapters map or clamp unsupported tiers. For older native mo ladder stops at `xhigh`, `nativeEffortClamp` maps a direct `max` or an `ultra` selection to `xhigh` (for example, GPT-5.5). Sol, Terra, and Luna have a real `max` rung. +Catalog advertisement of the two top tiers is unconditional: `ocx sync` no longer removes `max` or +`ultra` when the installed Codex binary is too old to offer them — Codex versions without those +rungs are out of support, and hiding them from current clients costs more than it buys. Other +rungs are still intersected with the observed runtime ladder, and a clamp diagnostic recorded by a +previous binary stops applying once the binary at that path reports a different version (the +in-place upgrade case), so `ocx status` and `ocx doctor` stop warning about a clamp the upgraded +runtime no longer needs. +Catalog visibility is not entitlement: advertising `max`/`ultra` does not guarantee the upstream +account or provider accepts the tier, and for older native models whose real ladder stops at +`xhigh` the wire clamp above still maps the selection down at request time. + ## Fast tier rules Codex stores fast mode as: diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index a769252620..0f8fffedf6 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -56,6 +56,8 @@ import { import { collectStartupHealth, formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; import { displayCodexRuntimePath, + effortClampAppliesToRuntime, + liveRemovedEfforts, loadLastEffortClamp, persistCodexRuntime, resolveAndPersistCodexRuntime, @@ -1177,9 +1179,14 @@ export async function runDoctor(args: string[] = []): Promise { console.log(" Suggested: set CODEX_CLI_PATH to the desired binary and run ocx sync."); console.log(" Optional: ocx doctor --fix-codex-runtime"); } + // Doctor used to warn on any non-empty `removedEfforts`, while `ocx status` asked + // `effortClampAppliesToRuntime` — so the two could disagree about the same file, and doctor + // would tell an operator to install a newer Codex while the resolved runtime was already + // newer than the one the diagnostic described. Both surfaces now read the same predicate. const lastClamp = loadLastEffortClamp(); - if (lastClamp && lastClamp.removedEfforts.length > 0) { - console.log(` !! ${lastClamp.removedEfforts.join(" and ")} were removed during catalog sync.`); + if (effortClampAppliesToRuntime(lastClamp, resolved.runtime)) { + const live = liveRemovedEfforts(lastClamp); + console.log(` !! ${live.join(" and ")} were removed during catalog sync.`); console.log(" Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync."); } } diff --git a/src/cli/status.ts b/src/cli/status.ts index 1fcbe33466..1b5d9bd508 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -8,7 +8,7 @@ import { diagnoseService, serviceLogPath } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; import { getCodexRoutingKind } from "../codex/inject"; import { diagnoseCodexShim } from "../codex/shim"; -import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime"; +import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime"; import { packageVersion } from "./help"; import { computeVersionSkew, type VersionSkew } from "./version-skew"; import { redactSecretString, redactUserPath } from "../lib/redact"; @@ -573,7 +573,7 @@ export async function collectStatus(): Promise { } if (clampActive) { warningParts.push( - `Catalog clamp removed: ${lastClamp!.removedEfforts.join(", ")}. Run ocx doctor for diagnosis and recovery.`, + `Catalog clamp removed: ${liveRemovedEfforts(lastClamp).join(", ")}. Run ocx doctor for diagnosis and recovery.`, ); } // A Grok fence naming a port we are not listening on is invisible everywhere else: @@ -611,7 +611,9 @@ export async function collectStatus(): Promise { warning: warningParts.length > 0 ? warningParts.join(" ") : null, catalogClamp: { active: clampActive, - removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [], + // Report what is still clamped, not what the file happens to name: a leftover written + // before the max/ultra exemption lists rungs nothing removes any more. + removedEfforts: clampActive ? [...liveRemovedEfforts(lastClamp)] : [], runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null, }, }; diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index ba324f12a3..f54792b2ec 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -46,6 +46,7 @@ import { displayCodexRuntimePath, persistEffortClamp, resolveAndPersistCodexRuntime, + UNCLAMPABLE_REASONING_EFFORTS, type EffortClampDiagnostic, } from "../runtime"; @@ -354,7 +355,13 @@ export function clampEntryToCodexSupportedEfforts( ? entry.supported_reasoning_levels as Array<{ effort?: string }> : null; if (levels && levels.length > 0) { - const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort)); + // A rung survives when the observed runtime offers it OR when it is one of the rungs the + // clamp no longer removes (max/ultra, per the unconditional-emission ruling): CLI versions + // that genuinely lack them are out of support, and hiding them from current clients costs + // more than it buys. Hub admission is a different question and stays fail-closed in + // `catalogEffortCompatibility` below. + const kept = levels.filter(level => typeof level?.effort === "string" + && (supported.has(level.effort) || UNCLAMPABLE_REASONING_EFFORTS.has(level.effort))); if (requiresExactReserveEfforts(entry)) { entry.supported_reasoning_levels = kept; if (kept.length === 0) { @@ -375,12 +382,19 @@ export function clampEntryToCodexSupportedEfforts( .map(level => ({ ...level })); } const currentDefault = entry.default_reasoning_level; - if (typeof currentDefault === "string" && !supported.has(currentDefault)) { - const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) - .flatMap(level => typeof (level as { effort?: string })?.effort === "string" - ? [(level as { effort: string }).effort] - : []); - entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving); + const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) + .flatMap(level => typeof (level as { effort?: string })?.effort === "string" + ? [(level as { effort: string }).effort] + : []); + // An exempt default survives only when the surviving ladder actually advertises it; + // otherwise the row would name a default the client cannot select (review: PR #4257). + if (typeof currentDefault === "string" + && !supported.has(currentDefault)) { + const exemptAndAdvertised = UNCLAMPABLE_REASONING_EFFORTS.has(currentDefault) + && surviving.includes(currentDefault); + if (!exemptAndAdvertised) { + entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving); + } } } @@ -466,7 +480,11 @@ export function clampCatalogModelsToObservedCodexSupport( const omitted = requiresExactReserveEfforts(entry) && hadLadder && after.size === 0; if (lost.length > 0 || defaultClamped || omitted) { for (const effort of lost) removed.add(effort); - if (defaultClamped && beforeDefault) removed.add(beforeDefault); + // An orphaned exempt default (ultra with no ultra rung in the ladder) is repaired for + // coherence, but nothing was removed from the offering — do not name it in the diagnostic. + if (defaultClamped && beforeDefault && !UNCLAMPABLE_REASONING_EFFORTS.has(beforeDefault)) { + removed.add(beforeDefault); + } if (typeof entry.slug === "string") affected.push(entry.slug); } if (omitted) models.splice(index, 1); diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 9a12395808..576f454c9c 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -427,13 +427,47 @@ function sameRuntimeCommand(a: string, b: string): boolean { return a.trim().toLowerCase() === b.trim().toLowerCase(); } -/** True when a persisted clamp diagnostic still applies to the currently selected runtime. */ +/** + * Rungs OpenCodex no longer lets the observed-runtime intersection remove, so a persisted + * diagnostic naming only these describes a policy that is gone rather than a live restriction. + * This is the single copy of the exemption: `catalog/effort.ts` imports it for the clamp + * predicate, and a leftover file written before the exemption must not keep warning about + * rungs the next sync will stop removing. + */ +export const UNCLAMPABLE_REASONING_EFFORTS: ReadonlySet = new Set(["max", "ultra"]); + +/** Removals that still describe a real restriction, ignoring rungs nothing clamps any more. */ +export function liveRemovedEfforts( + diagnostic: EffortClampDiagnostic | null | undefined, +): readonly string[] { + if (!diagnostic) return []; + return diagnostic.removedEfforts.filter(effort => !UNCLAMPABLE_REASONING_EFFORTS.has(effort)); +} + +/** + * True when a persisted clamp diagnostic still applies to the currently selected runtime. + * + * Two ways a stored diagnostic stops describing reality: + * + * 1. Every rung it names is one nothing clamps any more, so the file is inert until the next + * sync unlinks it. + * 2. The binary at that path was upgraded in place. Windows updates Codex without moving the + * executable, so path equality alone kept a 0.135.0 observation "current" for a 0.154.0 + * runtime whose own bundled catalog carried the rungs the diagnostic claimed were missing. + * A known version mismatch therefore wins over a path match; an unknown version on either + * side stays conservative, because absence of a version is not evidence of an upgrade. + */ export function effortClampAppliesToRuntime( diagnostic: EffortClampDiagnostic | null | undefined, runtime: Pick, ): boolean { - if (!diagnostic || diagnostic.removedEfforts.length === 0) return false; - if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) return true; + if (!diagnostic || liveRemovedEfforts(diagnostic).length === 0) return false; + if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) { + if (diagnostic.runtimeVersion && runtime.version) { + return diagnostic.runtimeVersion === runtime.version; + } + return true; + } return Boolean( diagnostic.runtimeVersion && runtime.version diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 527178ba0f..1faa056c8d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -110,7 +110,7 @@ import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { runWindowsTrayAction } from "../windows-tray-control"; import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; -import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; +import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -344,7 +344,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0 ? warningParts.join(" ") : null, diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 2aae43a671..c4fe01752a 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -396,7 +396,7 @@ describe("CLI status JSON", () => { persistEffortClamp({ runtimePath: fakeCodex, runtimeVersion: "0.133.0", - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], affectedModels: ["gpt-5.6-sol"], }, { configDir: opencodexHome }); resetCodexRuntimeResolveCacheForTests(); @@ -421,7 +421,7 @@ describe("CLI status JSON", () => { expect(parsed.codexRuntime?.version).toBe("0.133.0"); expect(parsed.codexRuntime?.catalogClamp).toEqual({ active: true, - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], runtimeVersion: "0.133.0", }); } finally { diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 38f362c81b..b3d263a488 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -7064,23 +7064,25 @@ describe("Codex reasoning-effort capability clamp", () => { const supported = supportedCodexReasoningEffortsFromObservedCatalog(observed); const clamp = clampCatalogModelsToObservedCodexSupport(models, supported); + // max and ultra are exempt from the observed-runtime intersection: nothing is removed, + // the ladder is untouched, and an ultra default survives a runtime that stops at xhigh. expect(clamp).toEqual({ - removedEfforts: ["max", "ultra"], - affectedModels: ["openrouter/example"], + removedEfforts: [], + affectedModels: [], }); expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) - .toEqual(["low", "medium", "high", "xhigh"]); - expect(models[0]!.default_reasoning_level).toBe("xhigh"); + .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + expect(models[0]!.default_reasoning_level).toBe("ultra"); expect(JSON.stringify(observed)).toBe(before); }); - test("strips max and ultra when the installed Codex ladder stops at xhigh", () => { + test("keeps max and ultra when the installed Codex ladder stops at xhigh", () => { const models = [routedEntry()]; clampCatalogModelsToCodexSupport(models, bundledCatalogDeps(["low", "medium", "high", "xhigh"])); expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) - .toEqual(["low", "medium", "high", "xhigh"]); + .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); }); test("preserves max and ultra when the installed Codex ladder includes them", () => { @@ -7092,7 +7094,7 @@ describe("Codex reasoning-effort capability clamp", () => { .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); }); - test("falls back to the conservative universal ladder when every advertised effort is unsupported", () => { + test("a max/ultra-only ladder survives instead of collapsing to the universal fallback", () => { const entry = { supported_reasoning_levels: [{ effort: "max" }, { effort: "ultra" }], default_reasoning_level: "ultra", @@ -7100,16 +7102,39 @@ describe("Codex reasoning-effort capability clamp", () => { clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high", "xhigh"])); + expect(entry.supported_reasoning_levels.map(level => level.effort)).toEqual(["max", "ultra"]); + expect(entry.default_reasoning_level).toBe("ultra"); + }); + + test("still falls back to the conservative universal ladder when every advertised effort is genuinely unsupported", () => { + const entry = { + supported_reasoning_levels: [{ effort: "xhigh" }], + default_reasoning_level: "xhigh", + }; + + clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium"])); + expect(entry.supported_reasoning_levels.map(level => level.effort)).toEqual(["low", "medium", "high"]); expect(clampedDefaultEffort("max", [])).toBe("medium"); }); - test("repairs an unsupported max default to the highest surviving xhigh rung", () => { + test("keeps an unclampable max default instead of repairing it down to xhigh", () => { const entry = routedEntry(); clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high", "xhigh"])); - expect(entry.default_reasoning_level).toBe("xhigh"); + expect(entry.default_reasoning_level).toBe("max"); + }); + + test("still repairs a genuinely unsupported default to the highest surviving rung", () => { + const entry = routedEntry(); + entry.default_reasoning_level = "xhigh"; + + clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high"])); + + expect(entry.supported_reasoning_levels.map(level => level.effort)) + .toEqual(["low", "medium", "high", "max", "ultra"]); + expect(entry.default_reasoning_level).toBe("high"); }); test("is a no-op when the installed Codex binary cannot be probed", () => { diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 9602e91309..ceb34ef6a2 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -913,7 +913,7 @@ test("disabled-provider selections cannot delete a foreign row in either writer" expect(readFileSync(catalogPath, "utf8")).toBe(convergenceBytes); }); -test("convergence clamps native, routed, and account rows to observed runtime support", async () => { +test("convergence clamps clampable rungs but keeps exempt max/ultra on native, routed, and account rows", async () => { grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); seedObservedRuntimeSupport(); writeCatalog([nativeEntry()]); @@ -931,6 +931,7 @@ test("convergence clamps native, routed, and account rows to observed runtime su const catalog = await convergeCatalog(nextConfig); const models = catalog.models ?? []; + const observed = ["low", "medium", "high", "xhigh"]; for (const slug of [ "gpt-5.6-sol", "static/reasoning-model", @@ -940,8 +941,11 @@ test("convergence clamps native, routed, and account rows to observed runtime su const entry = models.find(model => model.slug === slug); const efforts = (entry?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>; expect(entry).toBeDefined(); - expect(efforts.map(level => level.effort)).not.toContain("max"); - expect(efforts.map(level => level.effort)).not.toContain("ultra"); + // Every surviving rung is either observed or one of the exempt top tiers; the exemption + // preserves the max/ultra a row already advertises but never adds new rungs. + for (const level of efforts) { + expect(observed.includes(level.effort!) || level.effort === "max" || level.effort === "ultra").toBe(true); + } if (typeof entry?.default_reasoning_level === "string") { expect(efforts.some(level => level.effort === entry.default_reasoning_level)).toBe(true); } @@ -949,6 +953,12 @@ test("convergence clamps native, routed, and account rows to observed runtime su const cache = JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")) as { models?: RawEntry[]; }; + // The routed row advertises the full ladder with an ultra default: both exempt rungs and + // the default survive verbatim, which is the observable proof the exemption ran. + const routed = models.find(model => model.slug === "static/reasoning-model"); + expect((routed?.supported_reasoning_levels ?? []).map(level => (level as { effort?: string }).effort)) + .toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + expect(routed?.default_reasoning_level).toBe("ultra"); expect(cache.models).toEqual(models); }); diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index bf95f304a6..e413994ce6 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -23,6 +23,7 @@ import { compareCodexVersions, displayCodexRuntimePath, effortClampAppliesToRuntime, + liveRemovedEfforts, loadLastEffortClamp, loadPersistedCodexRuntime, parseCodexVersionOutput, @@ -603,11 +604,11 @@ describe("resolveCodexRuntime", () => { persistEffortClamp({ runtimePath: "C:\\Users\\Bob\\codex.exe", runtimeVersion: "0.133.0", - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], affectedModels: ["gpt-5.6-sol"], }, { configDir }); const loaded = loadLastEffortClamp({ configDir }); - expect(loaded?.removedEfforts).toEqual(["max", "ultra"]); + expect(loaded?.removedEfforts).toEqual(["xhigh"]); expect(loaded?.affectedModels).toEqual(["gpt-5.6-sol"]); expect(effortClampAppliesToRuntime(loaded, { command: "C:\\Users\\Bob\\codex.exe", @@ -621,6 +622,70 @@ describe("resolveCodexRuntime", () => { expect(loadLastEffortClamp({ configDir })).toBeNull(); }); + // The binary that produced the diagnostic is upgraded in place. Windows does exactly this, so + // path equality alone kept a 0.135.0 observation alive for a 0.154.0 runtime whose own bundled + // catalog carried the rungs the file claimed were missing. + test("a same-path runtime at a different version no longer inherits the diagnostic", () => { + const configDir = tempConfigDir(); + persistEffortClamp({ + runtimePath: "C:\\Users\\Bob\\codex.exe", + runtimeVersion: "0.135.0", + removedEfforts: ["xhigh"], + affectedModels: ["gpt-6-astra"], + }, { configDir }); + const loaded = loadLastEffortClamp({ configDir }); + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.154.0", + })).toBe(false); + // Same path, same version is still the runtime that produced it. + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.135.0", + })).toBe(true); + // An unknown version on either side is not evidence of an upgrade: stay conservative. + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: null, + })).toBe(true); + }); + + // max and ultra are exempt from the observed-runtime intersection, so a file naming only those + // describes a policy that no longer exists and must not keep the warning alive until the next + // sync unlinks it. + test("a diagnostic naming only max and ultra is inert", () => { + const configDir = tempConfigDir(); + persistEffortClamp({ + runtimePath: "C:\\Users\\Bob\\codex.exe", + runtimeVersion: "0.135.0", + removedEfforts: ["max", "ultra"], + affectedModels: ["gpt-6-astra"], + }, { configDir }); + const loaded = loadLastEffortClamp({ configDir }); + expect(loaded?.removedEfforts).toEqual(["max", "ultra"]); + expect(liveRemovedEfforts(loaded)).toEqual([]); + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.135.0", + })).toBe(false); + }); + + test("a mixed diagnostic still reports the rungs that are genuinely clamped", () => { + const configDir = tempConfigDir(); + persistEffortClamp({ + runtimePath: "C:\\Users\\Bob\\codex.exe", + runtimeVersion: "0.135.0", + removedEfforts: ["max", "ultra", "xhigh"], + affectedModels: ["gpt-6-astra"], + }, { configDir }); + const loaded = loadLastEffortClamp({ configDir }); + expect(liveRemovedEfforts(loaded)).toEqual(["xhigh"]); + expect(effortClampAppliesToRuntime(loaded, { + command: "C:\\Users\\Bob\\codex.exe", + version: "0.135.0", + })).toBe(true); + }); + test("creates missing config directory on first runtime/clamp persist", () => { const parent = tempConfigDir(); const configDir = join(parent, "nested", "opencodex-home"); @@ -940,6 +1005,8 @@ describe("resolveCodexRuntime", () => { test("clamp diagnostics include unsupported default_reasoning_level changes", async () => { const { clampCatalogModelsToCodexSupport } = await import("../../src/codex/catalog/effort"); const diagnostics: Array<{ removedEfforts: string[]; affectedModels: string[] }> = []; + // A genuinely unsupported (and clampable) default rung: xhigh. The exempt rungs + // (max/ultra) are covered by the no-diagnostic case below. const models = [{ slug: "openrouter/example", supported_reasoning_levels: [ @@ -947,7 +1014,7 @@ describe("resolveCodexRuntime", () => { { effort: "medium", description: "medium" }, { effort: "high", description: "high" }, ], - default_reasoning_level: "ultra", + default_reasoning_level: "xhigh", }]; clampCatalogModelsToCodexSupport(models, { commandCandidates: () => ["stub"], @@ -966,9 +1033,44 @@ describe("resolveCodexRuntime", () => { onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), }); expect(models[0]!.default_reasoning_level).toBe("high"); - expect(diagnostics[0]?.removedEfforts).toContain("ultra"); + expect(diagnostics[0]?.removedEfforts).toContain("xhigh"); expect(diagnostics[0]?.affectedModels).toEqual(["openrouter/example"]); }); + + // An exempt default only survives when the surviving ladder advertises it; an orphaned ultra + // default (no ultra rung in the ladder) is repaired down for catalog coherence, and because + // nothing was removed from the offering the repair produces no clamp diagnostic. + test("an orphaned ultra default is repaired without a clamp diagnostic", async () => { + const { clampCatalogModelsToCodexSupport } = await import("../../src/codex/catalog/effort"); + const diagnostics: Array<{ removedEfforts: string[]; affectedModels: string[] }> = []; + const models = [{ + slug: "openrouter/example", + supported_reasoning_levels: [ + { effort: "low", description: "low" }, + { effort: "medium", description: "medium" }, + { effort: "high", description: "high" }, + ], + default_reasoning_level: "ultra", + }]; + clampCatalogModelsToCodexSupport(models, { + commandCandidates: () => ["stub"], + execFileSync: () => JSON.stringify({ + models: [{ + slug: "gpt-5.5", + base_instructions: "x", + supported_reasoning_levels: [ + { effort: "low", description: "low" }, + { effort: "medium", description: "medium" }, + { effort: "high", description: "high" }, + ], + default_reasoning_level: "medium", + }], + }), + onEffortClamp: (diagnostic) => diagnostics.push(diagnostic), + }); + expect(models[0]!.default_reasoning_level).toBe("high"); + expect(diagnostics).toEqual([]); + }); }); describe("dead configured pin recovery (#4035)", () => { diff --git a/tests/codex-integration/reserve-catalog.test.ts b/tests/codex-integration/reserve-catalog.test.ts index 5abd28744d..7e7b46df84 100644 --- a/tests/codex-integration/reserve-catalog.test.ts +++ b/tests/codex-integration/reserve-catalog.test.ts @@ -249,6 +249,32 @@ describe("Reserve catalog metadata is not permission", () => { expect(diagnostic.removedEfforts).toContain("xhigh"); }); + test("a Reserve row whose sole survivors would be max/ultra is kept, not spliced out", () => { + const rows = merge(build(config(), [actualReserve({ + supported_reasoning_levels: [ + { effort: "max", description: "Source max" }, + { effort: "ultra", description: "Source ultra" }, + ], + default_reasoning_level: "ultra", + })])); + const diagnostic = clampCatalogModelsToObservedCodexSupport(rows, new Set(["medium"])); + // max/ultra are exempt from the observed-runtime intersection, so the ladder never + // empties and the omission branch never fires. + expect(rows.map(row => row.slug)).toContain("personal/gpt-reserve"); + expect(rows.find(isReserveCatalogProjection)).toMatchObject({ + supported_reasoning_levels: [ + { effort: "max", description: "Source max" }, + { effort: "ultra", description: "Source ultra" }, + ], + default_reasoning_level: "ultra", + }); + // Other rows in the merged catalog legitimately lose rungs against {medium}; the point + // is that nothing clampable was taken from the Reserve row. + expect(diagnostic.removedEfforts).not.toContain("max"); + expect(diagnostic.removedEfforts).not.toContain("ultra"); + expect(diagnostic.affectedModels).not.toContain("personal/gpt-reserve"); + }); + test("partial effort intersection keeps only source efforts and a surviving default", () => { const rows = merge(build(config(), [actualReserve({ supported_reasoning_levels: [ diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 514ba6110a..fb2f01579f 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -163,7 +163,7 @@ describe("GET /api/settings", () => { persistEffortClamp({ runtimePath: fakeCodex, runtimeVersion: "0.133.0", - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], affectedModels: ["gpt-5.6-sol"], }, { configDir: TEST_DIR }); @@ -190,7 +190,7 @@ describe("GET /api/settings", () => { expect(body.codexRuntime?.source).toBe("environment"); expect(body.codexRuntime?.catalogClamp).toEqual({ active: true, - removedEfforts: ["max", "ultra"], + removedEfforts: ["xhigh"], runtimeVersion: "0.133.0", }); expect(