diff --git a/src/index.ts b/src/index.ts index 6778427d0..c1519fed2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,11 +14,13 @@ 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 { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog" const serverPlugin: Plugin = async (input, _options): Promise => { + installAgentSortShim() initConfigContext("opencode", null) log("[oh-my-openagent] ENTRY - plugin loading", { directory: input.directory, diff --git a/src/shared/agent-sort-shim.test.ts b/src/shared/agent-sort-shim.test.ts new file mode 100644 index 000000000..33352aa30 --- /dev/null +++ b/src/shared/agent-sort-shim.test.ts @@ -0,0 +1,168 @@ +/// + +import { beforeAll, describe, expect, test } from "bun:test" + +import { installAgentSortShim } from "./agent-sort-shim" + +describe("agent-sort-shim", () => { + beforeAll(() => { + installAgentSortShim() + }) + + 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 + 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([sisyphus, hephaestus, prometheus, atlas]) + }) + }) + }) + + describe("#given 4 core agents mixed with 2 non-core agent objects", () => { + describe("#when toSorted with alphabetical compareFn", () => { + test("#then core agents come first in canonical order followed by non-core agents alphabetically", () => { + // given + const sisyphus = { name: "Sisyphus - Ultraworker" } + const hephaestus = { name: "Hephaestus - Deep Agent" } + const prometheus = { name: "Prometheus - Plan Builder" } + const atlas = { name: "Atlas - Plan Executor" } + const build = { name: "build" } + const plan = { name: "plan" } + const input = [atlas, build, prometheus, plan, hephaestus, sisyphus] + + // when + const result = input.toSorted((a, b) => a.name.localeCompare(b.name)) + + // then + expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas, build, plan]) + }) + }) + }) + + describe("#given an array with only one core agent and several non-core agent-like objects", () => { + describe("#when toSorted with case-sensitive string-comparison compareFn", () => { + test("#then activation predicate fails and result is ASCII-sensitive order with capital S before lowercase letters", () => { + // given + const oracle = { name: "oracle" } + const librarian = { name: "librarian" } + const sisyphus = { name: "Sisyphus - Ultraworker" } + const explore = { name: "explore" } + const input = [oracle, librarian, sisyphus, explore] + + // when + const result = input.toSorted((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ) + + // then + expect(result).toEqual([sisyphus, explore, librarian, oracle]) + }) + }) + }) + + describe("#given a mixed-type array containing null, objects, a string, and a number", () => { + describe("#when toSorted with a string-coercing compareFn", () => { + test("#then activation predicate fails, shim does not throw, and result matches native semantics", () => { + // given + const sisyphusObj = { name: "Sisyphus - Ultraworker" } + const hephaestusObj = { name: "Hephaestus - Deep Agent" } + const input: unknown[] = [null, sisyphusObj, "string", 42, hephaestusObj] + const compare = (a: unknown, b: unknown): number => { + const sa = String(a) + const sb = String(b) + if (sa < sb) return -1 + if (sa > sb) return 1 + return 0 + } + + // when + const result = input.toSorted(compare) + + // then + expect(result).toEqual([42, sisyphusObj, hephaestusObj, null, "string"]) + }) + }) + }) + + describe("#given a plain string array", () => { + describe("#when toSorted with no compareFn", () => { + test("#then returns native alphabetical ordering untouched", () => { + // given + const input = ["zebra", "apple", "mango"] + + // when + const result = input.toSorted() + + // then + expect(result).toEqual(["apple", "mango", "zebra"]) + }) + }) + }) + + describe("#given a number array", () => { + describe("#when sort with numeric compareFn (in-place)", () => { + test("#then mutates the array and returns the same reference in ascending order", () => { + // given + const input = [3, 1, 4, 1, 5, 9, 2, 6] + + // when + const result = input.sort((a, b) => a - b) + + // then + expect(result).toBe(input) + expect(input).toEqual([1, 1, 2, 3, 4, 5, 6, 9]) + }) + }) + }) + + describe("#given agent objects with all 4 core display names in random order", () => { + describe("#when sort with alphabetical compareFn (in-place)", () => { + test("#then mutates the original array to canonical order", () => { + // given + 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.sort((a, b) => a.name.localeCompare(b.name)) + + // then + expect(result).toBe(input) + expect(input).toEqual([sisyphus, hephaestus, prometheus, atlas]) + }) + }) + }) + + describe("#given installAgentSortShim has been invoked multiple times", () => { + describe("#when toSorted is called on core agents after duplicate installs", () => { + test("#then result is canonical order with no double-wrapping side effects", () => { + // given + installAgentSortShim() + installAgentSortShim() + 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([sisyphus, hephaestus, prometheus, atlas]) + }) + }) + }) +}) diff --git a/src/shared/agent-sort-shim.ts b/src/shared/agent-sort-shim.ts new file mode 100644 index 000000000..d040d660d --- /dev/null +++ b/src/shared/agent-sort-shim.ts @@ -0,0 +1,116 @@ +/** + * Agent sort shim. + * + * 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. + * + * Earlier attempts to bias the sort key with invisible characters (ZWSP, + * U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap + * and column-truncation regressions in the TUI status bar (#3259, #3238). + * + * This shim is the narrowly-scoped alternative from PR #3267 with the Cubic + * P1 mitigations applied: + * 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. + * + * 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" + +const AGENT_RANK: ReadonlyMap = new Map( + CANONICAL_CORE_AGENT_ORDER.map( + (configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1], + ), +) + +const UNRANKED = Number.MAX_SAFE_INTEGER + +function extractAgentName(value: unknown): string { + if (value === null || typeof value !== "object") return "" + const candidate = value as { name?: unknown } + return typeof candidate.name === "string" ? candidate.name : "" +} + +function isAgentArray(arr: ReadonlyArray): boolean { + if (arr.length < 2) return false + + let rankedCount = 0 + for (const element of arr) { + 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++ + } + + return rankedCount >= 2 +} + +function agentComparator( + a: unknown, + 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 + + if (aRank !== bRank) return aRank - bRank + if (fallback) return fallback(a, b) + return 0 +} + +let installed = false + +export function installAgentSortShim(): void { + if (installed) return + + const originalToSorted = Array.prototype.toSorted + const originalSort = Array.prototype.sort + + function patchedToSorted( + this: unknown[], + compareFn?: (a: unknown, b: unknown) => number, + ): unknown[] { + if (isAgentArray(this)) { + return originalToSorted.call(this, (a, b) => agentComparator(a, b, compareFn)) + } + return originalToSorted.call(this, compareFn) + } + + function patchedSort( + this: unknown[], + compareFn?: (a: unknown, b: unknown) => number, + ): unknown[] { + if (isAgentArray(this)) { + return originalSort.call(this, (a, b) => agentComparator(a, b, compareFn)) + } + return originalSort.call(this, compareFn) + } + + Object.defineProperty(Array.prototype, "toSorted", { + value: patchedToSorted, + configurable: true, + writable: true, + enumerable: false, + }) + + Object.defineProperty(Array.prototype, "sort", { + value: patchedSort, + configurable: true, + writable: true, + enumerable: false, + }) + + installed = true +}