diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md b/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md new file mode 100644 index 0000000000..adb48ece4e --- /dev/null +++ b/devlog/_plan/260913_model_picker_grouping_and_effort/000_plan.md @@ -0,0 +1,81 @@ +# 000 — Devin 추론 사다리: 피커 불일치와 Pi 컨트롤 부재 + +- 단위: `260913_model_picker_grouping_and_effort` +- 세션: `01a0985e-ce1a-7d12-81b9-c2e93a2bce67` (HOTL, cxc-loop) +- 기준: `origin/dev` `f7d9dbad03` (#4484 devin-cli→devin 병합 반영) + +## 사용자가 말한 세 가지 + +| # | 증상 | 판정 | +|---|---|---| +| 1 | "gemini 처리했던거처럼 묶는 기능"이 필요하다 | **부분 NOOP** — 접기는 이미 있음, 짝이 빠졌음 | +| 2 | 추론 매칭 | **실결함** | +| 3 | Pi 커넥터로 연결하면 추론 피커가 안 보임 | **실결함** | + +셋 다 뿌리가 하나다. `devin` 레지스트리 행에 `modelReasoningEfforts`가 없다. + +## Gemini 선례는 두 부분이다 + +공용 family-grouper 같은 건 없다. 사용자가 기억한 "gemini 처리"는 Antigravity 전용 +구현이고(도입 `c07f2d63dc`, 회귀 복구 `06f8e7a944`), 두 조각이 짝을 이룬다. + +| 조각 | Antigravity | Devin 현재 | +|---|---|---| +| A. wire variant를 base 한 줄로 접기 | `pickerModelIdForDiscoveredWireId` + `collapsesIntoKnownPickerModel` (`antigravity-models.ts:140-156`) | **있음** — `collapseDevinModelUid` (`live-models.ts:80-86`), `provider-fetch.ts:1706-1750` | +| B. 접힌 base에 effort 사다리 붙이기 | `ANTIGRAVITY_MODEL_EFFORTS` → `modelReasoningEfforts` (`registry.ts:2139`) | **없음** (`registry.ts:1340-1358`) | + +`antigravity-models.ts:148-150` 주석이 왜 짝이어야 하는지 직접 말한다. + +> `gemini-3.7-flash-high` looks like a model id and is not one: it is the "high" +> rung of `gemini-3.7-flash`, whose ladder lives in ANTIGRAVITY_MODEL_EFFORTS. +> Publishing it as its own row is what breaks effort selection. + +Devin은 A만 하고 B를 안 해서, 행은 하나로 접혔는데 그 행에 붙는 사다리가 Devin 것이 +아니다. 그래서 "묶는 기능이 필요하다"는 체감이 나온다 — 접기는 됐지만 쓸모가 없다. + +## 증상 2 — 추론 매칭 불일치 + +`modelReasoningEfforts`가 없으면 `applyReasoningLevels`(`effort.ts:231`)가 기본 +사다리로 떨어진다. + +| | 광고되는 사다리 | 실제 레인 | +|---|---|---| +| SWE-2 | low, medium, high, xhigh, max, ultra | **medium, high, max** (`devin.ts:116-126` `SWE2_EFFORT`) | + +`low`를 고르면 조용히 medium으로 올라가고, `xhigh`/`ultra`도 max로 접힌다. 컨트롤이 +말한 대로 동작하지 않는다. Anthropic 행 주석(`registry.ts:418-420`)이 정확히 이 기준을 +세워 뒀다 — "advertising it would offer a control that does not do what it says". + +## 증상 3 — Pi에서 컨트롤이 아예 안 그려짐 + +omp에 프로바이더 화이트리스트는 없다. `ompEfforts()`가 `ExportModel.reasoningEfforts` +(`contracts.ts:76`)를 걸러서 **비어 있지 않을 때만** `reasoning: true` + +`thinking: { mode: "effort", efforts }`를 쓴다(`omp.ts:72`). Devin은 그 배열이 비어 +있으니 컨트롤이 통째로 빠진다. + +이건 Pi만의 문제가 아니다. 같은 필드를 읽는 `pi`, `aside`, `prime`, `omo`, `zcode`, +`mcode`, `dsh`, `raycast`, OpenCode 계열이 전부 같이 깨진다. `cline` export는 effort +필드 자체가 없어 대상이 아니다. + +선례 커밋은 `df416a439c` (#3454, Anthropic)다. 어댑터는 원래 effort를 보내고 있었는데 +레지스트리가 사다리를 안 실어서 Pi형 피커가 숨었던, 글자 그대로 같은 결함이다. + +## 스크린샷에 대해 + +사용자 스크린샷의 `swe-2 (devin-cli)` 행은 Codex app composer이고 출처는 +`model_catalog_json`(`inject.ts:864`)이다. #4484가 `devin-cli` 행을 지웠으므로 그 라벨은 +현재 dev에 없다 — 스크린샷은 병합 전 상태이거나 재기동 전 캐시다. 이 단위는 그 라벨을 +쫓지 않고 사다리만 고친다. + +## 작업 단계 + +| wp | 문서 | 산출물 | +|---|---|---| +| wp0 | 이 문서 + 010 | 로드맵 | +| wp1 | (이 문서에 통합) | 선례 확정 — 완료 | +| wp2 | `010_devin_effort_ladder.md` | 레지스트리 사다리 + 테스트 → PR → merge | +| wp3 | 통합 | Pi 익스포트는 같은 변경으로 해결 | + +wp2와 wp3은 같은 한 줄짜리 원인을 공유하므로 PR 하나로 착지한다. 나누면 두 번째 +PR이 빈 변경이 된다. + diff --git a/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md b/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md new file mode 100644 index 0000000000..d28c0674cd --- /dev/null +++ b/devlog/_plan/260913_model_picker_grouping_and_effort/010_devin_effort_ladder.md @@ -0,0 +1,164 @@ +# 010 — wp2: Devin 모델별 추론 사다리 + +## NEW: src/providers/devin-models.ts 에 사다리 추가 — 또는 live-models.ts 확장 + +Antigravity는 `ANTIGRAVITY_MODEL_EFFORTS`를 `antigravity-models.ts`에 두고 레지스트리가 +import한다. Devin도 같은 자리에 둔다. `src/adapters/devin/live-models.ts`가 이미 +`DEVIN_MODEL_CONTEXT_WINDOWS`를 export하고 레지스트리가 그걸 쓰므로 같은 파일에 붙인다. + +```ts +/** + * Effort ladders per collapsed base model. + * + * Cognition spells effort as a model-id suffix, so the picker row that + * collapseDevinModelUid() produces needs its ladder declared here or the catalog + * falls back to the six-rung default. SWE-2 ships exactly three lanes, so + * advertising low, xhigh or ultra would offer a control that silently rounds to + * one of these three - the bar registry.ts:418-420 already sets for Anthropic. + */ +export const DEVIN_MODEL_EFFORTS: Record = { + "swe-2": ["medium", "high", "max"], +}; + +/** + * Ladder for a model this table does not name, including anything the live + * catalog discovers. Five rungs rather than six: `ultra` has no Cognition lane. + */ +export const DEVIN_DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +``` + +## MODIFY: src/providers/registry.ts — devin 행 + +```ts +// import 줄 +import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../adapters/devin/live-models"; + +// devin 행 (1340-1358) 끝에 두 줄 + modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, ++ modelReasoningEfforts: DEVIN_MODEL_EFFORTS, ++ reasoningEfforts: DEVIN_DEFAULT_EFFORTS, +``` + +`modelReasoningEfforts`가 Codex 피커의 모델별 사다리를 정하고(Antigravity 선례), +`reasoningEfforts`가 Pi 형태 익스포트의 `ExportModel.reasoningEfforts`를 채운다 +(Anthropic 선례 `df416a439c`). 한 변경이 두 표면을 동시에 고친다. + +## 왜 SWE-2만 표에 넣는가 + +라이브 카탈로그의 모델별 실제 suffix 집합은 계정마다 다르고 이 세션에서 실측하지 +않았다. 확증된 것은 `SWE2_EFFORT`(`devin.ts:116-126`)가 박아 둔 SWE-2의 3레인뿐이다. +나머지는 5단 기본값으로 두고, 실측이 생기면 표에 줄을 추가한다. 모르는 사다리를 +지어내는 것보다 낫다. + +## NEW: tests/providers/devin-effort-ladder.test.ts + +| 케이스 | 기대 | +|---|---| +| `DEVIN_MODEL_EFFORTS["swe-2"]` | `["medium","high","max"]` — `low`/`xhigh`/`ultra` 없음 | +| 표의 모든 사다리가 `SWE2_EFFORT`의 치역에 포함 | 광고와 실제 레인 일치 (드리프트 가드) | +| devin 레지스트리 행이 두 필드를 모두 노출 | 두 표면 회귀 | +| `DEVIN_DEFAULT_EFFORTS`에 `ultra` 없음 | Cognition 레인 없음 | +| omp export가 Devin 모델에 `thinking.mode = "effort"`를 씀 | Pi 회귀 — `management-client-config-route.test.ts:181` Anthropic 케이스 복제 | + +마지막 줄이 증상 3의 직접 회귀다. 기존 Anthropic 케이스가 그대로 본이 된다. + +## 레이아웃 등록 + +- `scripts/test-layout/layout.json` `explicit` +- `tests/fixtures/test-layout-expected.json` + +`devin-adapter.test.ts`가 `providers`로 등록돼 있으니 같은 값을 쓴다. + +## 범위 밖 + +레인 C가 #4484 후속으로 남긴 것들 — `src/adapters/registry.ts:26-30`의 구 주석, +`DEVIN_STATIC_MODELS`에 swe-2 부재, `stale-context-window-migration.ts:45-56`의 구 +로스터. 사다리와 무관하므로 이 PR에서 건드리지 않는다. + + +## 계획 수정 — 전 모델 적용 (사용자 지시) + +"swe 뿐만 아니라 모든 devin 모델들에 대해 적용해야" 한다는 지시를 받았다. 정적 표로 +전 모델을 채우려면 계정마다 다른 로스터를 지어내야 하므로, 설계를 바꾼다. + +**라이브 카탈로그가 이미 답을 알고 있고 우리가 버리고 있다.** + +`fetchDevinUsableModels`(`live-models.ts:97-127`)는 `catalog.byUid`를 돌면서 +`collapseDevinModelUid`로 접미사를 벗긴다. 벗겨낸 그 토큰이 곧 그 모델의 실제 +사다리다. 지금은 버려지고 base id만 남는다. `contextWindows`를 base별로 모으는 것과 +똑같은 자리에서 efforts도 모으면 된다. + +전달 채널도 이미 있다 — `CatalogModel.reasoningEfforts?: string[]` +(`parsing.ts:114`)와 `defaultReasoningEffort`(`:115`). + +### 바뀐 diff 계획 + +**MODIFY `src/adapters/devin/live-models.ts`** — 결과 타입에 `efforts` 추가. + +```ts +export type DevinUsableModelsResult = + | { ok: true; models: string[]; contextWindows: Record; + efforts: Record } + | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; +``` + +수집 루프에서, 벗겨낸 토큰 중 Codex 사다리에 해당하는 것만 모은다. + +```ts +const efforts = new Map>(); +// ...루프 안, base 계산 직후 +for (const token of devinEffortTokensOf(entry.modelUid, base)) { + if (!CODEX_REASONING_RUNGS.has(token)) continue; // fast/priority/1m 제외 + (efforts.get(base) ?? efforts.set(base, new Set()).get(base)!).add(token); +} +``` + +`fast`, `priority`, `1m`은 추론 단계가 아니라 티어·변형이므로 사다리에서 뺀다. +접미사 변형이 하나도 없는 base는 사다리가 비고, 그러면 컨트롤이 안 붙는다 — 그게 +정직한 결과다. + +**MODIFY `src/codex/catalog/provider-fetch.ts:1736-1744`** — base별 사다리를 싣는다. + +```ts + const result = liveResult.models.map((id) => { + const liveWindow = liveResult.contextWindows[id]; + const liveEfforts = liveResult.efforts[id]; + return { + id, + provider: name, + ...(liveWindow ? { contextWindow: liveWindow } : {}), + ...(liveEfforts?.length ? { reasoningEfforts: liveEfforts } : {}), + ...catalogHintsFromProviderConfig(...), + } as CatalogModel; + }); +``` + +`catalogHintsFromProviderConfig`를 뒤에 두는 순서는 그대로다. contextWindow와 같은 +이유로, 사용자가 명시한 오버라이드가 계속 이긴다. + +**MODIFY `src/providers/registry.ts`** — degraded 모드 폴백. + +라이브 카탈로그가 없을 때(로그인 전, 쿨다운, 네트워크 실패)는 시드 로스터가 쓰인다. +그때를 위한 정적 표와, Pi 형태 익스포트가 읽는 프로바이더 레벨 기본값을 둔다. + +```ts + modelReasoningEfforts: DEVIN_MODEL_EFFORTS, + reasoningEfforts: DEVIN_DEFAULT_EFFORTS, +``` + +정적 표에는 실측된 것만 넣는다 — 현재는 `swe-2: ["medium","high","max"]` +(`SWE2_EFFORT` `devin.ts:116-126`이 박아 둔 3레인). 나머지는 기본값을 쓰고, 로그인 +후에는 라이브 값이 덮는다. 모르는 사다리를 지어내지 않는다. + +### 이 설계가 나은 이유 + +| | 정적 표만 | 라이브 파생 | +|---|---|---| +| 커버리지 | 손으로 적은 모델만 | **계정이 가진 전 모델** | +| 정확도 | 작성 시점 추측 | 계정의 실제 카탈로그 | +| 새 모델 | 코드 수정 필요 | 자동 | +| 계정별 차이 | 표현 불가 | 자연히 반영 | + +Antigravity가 정적 표를 쓰는 건 그쪽 로스터가 고정이기 때문이다. Devin은 계정마다 +다르고 이미 라이브 디스커버리를 하므로, 같은 목적지에 더 맞는 길이 있다. + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4ad1cdf000..4d1d6ce272 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -645,6 +645,7 @@ "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", + "devin-effort-ladder.test.ts": "providers", "devin-login.test.ts": "providers", "devin-provider-merge-migration.test.ts": "providers", "devin-hardening.test.ts": "providers", diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts index addd18a5c3..bfd75e70bb 100644 --- a/src/adapters/devin/live-models.ts +++ b/src/adapters/devin/live-models.ts @@ -85,8 +85,61 @@ export function collapseDevinModelUid(uid: string): string { return parts.join("-"); } +/** + * The subset of catalog suffix tokens that are reasoning rungs. + * + * `fast`, `priority` and `1m` are service tiers and context variants, not effort. + * Offering them on a reasoning control would name a setting that does something + * else, so the collapse keeps stripping them while the ladder ignores them. + */ +const REASONING_RUNG_TOKENS = new Set(["none", "low", "medium", "high", "xhigh", "max"]); + +/** + * The reasoning rungs a catalog UID carries, in ladder order. + * + * Cognition spells effort as a suffix on the model id, so the variants an account + * actually has ARE its ladder — and collapseDevinModelUid() was throwing exactly + * that evidence away. Reading it back is what lets every model advertise the rungs + * it can really run instead of inheriting the generic six-rung default. + */ +export function devinReasoningRungsOf(uid: string): string[] { + const parts = uid.split("-"); + const rungs: string[] = []; + while (parts.length > 1 && EFFORT_TOKENS.has(parts[parts.length - 1]!)) { + const token = parts.pop()!; + if (REASONING_RUNG_TOKENS.has(token)) rungs.push(token); + } + return rungs; +} + +/** Ladder order for display, matching the Codex rung order. */ +const RUNG_ORDER = ["none", "low", "medium", "high", "xhigh", "max"]; +export function sortDevinRungs(rungs: Iterable): string[] { + return [...new Set(rungs)].sort((a, b) => RUNG_ORDER.indexOf(a) - RUNG_ORDER.indexOf(b)); +} + +/** + * Degraded-mode ladders, used only before the account catalog is readable. + * + * Only measured entries belong here. SWE-2 ships exactly three native lanes + * (see SWE2_EFFORT in src/adapters/devin.ts); inventing ladders for the rest + * would advertise rungs nobody verified, and the live catalog replaces this + * table as soon as a credential is present. + */ +export const DEVIN_MODEL_EFFORTS: Record = { + "swe-2": ["medium", "high", "max"], +}; + +/** + * Provider-level fallback ladder. No `ultra`: Cognition has no such lane, and + * the Codex catalog re-adds its own top rungs anyway (src/codex/catalog/effort.ts). + * Clients that key an effort control off this list — the Pi-shaped exports — get + * a control instead of none. + */ +export const DEVIN_DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + export type DevinUsableModelsResult = - | { ok: true; models: string[]; contextWindows: Record } + | { ok: true; models: string[]; contextWindows: Record; efforts: Record } | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; /** @@ -105,6 +158,8 @@ export async function fetchDevinUsableModels(opts: { if (!catalog) return { ok: false, error: "empty" }; const bases = new Set(); const contextWindows: Record = {}; + // Effort rungs per base, recovered from the suffixes the collapse strips. + const rungs = new Map>(); for (const entry of catalog.byUid.values()) { if (entry.disabled) continue; // Skip internal enum constants (e.g. MODEL_GPT_5_2_LOW, MODEL_PRIVATE_*). @@ -112,6 +167,12 @@ export async function fetchDevinUsableModels(opts: { if (entry.modelUid.startsWith("MODEL_")) continue; const base = collapseDevinModelUid(entry.modelUid); bases.add(base); + const found = devinReasoningRungsOf(entry.modelUid); + if (found.length > 0) { + let set = rungs.get(base); + if (!set) { set = new Set(); rungs.set(base, set); } + for (const rung of found) set.add(rung); + } if (entry.contextWindow && entry.contextWindow > 0) { // Variants of one base can disagree: the opt-in `-1m` rows report a // larger window than the plain row of the same base, and both collapse @@ -124,7 +185,13 @@ export async function fetchDevinUsableModels(opts: { } } if (bases.size === 0) return { ok: false, error: "empty" }; - return { ok: true, models: [...bases].sort(), contextWindows }; + const efforts: Record = {}; + for (const [base, set] of rungs) { + // A single rung is not a choice, so it is not a control. Advertising one + // would draw a picker whose only option is the value already in effect. + if (set.size > 1) efforts[base] = sortDevinRungs(set); + } + return { ok: true, models: [...bases].sort(), contextWindows, efforts }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/unauth|401|invalid token|login/i.test(message)) return { ok: false, error: "auth", detail: message }; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index cdf7806f73..f4560a03b8 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1739,6 +1739,12 @@ async function fetchProviderModelsWithAuth( id, provider: name, ...(liveWindow ? { contextWindow: liveWindow } : {}), + // The account catalog names the effort variants each base model has, so + // its ladder is measured rather than assumed. Without this the entry + // inherits the generic routed ladder and offers rungs the model rounds + // away, and every client that keys an effort control off this field — + // the Pi-shaped exports — renders no control at all. + ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}), ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), } as CatalogModel; }); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 78e645d029..b743354336 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,7 +1,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; -import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { @@ -1356,6 +1356,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, defaultModel: "swe-2", modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, + // Degraded-mode ladders only. Once a credential is present the account + // catalog supplies each base model its measured rungs; these two fields are + // what a signed-out picker and the Pi-shaped client exports fall back to. + modelReasoningEfforts: DEVIN_MODEL_EFFORTS, + reasoningEfforts: DEVIN_DEFAULT_EFFORTS, }, { id: "xai", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 273d132228..2d8bde9618 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -477,6 +477,7 @@ "desktop-remote-store.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", + "devin-effort-ladder.test.ts": "providers", "devin-hardening.test.ts": "providers", "devin-prompt-cache.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", diff --git a/tests/providers/devin-effort-ladder.test.ts b/tests/providers/devin-effort-ladder.test.ts new file mode 100644 index 0000000000..0b800c11a8 --- /dev/null +++ b/tests/providers/devin-effort-ladder.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { + DEVIN_DEFAULT_EFFORTS, + DEVIN_MODEL_EFFORTS, + collapseDevinModelUid, + devinReasoningRungsOf, + sortDevinRungs, +} from "../../src/adapters/devin/live-models"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + +const devinRow = () => PROVIDER_REGISTRY.find(row => row.id === "devin")!; + +describe("devin reasoning rungs come from the catalog suffixes", () => { + // Cognition spells effort as a model-id suffix, so the variants an account has + // ARE its ladder. The collapse used to strip that evidence and drop it. + test.each([ + ["swe-2-high", "swe-2", ["high"]], + ["gpt-5-6-sol-medium", "gpt-5-6-sol", ["medium"]], + ["gpt-5-6-sol-medium-priority", "gpt-5-6-sol", ["medium"]], + ["swe-2", "swe-2", []], + ])("%p collapses to %p with rungs %p", (uid, base, rungs) => { + expect(collapseDevinModelUid(uid)).toBe(base); + expect(devinReasoningRungsOf(uid)).toEqual(rungs); + }); + + // `fast`, `priority` and `1m` are tiers and context variants. Offering them on a + // reasoning control would name a setting that does something else. + test.each(["gpt-5-6-sol-priority", "swe-2-fast", "kimi-k3-1m"])( + "%p contributes no reasoning rung", + (uid) => { + expect(devinReasoningRungsOf(uid)).toEqual([]); + }, + ); + + test("rungs sort into ladder order, not discovery order", () => { + expect(sortDevinRungs(["max", "low", "high", "medium"])).toEqual(["low", "medium", "high", "max"]); + expect(sortDevinRungs(["high", "high"])).toEqual(["high"]); + }); +}); + +describe("devin advertises a ladder instead of inheriting the generic one", () => { + test("the provider row carries both fields", () => { + // modelReasoningEfforts drives the Codex picker; reasoningEfforts is what the + // Pi-shaped client exports read. Without them the row inherited the routed + // six-rung default and Pi drew no control at all. + const row = devinRow(); + expect(row.modelReasoningEfforts).toBeDefined(); + expect(row.reasoningEfforts).toBeDefined(); + expect(row.reasoningEfforts!.length).toBeGreaterThan(1); + }); + + test("SWE-2 advertises only the lanes it actually runs", () => { + // src/adapters/devin.ts SWE2_EFFORT maps every caller effort onto exactly + // these three. Advertising low or xhigh would offer a control that silently + // rounds to one of them. + expect(DEVIN_MODEL_EFFORTS["swe-2"]).toEqual(["medium", "high", "max"]); + }); + + test("the fallback ladder omits ultra, which Cognition has no lane for", () => { + expect(DEVIN_DEFAULT_EFFORTS).not.toContain("ultra"); + expect(DEVIN_DEFAULT_EFFORTS).toContain("medium"); + }); + + test("every static ladder is a subset of the fallback vocabulary", () => { + // A drift guard: a table entry naming a rung the provider vocabulary does not + // have would advertise a control the adapter cannot honour. + const vocabulary = new Set([...DEVIN_DEFAULT_EFFORTS, "none"]); + for (const [model, ladder] of Object.entries(DEVIN_MODEL_EFFORTS)) { + for (const rung of ladder) { + expect({ model, rung, known: vocabulary.has(rung) }).toMatchObject({ known: true }); + } + } + }); +});