Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/shared/effortOrder.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
31 changes: 31 additions & 0 deletions src/shared/effortOrder.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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));
}
26 changes: 25 additions & 1 deletion src/supervisor/agents/acp/probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
11 changes: 8 additions & 3 deletions src/supervisor/agents/acp/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
26 changes: 3 additions & 23 deletions src/supervisor/agents/opencode/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type AgentConnectedProvider,
type ProjectLocation,
} from "@/shared/contracts";
import { sortEffortsByCanonicalOrder } from "@/shared/effortOrder";
import {
configFileAuthProbe,
readAgentCommandOutput,
Expand All @@ -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,
Expand Down Expand Up @@ -538,20 +532,6 @@ export const opencodeDetectionSpec: DetectionSpec = {
},
};

function orderEffortsCanonically(seen: Set<string>): 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 };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
4 changes: 2 additions & 2 deletions src/supervisor/agents/qoder/qoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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);
Expand Down