feat(config): add configurable agent ordering

This commit is contained in:
YeonGyu-Kim
2026-05-08 16:08:18 +09:00
parent 8667d7e5b8
commit 9522dd4ca4
16 changed files with 400 additions and 57 deletions
+8
View File
@@ -14,6 +14,14 @@
"default_run_agent": {
"type": "string"
},
"agent_order": {
"maxItems": 64,
"type": "array",
"items": {
"type": "string",
"maxLength": 128
}
},
"agent_definitions": {
"type": "array",
"items": {
+7 -1
View File
@@ -161,7 +161,13 @@ Override built-in agent settings. Available agents: `sisyphus`, `hephaestus`, `p
Disable agents entirely: `{ "disabled_agents": ["oracle", "multimodal-looker"] }`
Core agents receive an injected runtime `order` field for deterministic Tab cycling in the UI: Sisyphus = 1, Hephaestus = 2, Prometheus = 3, Atlas = 4. This is not a user-configurable config key.
Agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas. Override known agent ordering with `agent_order`; omitted core agents keep their default relative order. Unknown or duplicate names are ignored and reported with a config toast.
```json
{
"agent_order": ["hephaestus", "sisyphus", "prometheus", "atlas"]
}
```
#### Agent Options
+1 -1
View File
@@ -90,7 +90,7 @@ When running inside tmux:
- Watch multiple agents work in real-time
- Each pane shows agent output live
- Auto-cleanup when agents complete
- **Stable agent ordering**: core-agent tab cycling is deterministic via injected runtime order field (Sisyphus: 1, Hephaestus: 2, Prometheus: 3, Atlas: 4)
- **Stable agent ordering**: core-agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas, and can be customized with `agent_order`
Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`.
@@ -38,3 +38,58 @@ describe("OhMyOpenCodeConfigSchema team_mode", () => {
}
})
})
describe("OhMyOpenCodeConfigSchema agent_order", () => {
it("accepts string agent ordering when provided", () => {
// given
const rawConfig = {
agent_order: ["hephaestus", "sisyphus", "prometheus", "atlas"],
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agent_order).toEqual([
"hephaestus",
"sisyphus",
"prometheus",
"atlas",
])
}
})
it("allows agent_order omission", () => {
// given
const rawConfig = {}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
// then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agent_order).toBeUndefined()
}
})
it("rejects abusive agent_order string length and item count", () => {
// given
const tooLongName = "x".repeat(129)
const tooManyNames = Array.from({ length: 65 }, (_, index) => `agent-${index}`)
// when
const tooLongResult = OhMyOpenCodeConfigSchema.safeParse({
agent_order: [tooLongName],
})
const tooManyResult = OhMyOpenCodeConfigSchema.safeParse({
agent_order: tooManyNames,
})
// then
expect(tooLongResult.success).toBe(false)
expect(tooManyResult.success).toBe(false)
})
})
@@ -32,6 +32,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
new_task_system_enabled: z.boolean().optional(),
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
default_run_agent: z.string().optional(),
/** Preferred display order for known agents. Invalid names are ignored with a toast warning. */
agent_order: z.array(z.string().max(128)).max(64).optional(),
/** Paths to external agent definition files (.md or .json) */
agent_definitions: AgentDefinitionsConfigSchema,
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
+9
View File
@@ -37,6 +37,8 @@ const mockCreateHooks = mock(() => ({
const mockCreatePluginInterface = mock(() => ({}))
const mockInitializeOpenClaw = mock(async () => {})
const mockStartTmuxCheck = mock(() => {})
const mockInstallAgentSortShim = mock(() => {})
const mockSetAgentSortOrder = mock(() => {})
let pluginModule: (typeof import("./index"))["default"]
@@ -95,6 +97,11 @@ function installIndexModuleMocks(): void {
})),
}))
mock.module("./shared/agent-sort-shim", () => ({
installAgentSortShim: mockInstallAgentSortShim,
setAgentSortOrder: mockSetAgentSortOrder,
}))
mock.module("./openclaw", () => ({
initializeOpenClaw: mockInitializeOpenClaw,
}))
@@ -130,6 +137,8 @@ describe("oh-my-openagent plugin module", () => {
mockCreatePluginInterface.mockClear()
mockInitializeOpenClaw.mockClear()
mockStartTmuxCheck.mockClear()
mockInstallAgentSortShim.mockClear()
mockSetAgentSortOrder.mockClear()
})
afterEach(() => {
+2 -1
View File
@@ -14,7 +14,7 @@ import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
import { installAgentSortShim } from "./shared/agent-sort-shim"
import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shim"
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
@@ -34,6 +34,7 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
injectServerAuthIntoClient(input.client)
const pluginConfig = loadPluginConfig(input.directory, input)
setAgentSortOrder(pluginConfig.agent_order)
if (pluginConfig.openclaw) {
await initializeOpenClaw(pluginConfig.openclaw)
+78 -2
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { afterEach, describe, expect, it, mock } from "bun:test";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
import { loadConfigFromPath, mergeConfigs, parseConfigPartially } from "./plugin-config";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type TeamModeConfig } from "./config";
import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared/config-errors";
const tempDirs: string[] = []
type ConfigInput = Omit<Partial<OhMyOpenCodeConfig>, "team_mode"> & {
@@ -20,6 +21,7 @@ async function importFreshPluginConfigModule(): Promise<typeof import("./plugin-
afterEach(() => {
mock.restore()
clearConfigLoadErrors()
delete process.env.OPENCODE_CONFIG_DIR
for (const dir of tempDirs.splice(0)) {
@@ -273,6 +275,35 @@ describe("parseConfigPartially", () => {
expect(result!.agents).toBeUndefined();
});
it("should preserve valid agent_order when another section is invalid", () => {
const rawConfig = {
agent_order: ["hephaestus", "sisyphus", "prometheus", "atlas"],
disabled_skills: [42],
};
const result = parseConfigPartially(rawConfig);
expect(result?.agent_order).toEqual([
"hephaestus",
"sisyphus",
"prometheus",
"atlas",
]);
expect(result?.disabled_skills).toBeUndefined();
});
it("should skip abusive agent_order when another section is valid", () => {
const rawConfig = {
agent_order: ["x".repeat(129)],
disabled_hooks: ["comment-checker"],
};
const result = parseConfigPartially(rawConfig);
expect(result?.agent_order).toBeUndefined();
expect(result?.disabled_hooks).toEqual(["comment-checker"]);
});
it("should preserve valid agents when a non-agent section is invalid", () => {
const rawConfig = {
agents: {
@@ -349,6 +380,51 @@ describe("parseConfigPartially", () => {
});
});
describe("loadConfigFromPath agent_order warnings", () => {
it("loads config and records warning for invalid agent_order entries", () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "agent-order-warning-"))
tempDirs.push(rootDir)
const configPath = join(rootDir, "oh-my-openagent.json")
writeJsonFile(configPath, {
agent_order: ["hephaestus", "not-real", "sisyphus", "hephaestus"],
})
// when
const result = loadConfigFromPath(configPath, {})
// then
expect(result?.agent_order).toEqual(["hephaestus", "not-real", "sisyphus", "hephaestus"])
expect(getConfigLoadErrors()).toEqual([
{
path: configPath,
error: 'agent_order warning - unknown agent names ignored: "not-real"; duplicate agent names ignored: "hephaestus"',
},
])
})
it("sanitizes and caps invalid agent_order values before recording warnings", () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "agent-order-sanitize-"))
tempDirs.push(rootDir)
const configPath = join(rootDir, "oh-my-openagent.json")
writeJsonFile(configPath, {
agent_order: [
"\u001B[31mbad\u001B[0m",
...Array.from({ length: 11 }, (_, index) => `missing-${index}`),
],
})
// when
loadConfigFromPath(configPath, {})
// then
expect(getConfigLoadErrors()[0]?.error).toBe(
'agent_order warning - unknown agent names ignored: "[31mbad[0m", "missing-0", "missing-1", "missing-2", "missing-3", "missing-4", "missing-5", "missing-6", "missing-7", "missing-8", (+2 more)',
)
})
})
describe("loadPluginConfig", () => {
it("should only honor mcp_env_allowlist from user config", async () => {
// given
+46
View File
@@ -16,6 +16,50 @@ import {
} from "./shared";
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
import { validateAgentOrder } from "./shared/agent-ordering";
const CONTROL_CHARACTERS_REGEX = /[\u0000-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g;
const MAX_AGENT_ORDER_WARNING_VALUES = 10;
const MAX_AGENT_ORDER_WARNING_VALUE_LENGTH = 80;
function formatAgentOrderWarningValues(values: readonly string[]): string {
const displayedValues = values.slice(0, MAX_AGENT_ORDER_WARNING_VALUES).map((value) => {
const sanitized = value.replace(CONTROL_CHARACTERS_REGEX, "");
const truncated = sanitized.length > MAX_AGENT_ORDER_WARNING_VALUE_LENGTH
? `${sanitized.slice(0, MAX_AGENT_ORDER_WARNING_VALUE_LENGTH)}...`
: sanitized;
return JSON.stringify(truncated);
});
const remaining = values.length - displayedValues.length;
if (remaining > 0) {
displayedValues.push(`(+${remaining} more)`);
}
return displayedValues.join(", ");
}
function addAgentOrderWarnings(configPath: string, agentOrder: string[] | undefined): void {
if (!agentOrder) return;
const validation = validateAgentOrder(agentOrder);
const messages: string[] = [];
if (validation.invalid.length > 0) {
messages.push(`unknown agent names ignored: ${formatAgentOrderWarningValues(validation.invalid)}`);
}
if (validation.duplicates.length > 0) {
messages.push(`duplicate agent names ignored: ${formatAgentOrderWarningValues(validation.duplicates)}`);
}
if (messages.length === 0) return;
addConfigLoadError({
path: configPath,
error: `agent_order warning - ${messages.join("; ")}`,
});
}
function resolveHomeDirectory(): string {
// Read env vars directly to bypass os.homedir() caching. Bun caches the
@@ -134,6 +178,7 @@ export function loadConfigFromPath(
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (result.success) {
addAgentOrderWarnings(configPath, result.data.agent_order);
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
return result.data;
}
@@ -149,6 +194,7 @@ export function loadConfigFromPath(
const partialResult = parseConfigPartially(rawConfig);
if (partialResult) {
addAgentOrderWarnings(configPath, partialResult.agent_order);
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents });
return partialResult;
}
+6 -5
View File
@@ -4,11 +4,12 @@
## CRITICAL: AGENT ORDERING
The canonical agent order is **sisyphus → hephaestus → prometheus → atlas**.
The default agent order is **sisyphus → hephaestus → prometheus → atlas**. User config may override it with `agent_order`; omitted core agents fall back to this default order.
This order is enforced via two cooperating mechanisms:
1. `CANONICAL_CORE_AGENT_ORDER` in `agent-priority-order.ts` controls object key insertion order in the agent map produced by `applyAgentConfig`.
2. `installAgentSortShim()` in `src/shared/agent-sort-shim.ts` narrows `Array.prototype.toSorted` and `Array.prototype.sort` so that whenever the sorted array contains two or more agent objects whose `.name` matches a canonical core display name, OpenCode's `Agent.list()` (and any other sort site) returns the canonical order. The shim is installed once at plugin entry, before any agent registration.
1. `DEFAULT_AGENT_ORDER` in `src/shared/agent-ordering.ts` supplies the fallback order used when `agent_order` is absent or incomplete.
2. `reorderAgentsByPriority()` in `agent-priority-order.ts` controls object key insertion order in the agent map produced by `applyAgentConfig`.
3. `installAgentSortShim()` in `src/shared/agent-sort-shim.ts` narrows `Array.prototype.toSorted` and `Array.prototype.sort` so that whenever the sorted array contains two or more ranked agent objects, OpenCode's `Agent.list()` (and any other sort site) returns the active configured/default order. The shim is installed once at plugin entry, before any agent registration, and its rank map is updated after plugin config loads.
### Why a Sort Shim
@@ -18,7 +19,7 @@ OpenCode 1.4.x sorts agents purely by `agent.name` via Remeda `sortBy`, which us
- Removing the prefix and relying on insertion order alone falls back to alphabetical Atlas → Hephaestus → Prometheus → Sisyphus.
The sort shim resolves this by intercepting only the narrow case it cares about, with strict activation guards to prevent collateral damage from a global prototype patch:
- The activation predicate (`isAgentArray`) requires `arr.length >= 2`, every element is a non-null object with a string `.name`, and at least 2 elements have a `.name` matching one of the four canonical core display names. This rejects mixed-type arrays (numbers, strings, plain objects without `.name`) so unrelated `.sort()` / `.toSorted()` calls execute native semantics.
- The activation predicate (`isAgentArray`) requires `arr.length >= 2`, every element is a non-null object with a string `.name`, and at least 2 elements have a `.name` ranked by the active order. This rejects mixed-type arrays (numbers, strings, plain objects without `.name`) so unrelated `.sort()` / `.toSorted()` calls execute native semantics.
- The comparator never throws on mixed input — it defensively extracts `.name` and falls back to the user-supplied `compareFn`.
- `installAgentSortShim()` is idempotent.
@@ -34,7 +35,7 @@ Agent ordering has caused 15+ commits, 8+ PRs, and multiple reverts. Notable mil
DO NOT introduce:
- ZWSP, U+2060, U+00AD, ANSI escape, or any other invisible / control character in agent names, display names, or object keys.
- ASCII spaces or other visible sort prefixes on agent names.
- Alternative ordering constants outside `CANONICAL_CORE_AGENT_ORDER`.
- Alternative ordering constants outside `DEFAULT_AGENT_ORDER` / `CANONICAL_CORE_AGENT_ORDER`, or ordering code that bypasses `validateAgentOrder`.
- Object.entries() iteration-order dependencies.
- Agent name string comparisons that skip `getAgentConfigKey` / `stripInvisibleAgentCharacters` (legacy ZWSP-baked data must keep resolving).
@@ -395,6 +395,7 @@ export async function applyAgentConfig(params: {
);
params.config.agent = reorderAgentsByPriority(
params.config.agent as Record<string, unknown>,
params.pluginConfig.agent_order,
);
}
@@ -65,6 +65,48 @@ describe("agent-priority-order", () => {
expect(keys[3]).toBe(atlas)
})
test("#when custom agent order is provided #then follows configured core ordering", () => {
// given
const agents: Record<string, unknown> = {
[atlas]: { name: "atlas" },
[prometheus]: { name: "prometheus" },
[hephaestus]: { name: "hephaestus" },
[sisyphus]: { name: "sisyphus" },
}
// when
const result = reorderAgentsByPriority(agents, [
"hephaestus",
"sisyphus",
"prometheus",
"atlas",
])
// then
expect(Object.keys(result)).toEqual([hephaestus, sisyphus, prometheus, atlas])
})
test("#when custom agent order contains invalid entries #then ignores them and keeps valid/default ordering", () => {
// given
const agents: Record<string, unknown> = {
[atlas]: { name: "atlas" },
[prometheus]: { name: "prometheus" },
[hephaestus]: { name: "hephaestus" },
[sisyphus]: { name: "sisyphus" },
}
// when
const result = reorderAgentsByPriority(agents, [
"not-real",
"atlas",
"hephaestus",
"atlas",
])
// then
expect(Object.keys(result)).toEqual([atlas, hephaestus, sisyphus, prometheus])
})
test("#when core agents mixed with non-core #then core agents come first in canonical order", () => {
// given: mixed order with non-core agents interleaved
const agents: Record<string, unknown> = {
@@ -199,6 +241,21 @@ describe("agent-priority-order", () => {
expect(result[atlas]).toEqual({ name: "atlas", mode: "primary", order: 4 })
})
test("#when custom agent order is provided #then injects matching order fields", () => {
// given
const agents: Record<string, unknown> = {
[sisyphus]: { name: "sisyphus", mode: "primary" },
[hephaestus]: { name: "hephaestus", mode: "primary" },
}
// when
const result = reorderAgentsByPriority(agents, ["hephaestus", "sisyphus"])
// then
expect(result[hephaestus]).toEqual({ name: "hephaestus", mode: "primary", order: 1 })
expect(result[sisyphus]).toEqual({ name: "sisyphus", mode: "primary", order: 2 })
})
test("#when core agent is non-object #then leaves value unchanged", () => {
// given
const agents: Record<string, unknown> = {
+11 -28
View File
@@ -1,35 +1,16 @@
import { getAgentListDisplayName } from "../shared/agent-display-names"
import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "../shared/agent-ordering"
/**
* CRITICAL: This is the ONLY source of truth for core agent ordering.
* The order is: sisyphus → hephaestus → prometheus → atlas
* Default source of truth for core agent ordering.
* The default order is: sisyphus → hephaestus → prometheus → atlas.
*
* DO NOT CHANGE THIS ORDER. Any PR attempting to modify this order
* or introduce alternative ordering mechanisms (ZWSP prefixes, sort
* shims, etc.) will be rejected.
* User config may override the runtime order through `agent_order`; missing
* core agents still fall back to this default order. Do not reintroduce sort
* key prefixes or a second ordering constant.
*
* See: src/plugin-handlers/AGENTS.md for architectural context.
*/
export const CANONICAL_CORE_AGENT_ORDER = [
"sisyphus",
"hephaestus",
"prometheus",
"atlas",
] as const
type CoreAgentName = (typeof CANONICAL_CORE_AGENT_ORDER)[number]
const CORE_AGENT_ORDER: ReadonlyArray<{
configKey: CoreAgentName
displayName: string
order: number
}> = CANONICAL_CORE_AGENT_ORDER.map((configKey, index) => ({
configKey,
displayName: getAgentListDisplayName(configKey),
order: index + 1,
}))
const CORE_DISPLAY_NAMES = new Set(CORE_AGENT_ORDER.map((a) => a.displayName))
export const CANONICAL_CORE_AGENT_ORDER = DEFAULT_AGENT_ORDER
function injectOrderField(agentConfig: unknown, order: number): unknown {
if (typeof agentConfig === "object" && agentConfig !== null) {
@@ -40,13 +21,15 @@ function injectOrderField(agentConfig: unknown, order: number): unknown {
export function reorderAgentsByPriority(
agents: Record<string, unknown>,
agentOrder?: readonly string[],
): Record<string, unknown> {
const ordered: Record<string, unknown> = {}
const seen = new Set<string>()
const orderedDisplayNames = resolveAgentOrderDisplayNames(agentOrder)
for (const { displayName, order } of CORE_AGENT_ORDER) {
for (const [index, displayName] of orderedDisplayNames.entries()) {
if (Object.prototype.hasOwnProperty.call(agents, displayName)) {
ordered[displayName] = injectOrderField(agents[displayName], order)
ordered[displayName] = injectOrderField(agents[displayName], index + 1)
seen.add(displayName)
}
}
+61
View File
@@ -0,0 +1,61 @@
import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentListDisplayName } from "./agent-display-names"
export const DEFAULT_AGENT_ORDER = [
"sisyphus",
"hephaestus",
"prometheus",
"atlas",
] as const
export type AgentOrderValidation = {
order: string[]
invalid: string[]
duplicates: string[]
}
const KNOWN_AGENT_KEYS = new Set(Object.keys(AGENT_DISPLAY_NAMES))
function appendUnique(target: string[], value: string): void {
if (!target.includes(value)) {
target.push(value)
}
}
export function validateAgentOrder(agentOrder: readonly string[] | undefined): AgentOrderValidation {
const order: string[] = []
const invalid: string[] = []
const duplicates: string[] = []
const seen = new Set<string>()
for (const rawName of agentOrder ?? []) {
const trimmed = rawName.trim()
if (trimmed.length === 0) {
invalid.push(rawName)
continue
}
const configKey = getAgentConfigKey(trimmed)
if (!KNOWN_AGENT_KEYS.has(configKey)) {
invalid.push(rawName)
continue
}
if (seen.has(configKey)) {
duplicates.push(rawName)
continue
}
seen.add(configKey)
order.push(configKey)
}
for (const configKey of DEFAULT_AGENT_ORDER) {
appendUnique(order, configKey)
}
return { order, invalid, duplicates }
}
export function resolveAgentOrderDisplayNames(agentOrder: readonly string[] | undefined): string[] {
return validateAgentOrder(agentOrder).order.map((configKey) => getAgentListDisplayName(configKey))
}
+29 -2
View File
@@ -1,8 +1,8 @@
/// <reference types="bun-types" />
import { beforeAll, describe, expect, test } from "bun:test"
import { afterEach, beforeAll, describe, expect, test } from "bun:test"
import { installAgentSortShim } from "./agent-sort-shim"
import { installAgentSortShim, setAgentSortOrder } from "./agent-sort-shim"
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
type AgentListItem = {
@@ -10,15 +10,26 @@ type AgentListItem = {
default_agent?: boolean
}
declare global {
interface Array<T> {
toSorted(compareFn?: (a: T, b: T) => number): T[]
}
}
describe("agent-sort-shim", () => {
beforeAll(() => {
installAgentSortShim()
})
afterEach(() => {
setAgentSortOrder(undefined)
})
describe("#given an array of all 4 core agent objects in random order", () => {
describe("#when toSorted with alphabetical compareFn", () => {
test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => {
// given
setAgentSortOrder(undefined)
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
@@ -31,6 +42,22 @@ describe("agent-sort-shim", () => {
// then
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas])
})
test("#then follows configured core agent order", () => {
// given
setAgentSortOrder(["hephaestus", "sisyphus", "prometheus", "atlas"])
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
const atlas = { name: "Atlas - Plan Executor" }
const input = [atlas, prometheus, hephaestus, sisyphus]
// when
const result = input.toSorted((a, b) => a.name.localeCompare(b.name))
// then
expect(result).toEqual([hephaestus, sisyphus, prometheus, atlas])
})
})
})
+27 -17
View File
@@ -3,10 +3,9 @@
*
* OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127) and
* sorts the agent list by `agent.name` via Remeda `sortBy(x => x.name, "asc")`
* at packages/opencode/src/agent/agent.ts. Without intervention, the four
* core agents collapse into Atlas -> Hephaestus -> Prometheus -> Sisyphus,
* which inverts the canonical sisyphus -> hephaestus -> prometheus -> atlas
* order this project ships.
* at packages/opencode/src/agent/agent.ts. Without intervention, core agents
* collapse into name order, which can invert the default sisyphus -> hephaestus
* -> prometheus -> atlas order or a user's configured `agent_order`.
*
* Earlier attempts to bias the sort key with invisible characters (ZWSP,
* U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap
@@ -17,22 +16,21 @@
* 1. `isAgentArray` rejects any array element that is null, non-object, or
* lacks a string `name`, eliminating the throw-on-mixed-array failure
* mode that closed the original PR.
* 2. The activation predicate requires >= 2 elements whose `.name` is one
* of the four canonical core display names, so unrelated `.sort()` and
* `.toSorted()` calls (string arrays, number arrays, generic objects)
* execute native behavior unchanged.
* 2. The activation predicate requires >= 2 elements whose `.name` is ranked
* by the active agent order, so unrelated `.sort()` and `.toSorted()` calls
* (string arrays, number arrays, generic objects) execute native behavior
* unchanged.
*
* Remove this shim once OpenCode honors the agent `order` field
* (sst/opencode#19127).
*/
import { CANONICAL_CORE_AGENT_ORDER } from "../plugin-handlers/agent-priority-order"
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "./agent-ordering"
import { getAgentListDisplayName } from "./agent-display-names"
const AGENT_RANK: ReadonlyMap<string, number> = new Map(
CANONICAL_CORE_AGENT_ORDER.map(
(configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1],
),
let agentRank: ReadonlyMap<string, number> = createAgentRank(undefined)
const AGENT_ARRAY_SENTINELS = new Set(
DEFAULT_AGENT_ORDER.map((configKey) => getAgentListDisplayName(configKey)),
)
const UNRANKED = Number.MAX_SAFE_INTEGER
@@ -51,7 +49,7 @@ function isAgentArray(arr: ReadonlyArray<unknown>): boolean {
if (element === null || typeof element !== "object") return false
const name = (element as { name?: unknown }).name
if (typeof name !== "string") return false
if (AGENT_RANK.has(name)) rankedCount++
if (AGENT_ARRAY_SENTINELS.has(name)) rankedCount++
}
return rankedCount >= 2
@@ -62,8 +60,8 @@ function agentComparator(
b: unknown,
fallback: ((a: unknown, b: unknown) => number) | undefined,
): number {
const aRank = AGENT_RANK.get(extractAgentName(a)) ?? UNRANKED
const bRank = AGENT_RANK.get(extractAgentName(b)) ?? UNRANKED
const aRank = agentRank.get(extractAgentName(a)) ?? UNRANKED
const bRank = agentRank.get(extractAgentName(b)) ?? UNRANKED
if (aRank !== bRank) return aRank - bRank
if (fallback) return fallback(a, b)
@@ -72,6 +70,18 @@ function agentComparator(
let installed = false
function createAgentRank(agentOrder: readonly string[] | undefined): ReadonlyMap<string, number> {
return new Map(
resolveAgentOrderDisplayNames(agentOrder).map(
(displayName, index): [string, number] => [displayName, index + 1],
),
)
}
export function setAgentSortOrder(agentOrder: readonly string[] | undefined): void {
agentRank = createAgentRank(agentOrder)
}
export function installAgentSortShim(): void {
if (installed) return