From 9522dd4ca4a14b3ada5610e41ead71a68f573b83 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 8 May 2026 16:08:18 +0900 Subject: [PATCH] feat(config): add configurable agent ordering --- assets/oh-my-opencode.schema.json | 8 ++ docs/reference/configuration.md | 8 +- docs/reference/features.md | 2 +- .../schema/oh-my-opencode-config.test.ts | 55 +++++++++++++ src/config/schema/oh-my-opencode-config.ts | 2 + src/index.test.ts | 9 +++ src/index.ts | 3 +- src/plugin-config.test.ts | 80 ++++++++++++++++++- src/plugin-config.ts | 46 +++++++++++ src/plugin-handlers/AGENTS.md | 11 +-- src/plugin-handlers/agent-config-handler.ts | 1 + .../agent-priority-order.test.ts | 57 +++++++++++++ src/plugin-handlers/agent-priority-order.ts | 39 +++------ src/shared/agent-ordering.ts | 61 ++++++++++++++ src/shared/agent-sort-shim.test.ts | 31 ++++++- src/shared/agent-sort-shim.ts | 44 ++++++---- 16 files changed, 400 insertions(+), 57 deletions(-) create mode 100644 src/shared/agent-ordering.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 380cdcd2b..6700c97f9 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -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": { diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f33084acd..d67f4f1fc 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 diff --git a/docs/reference/features.md b/docs/reference/features.md index 301d0b5c4..e8f5a3729 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -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`. diff --git a/src/config/schema/oh-my-opencode-config.test.ts b/src/config/schema/oh-my-opencode-config.test.ts index 6fef426ac..eb3315fea 100644 --- a/src/config/schema/oh-my-opencode-config.test.ts +++ b/src/config/schema/oh-my-opencode-config.test.ts @@ -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) + }) +}) diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index df7032514..197948bca 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -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(), diff --git a/src/index.test.ts b/src/index.test.ts index ba7be1363..715eeeb99 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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(() => { diff --git a/src/index.ts b/src/index.ts index 5921bb73b..372143cd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 => { injectServerAuthIntoClient(input.client) const pluginConfig = loadPluginConfig(input.directory, input) + setAgentSortOrder(pluginConfig.agent_order) if (pluginConfig.openclaw) { await initializeOpenClaw(pluginConfig.openclaw) diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index ee2dfa8c4..c98ab715a 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -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, "team_mode"> & { @@ -20,6 +21,7 @@ async function importFreshPluginConfigModule(): Promise { 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 diff --git a/src/plugin-config.ts b/src/plugin-config.ts index cedb3ac2d..0914be7b8 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -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; } diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md index 1d550d3bc..d378f9391 100644 --- a/src/plugin-handlers/AGENTS.md +++ b/src/plugin-handlers/AGENTS.md @@ -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). diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index f4fcc8533..9d5c3b2ca 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -395,6 +395,7 @@ export async function applyAgentConfig(params: { ); params.config.agent = reorderAgentsByPriority( params.config.agent as Record, + params.pluginConfig.agent_order, ); } diff --git a/src/plugin-handlers/agent-priority-order.test.ts b/src/plugin-handlers/agent-priority-order.test.ts index d1af68a61..94a6581ea 100644 --- a/src/plugin-handlers/agent-priority-order.test.ts +++ b/src/plugin-handlers/agent-priority-order.test.ts @@ -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 = { + [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 = { + [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 = { @@ -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 = { + [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 = { diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts index 711f6a58c..43becbf9d 100644 --- a/src/plugin-handlers/agent-priority-order.ts +++ b/src/plugin-handlers/agent-priority-order.ts @@ -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, + agentOrder?: readonly string[], ): Record { const ordered: Record = {} const seen = new Set() + 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) } } diff --git a/src/shared/agent-ordering.ts b/src/shared/agent-ordering.ts new file mode 100644 index 000000000..f1f621d67 --- /dev/null +++ b/src/shared/agent-ordering.ts @@ -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() + + 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)) +} diff --git a/src/shared/agent-sort-shim.test.ts b/src/shared/agent-sort-shim.test.ts index 647b86f80..47145924a 100644 --- a/src/shared/agent-sort-shim.test.ts +++ b/src/shared/agent-sort-shim.test.ts @@ -1,8 +1,8 @@ /// -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 { + 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]) + }) }) }) diff --git a/src/shared/agent-sort-shim.ts b/src/shared/agent-sort-shim.ts index d040d660d..479a20719 100644 --- a/src/shared/agent-sort-shim.ts +++ b/src/shared/agent-sort-shim.ts @@ -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 = new Map( - CANONICAL_CORE_AGENT_ORDER.map( - (configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1], - ), +let agentRank: ReadonlyMap = 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): 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 { + 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