diff --git a/src/shared/effortOrder.test.ts b/src/shared/effortOrder.test.ts new file mode 100644 index 000000000..494e34b0a --- /dev/null +++ b/src/shared/effortOrder.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { sortEffortsByCanonicalOrder } from "./effortOrder"; + +describe("sortEffortsByCanonicalOrder", () => { + it("orders a scrambled ladder weakest to strongest", () => { + expect(sortEffortsByCanonicalOrder(["xhigh", "low", "medium", "none"])).toEqual([ + "none", + "low", + "medium", + "xhigh", + ]); + }); + + it("keeps an already canonical ladder untouched", () => { + const efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + expect(sortEffortsByCanonicalOrder(efforts)).toEqual(efforts); + }); + + it("ranks extra-high spellings with xhigh", () => { + expect(sortEffortsByCanonicalOrder(["max", "extra-high", "low"])).toEqual([ + "low", + "extra-high", + "max", + ]); + }); + + it("appends unknown levels after the ladder in discovery order", () => { + expect(sortEffortsByCanonicalOrder(["turbo", "high", "on", "low"])).toEqual([ + "low", + "high", + "turbo", + "on", + ]); + }); + + it("does not mutate the input", () => { + const efforts = ["high", "low"]; + sortEffortsByCanonicalOrder(efforts); + expect(efforts).toEqual(["high", "low"]); + }); + + it("handles empty and single-element lists", () => { + expect(sortEffortsByCanonicalOrder([])).toEqual([]); + expect(sortEffortsByCanonicalOrder(["high"])).toEqual(["high"]); + }); + + it("preserves discovery order when every level is unknown", () => { + expect(sortEffortsByCanonicalOrder(["turbo", "ludicrous", "on"])).toEqual([ + "turbo", + "ludicrous", + "on", + ]); + }); + + it("normalizes case and surrounding whitespace before ranking", () => { + expect(sortEffortsByCanonicalOrder([" HIGH ", "low", "Extra-High"])).toEqual([ + "low", + " HIGH ", + "Extra-High", + ]); + }); +}); diff --git a/src/shared/effortOrder.ts b/src/shared/effortOrder.ts new file mode 100644 index 000000000..e14cb0c15 --- /dev/null +++ b/src/shared/effortOrder.ts @@ -0,0 +1,31 @@ +/** + * Canonical low→high ordering for reasoning-effort ladders. + * + * Providers advertise their effort levels in whatever order their CLI happens + * to emit — Qoder's ACP `reasoning_effort` selector, for example, reports + * `xhigh, low, medium, none`, which draws the picker out of order. Sorting on + * this ladder keeps every provider's effort menu reading weakest → strongest. + */ +const CANONICAL_EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; + +const CANONICAL_EFFORT_ALIASES: Record = { + "extra-high": "xhigh", + extra_high: "xhigh", + "very-high": "xhigh", +}; + +function effortRank(effort: string): number { + const key = effort.trim().toLowerCase(); + const canonical = CANONICAL_EFFORT_ALIASES[key] ?? key; + const index = CANONICAL_EFFORT_ORDER.indexOf(canonical); + return index === -1 ? CANONICAL_EFFORT_ORDER.length : index; +} + +/** + * Sort an effort list weakest → strongest. Values outside the canonical ladder + * (a provider-specific level such as Kimi's untiered `on`) keep their relative + * discovery order and land after the known tiers, so nothing is ever hidden. + */ +export function sortEffortsByCanonicalOrder(efforts: readonly string[]): string[] { + return [...efforts].sort((left, right) => effortRank(left) - effortRank(right)); +} diff --git a/src/supervisor/agents/acp/probe.test.ts b/src/supervisor/agents/acp/probe.test.ts index f0e57931c..f0dfb5d5e 100644 --- a/src/supervisor/agents/acp/probe.test.ts +++ b/src/supervisor/agents/acp/probe.test.ts @@ -367,12 +367,36 @@ describe("mapAcpThoughtLevels", () => { }, ]); + // Qoder advertises the levels out of order; the probe sorts them + // weakest -> strongest so the effort picker reads as a ladder. expect(result).toEqual({ - efforts: ["xhigh", "high", "low"], + efforts: ["low", "high", "xhigh"], defaultEffort: "xhigh", }); }); + it("sorts unknown effort levels after the canonical ladder, in discovery order", () => { + const result = mapAcpThoughtLevels([ + { + id: "thought_level", + category: "thought_level", + type: "select", + currentValue: "on", + options: [ + { value: "on", name: "On" }, + { value: "max", name: "Max" }, + { value: "turbo", name: "Turbo" }, + { value: "low", name: "Low" }, + ], + }, + ]); + + expect(result).toEqual({ + efforts: ["low", "max", "on", "turbo"], + defaultEffort: "on", + }); + }); + it("preserves ACP metadata for toggle-only reasoning selectors", () => { const result = mapAcpThoughtLevels([ { diff --git a/src/supervisor/agents/acp/probe.ts b/src/supervisor/agents/acp/probe.ts index 7aad2683d..392cde907 100644 --- a/src/supervisor/agents/acp/probe.ts +++ b/src/supervisor/agents/acp/probe.ts @@ -21,6 +21,7 @@ import { type SessionMode, } from "@agentclientprotocol/sdk"; import type { AgentSlashCommand, AuthState, ThreadMode } from "@/shared/contracts"; +import { sortEffortsByCanonicalOrder } from "@/shared/effortOrder"; import { terminateChildProcessTree } from "@/shared/processTree"; import { findThoughtLevelConfigOption, @@ -328,9 +329,13 @@ export function mapAcpThoughtLevels(configOptions: unknown): { return { efforts: [] }; } - const efforts = flattenSelectOptions(option.options) - .map((entry) => entry.value) - .filter((value): value is string => typeof value === "string" && value.length > 0); + // Agents advertise the levels in their own order (qodercli reports + // `xhigh, low, medium, none`); present them weakest → strongest instead. + const efforts = sortEffortsByCanonicalOrder( + flattenSelectOptions(option.options) + .map((entry) => entry.value) + .filter((value): value is string => typeof value === "string" && value.length > 0), + ); const defaultEffort = typeof option.currentValue === "string" && option.currentValue.length > 0 diff --git a/src/supervisor/agents/opencode/detection.ts b/src/supervisor/agents/opencode/detection.ts index 310c06010..529824cd0 100644 --- a/src/supervisor/agents/opencode/detection.ts +++ b/src/supervisor/agents/opencode/detection.ts @@ -9,6 +9,7 @@ import { type AgentConnectedProvider, type ProjectLocation, } from "@/shared/contracts"; +import { sortEffortsByCanonicalOrder } from "@/shared/effortOrder"; import { configFileAuthProbe, readAgentCommandOutput, @@ -27,13 +28,6 @@ import { probeOpenCodeInventoryViaSdk, type OpenCodeSdkInventory } from "./sdkPr */ export const OPENCODE_MIN_VERSION = "1.14.19"; -// Canonical ordering for the union effort list. Anything OpenCode reports -// outside this set gets appended after these in discovery order so we never -// silently hide a variant. `none` is OpenCode's "skip reasoning" variant on -// GPT-class models — kept first so it sorts ahead of the actual effort -// gradient. -const CANONICAL_EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; - // Per-model default — preferred when the model exposes it, falling back to // the highest-precedence available variant. Mirrors how Claude defaults to // `high`; OpenCode defaults to `medium` because several Zen models (GPT-5.5, @@ -538,20 +532,6 @@ export const opencodeDetectionSpec: DetectionSpec = { }, }; -function orderEffortsCanonically(seen: Set): string[] { - const ordered: string[] = []; - for (const effort of CANONICAL_EFFORT_ORDER) { - if (seen.has(effort)) { - ordered.push(effort); - seen.delete(effort); - } - } - // Append any non-canonical variant names OpenCode reported, preserving - // discovery order — keeps us forward-compatible with new variants. - for (const effort of seen) ordered.push(effort); - return ordered; -} - function defaultEffortFor(ordered: readonly string[]): { defaultEffort?: string } { if (ordered.includes(OPENCODE_PREFERRED_DEFAULT_EFFORT)) { return { defaultEffort: OPENCODE_PREFERRED_DEFAULT_EFFORT }; @@ -579,7 +559,7 @@ export function buildCapabilityPartialFromProbedModels( modelEfforts[m.id] = m.variants; for (const v of m.variants) seenEfforts.add(v); } - const ordered = orderEffortsCanonically(seenEfforts); + const ordered = sortEffortsByCanonicalOrder([...seenEfforts]); // Map each model to its registry-reported context limit so the renderer's // context-usage dock can show "X / Y tokens" before any message has flowed @@ -642,7 +622,7 @@ export function buildCapabilityPartialFromSdkInventory( } } - const ordered = orderEffortsCanonically(seenEfforts); + const ordered = sortEffortsByCanonicalOrder([...seenEfforts]); return { models: models.toSorted((left, right) => left.label.localeCompare(right.label)), diff --git a/src/supervisor/agents/qoder/qoder.test.ts b/src/supervisor/agents/qoder/qoder.test.ts index 9379e79fb..ad1194df1 100644 --- a/src/supervisor/agents/qoder/qoder.test.ts +++ b/src/supervisor/agents/qoder/qoder.test.ts @@ -120,7 +120,7 @@ describe("buildQoderProbeCapabilities", () => { { id: "auto", label: "Auto (default)" }, { id: "ultimate", label: "Ultimate" }, ], - efforts: ["xhigh", "high", "low", "max", "medium", "none"], + efforts: ["none", "low", "medium", "high", "xhigh", "max"], defaultEffort: "xhigh", modes: ["agent", "plan"], approvalPolicies: [ @@ -132,7 +132,7 @@ describe("buildQoderProbeCapabilities", () => { }); expect(capabilities.models?.map((model) => model.id)).toEqual(["auto", "ultimate"]); - expect(capabilities.efforts).toEqual(["xhigh", "high", "low", "max", "medium", "none"]); + expect(capabilities.efforts).toEqual(["none", "low", "medium", "high", "xhigh", "max"]); expect(capabilities.defaultEffort).toBe("xhigh"); expect(capabilities.modes).toEqual(["agent", "plan"]); expect(capabilities.approvalPolicies).toHaveLength(3);