Merge branch 'dev' into feat/web-mdx-docs-sot
Resolves conflicts after #3858 (configurable agent ordering) merged into dev: - docs/reference/configuration.md: take dev's reorganized + expanded reference (1040 lines covering the new agent ordering config) over this branch's earlier trim. Re-apply this branch's schema URL canonicalization (`oh-my-opencode.schema.json` -> `oh-my-openagent.schema.json`). - docs/reference/features.md: auto-merged cleanly.
This commit is contained in:
@@ -14,6 +14,14 @@
|
|||||||
"default_run_agent": {
|
"default_run_agent": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"agent_order": {
|
||||||
|
"maxItems": 64,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
}
|
||||||
|
},
|
||||||
"agent_definitions": {
|
"agent_definitions": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
+1018
-256
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@ When running inside tmux:
|
|||||||
- Watch multiple agents work in real-time
|
- Watch multiple agents work in real-time
|
||||||
- Each pane shows agent output live
|
- Each pane shows agent output live
|
||||||
- Auto-cleanup when agents complete
|
- 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`.
|
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(),
|
new_task_system_enabled: z.boolean().optional(),
|
||||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||||
default_run_agent: z.string().optional(),
|
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) */
|
/** Paths to external agent definition files (.md or .json) */
|
||||||
agent_definitions: AgentDefinitionsConfigSchema,
|
agent_definitions: AgentDefinitionsConfigSchema,
|
||||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const mockCreateHooks = mock(() => ({
|
|||||||
const mockCreatePluginInterface = mock(() => ({}))
|
const mockCreatePluginInterface = mock(() => ({}))
|
||||||
const mockInitializeOpenClaw = mock(async () => {})
|
const mockInitializeOpenClaw = mock(async () => {})
|
||||||
const mockStartTmuxCheck = mock(() => {})
|
const mockStartTmuxCheck = mock(() => {})
|
||||||
|
const mockInstallAgentSortShim = mock(() => {})
|
||||||
|
const mockSetAgentSortOrder = mock(() => {})
|
||||||
|
|
||||||
let pluginModule: (typeof import("./index"))["default"]
|
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", () => ({
|
mock.module("./openclaw", () => ({
|
||||||
initializeOpenClaw: mockInitializeOpenClaw,
|
initializeOpenClaw: mockInitializeOpenClaw,
|
||||||
}))
|
}))
|
||||||
@@ -130,6 +137,8 @@ describe("oh-my-openagent plugin module", () => {
|
|||||||
mockCreatePluginInterface.mockClear()
|
mockCreatePluginInterface.mockClear()
|
||||||
mockInitializeOpenClaw.mockClear()
|
mockInitializeOpenClaw.mockClear()
|
||||||
mockStartTmuxCheck.mockClear()
|
mockStartTmuxCheck.mockClear()
|
||||||
|
mockInstallAgentSortShim.mockClear()
|
||||||
|
mockSetAgentSortOrder.mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ import { loadPluginConfig } from "./plugin-config"
|
|||||||
import { createModelCacheState } from "./plugin-state"
|
import { createModelCacheState } from "./plugin-state"
|
||||||
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
||||||
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
|
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 { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
|
||||||
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
|
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
|||||||
injectServerAuthIntoClient(input.client)
|
injectServerAuthIntoClient(input.client)
|
||||||
|
|
||||||
const pluginConfig = loadPluginConfig(input.directory, input)
|
const pluginConfig = loadPluginConfig(input.directory, input)
|
||||||
|
setAgentSortOrder(pluginConfig.agent_order)
|
||||||
|
|
||||||
if (pluginConfig.openclaw) {
|
if (pluginConfig.openclaw) {
|
||||||
await initializeOpenClaw(pluginConfig.openclaw)
|
await initializeOpenClaw(pluginConfig.openclaw)
|
||||||
|
|||||||
@@ -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 { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
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 { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type TeamModeConfig } from "./config";
|
||||||
|
import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared/config-errors";
|
||||||
|
|
||||||
const tempDirs: string[] = []
|
const tempDirs: string[] = []
|
||||||
type ConfigInput = Omit<Partial<OhMyOpenCodeConfig>, "team_mode"> & {
|
type ConfigInput = Omit<Partial<OhMyOpenCodeConfig>, "team_mode"> & {
|
||||||
@@ -20,6 +21,7 @@ async function importFreshPluginConfigModule(): Promise<typeof import("./plugin-
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
clearConfigLoadErrors()
|
||||||
delete process.env.OPENCODE_CONFIG_DIR
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
@@ -273,6 +275,35 @@ describe("parseConfigPartially", () => {
|
|||||||
expect(result!.agents).toBeUndefined();
|
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", () => {
|
it("should preserve valid agents when a non-agent section is invalid", () => {
|
||||||
const rawConfig = {
|
const rawConfig = {
|
||||||
agents: {
|
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", () => {
|
describe("loadPluginConfig", () => {
|
||||||
it("should only honor mcp_env_allowlist from user config", async () => {
|
it("should only honor mcp_env_allowlist from user config", async () => {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -16,6 +16,50 @@ import {
|
|||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
||||||
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
|
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 {
|
function resolveHomeDirectory(): string {
|
||||||
// Read env vars directly to bypass os.homedir() caching. Bun caches the
|
// Read env vars directly to bypass os.homedir() caching. Bun caches the
|
||||||
@@ -134,6 +178,7 @@ export function loadConfigFromPath(
|
|||||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
addAgentOrderWarnings(configPath, result.data.agent_order);
|
||||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
@@ -149,6 +194,7 @@ export function loadConfigFromPath(
|
|||||||
|
|
||||||
const partialResult = parseConfigPartially(rawConfig);
|
const partialResult = parseConfigPartially(rawConfig);
|
||||||
if (partialResult) {
|
if (partialResult) {
|
||||||
|
addAgentOrderWarnings(configPath, partialResult.agent_order);
|
||||||
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents });
|
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents });
|
||||||
return partialResult;
|
return partialResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
|
|
||||||
## CRITICAL: AGENT ORDERING
|
## 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:
|
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`.
|
1. `DEFAULT_AGENT_ORDER` in `src/shared/agent-ordering.ts` supplies the fallback order used when `agent_order` is absent or incomplete.
|
||||||
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.
|
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
|
### 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.
|
- 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 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`.
|
- The comparator never throws on mixed input — it defensively extracts `.name` and falls back to the user-supplied `compareFn`.
|
||||||
- `installAgentSortShim()` is idempotent.
|
- `installAgentSortShim()` is idempotent.
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ Agent ordering has caused 15+ commits, 8+ PRs, and multiple reverts. Notable mil
|
|||||||
DO NOT introduce:
|
DO NOT introduce:
|
||||||
- ZWSP, U+2060, U+00AD, ANSI escape, or any other invisible / control character in agent names, display names, or object keys.
|
- 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.
|
- 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.
|
- Object.entries() iteration-order dependencies.
|
||||||
- Agent name string comparisons that skip `getAgentConfigKey` / `stripInvisibleAgentCharacters` (legacy ZWSP-baked data must keep resolving).
|
- 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 = reorderAgentsByPriority(
|
||||||
params.config.agent as Record<string, unknown>,
|
params.config.agent as Record<string, unknown>,
|
||||||
|
params.pluginConfig.agent_order,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,48 @@ describe("agent-priority-order", () => {
|
|||||||
expect(keys[3]).toBe(atlas)
|
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", () => {
|
test("#when core agents mixed with non-core #then core agents come first in canonical order", () => {
|
||||||
// given: mixed order with non-core agents interleaved
|
// given: mixed order with non-core agents interleaved
|
||||||
const agents: Record<string, unknown> = {
|
const agents: Record<string, unknown> = {
|
||||||
@@ -199,6 +241,21 @@ describe("agent-priority-order", () => {
|
|||||||
expect(result[atlas]).toEqual({ name: "atlas", mode: "primary", order: 4 })
|
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", () => {
|
test("#when core agent is non-object #then leaves value unchanged", () => {
|
||||||
// given
|
// given
|
||||||
const agents: Record<string, unknown> = {
|
const agents: Record<string, unknown> = {
|
||||||
|
|||||||
@@ -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.
|
* Default source of truth for core agent ordering.
|
||||||
* The order is: sisyphus → hephaestus → prometheus → atlas
|
* The default order is: sisyphus → hephaestus → prometheus → atlas.
|
||||||
*
|
*
|
||||||
* DO NOT CHANGE THIS ORDER. Any PR attempting to modify this order
|
* User config may override the runtime order through `agent_order`; missing
|
||||||
* or introduce alternative ordering mechanisms (ZWSP prefixes, sort
|
* core agents still fall back to this default order. Do not reintroduce sort
|
||||||
* shims, etc.) will be rejected.
|
* key prefixes or a second ordering constant.
|
||||||
*
|
*
|
||||||
* See: src/plugin-handlers/AGENTS.md for architectural context.
|
* See: src/plugin-handlers/AGENTS.md for architectural context.
|
||||||
*/
|
*/
|
||||||
export const CANONICAL_CORE_AGENT_ORDER = [
|
export const CANONICAL_CORE_AGENT_ORDER = DEFAULT_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))
|
|
||||||
|
|
||||||
function injectOrderField(agentConfig: unknown, order: number): unknown {
|
function injectOrderField(agentConfig: unknown, order: number): unknown {
|
||||||
if (typeof agentConfig === "object" && agentConfig !== null) {
|
if (typeof agentConfig === "object" && agentConfig !== null) {
|
||||||
@@ -40,13 +21,15 @@ function injectOrderField(agentConfig: unknown, order: number): unknown {
|
|||||||
|
|
||||||
export function reorderAgentsByPriority(
|
export function reorderAgentsByPriority(
|
||||||
agents: Record<string, unknown>,
|
agents: Record<string, unknown>,
|
||||||
|
agentOrder?: readonly string[],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const ordered: Record<string, unknown> = {}
|
const ordered: Record<string, unknown> = {}
|
||||||
const seen = new Set<string>()
|
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)) {
|
if (Object.prototype.hasOwnProperty.call(agents, displayName)) {
|
||||||
ordered[displayName] = injectOrderField(agents[displayName], order)
|
ordered[displayName] = injectOrderField(agents[displayName], index + 1)
|
||||||
seen.add(displayName)
|
seen.add(displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <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"
|
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
|
||||||
|
|
||||||
type AgentListItem = {
|
type AgentListItem = {
|
||||||
@@ -10,15 +10,26 @@ type AgentListItem = {
|
|||||||
default_agent?: boolean
|
default_agent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Array<T> {
|
||||||
|
toSorted(compareFn?: (a: T, b: T) => number): T[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("agent-sort-shim", () => {
|
describe("agent-sort-shim", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
installAgentSortShim()
|
installAgentSortShim()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setAgentSortOrder(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
describe("#given an array of all 4 core agent objects in random order", () => {
|
describe("#given an array of all 4 core agent objects in random order", () => {
|
||||||
describe("#when toSorted with alphabetical compareFn", () => {
|
describe("#when toSorted with alphabetical compareFn", () => {
|
||||||
test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => {
|
test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => {
|
||||||
// given
|
// given
|
||||||
|
setAgentSortOrder(undefined)
|
||||||
const sisyphus = { name: "Sisyphus - Ultraworker" }
|
const sisyphus = { name: "Sisyphus - Ultraworker" }
|
||||||
const hephaestus = { name: "Hephaestus - Deep Agent" }
|
const hephaestus = { name: "Hephaestus - Deep Agent" }
|
||||||
const prometheus = { name: "Prometheus - Plan Builder" }
|
const prometheus = { name: "Prometheus - Plan Builder" }
|
||||||
@@ -31,6 +42,22 @@ describe("agent-sort-shim", () => {
|
|||||||
// then
|
// then
|
||||||
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas])
|
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])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
*
|
*
|
||||||
* OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127) and
|
* 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")`
|
* 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
|
* at packages/opencode/src/agent/agent.ts. Without intervention, core agents
|
||||||
* core agents collapse into Atlas -> Hephaestus -> Prometheus -> Sisyphus,
|
* collapse into name order, which can invert the default sisyphus -> hephaestus
|
||||||
* which inverts the canonical sisyphus -> hephaestus -> prometheus -> atlas
|
* -> prometheus -> atlas order or a user's configured `agent_order`.
|
||||||
* order this project ships.
|
|
||||||
*
|
*
|
||||||
* Earlier attempts to bias the sort key with invisible characters (ZWSP,
|
* Earlier attempts to bias the sort key with invisible characters (ZWSP,
|
||||||
* U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap
|
* 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
|
* 1. `isAgentArray` rejects any array element that is null, non-object, or
|
||||||
* lacks a string `name`, eliminating the throw-on-mixed-array failure
|
* lacks a string `name`, eliminating the throw-on-mixed-array failure
|
||||||
* mode that closed the original PR.
|
* mode that closed the original PR.
|
||||||
* 2. The activation predicate requires >= 2 elements whose `.name` is one
|
* 2. The activation predicate requires >= 2 elements whose `.name` is ranked
|
||||||
* of the four canonical core display names, so unrelated `.sort()` and
|
* by the active agent order, so unrelated `.sort()` and `.toSorted()` calls
|
||||||
* `.toSorted()` calls (string arrays, number arrays, generic objects)
|
* (string arrays, number arrays, generic objects) execute native behavior
|
||||||
* execute native behavior unchanged.
|
* unchanged.
|
||||||
*
|
*
|
||||||
* Remove this shim once OpenCode honors the agent `order` field
|
* Remove this shim once OpenCode honors the agent `order` field
|
||||||
* (sst/opencode#19127).
|
* (sst/opencode#19127).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CANONICAL_CORE_AGENT_ORDER } from "../plugin-handlers/agent-priority-order"
|
import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "./agent-ordering"
|
||||||
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
|
import { getAgentListDisplayName } from "./agent-display-names"
|
||||||
|
|
||||||
const AGENT_RANK: ReadonlyMap<string, number> = new Map(
|
let agentRank: ReadonlyMap<string, number> = createAgentRank(undefined)
|
||||||
CANONICAL_CORE_AGENT_ORDER.map(
|
const AGENT_ARRAY_SENTINELS = new Set(
|
||||||
(configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1],
|
DEFAULT_AGENT_ORDER.map((configKey) => getAgentListDisplayName(configKey)),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const UNRANKED = Number.MAX_SAFE_INTEGER
|
const UNRANKED = Number.MAX_SAFE_INTEGER
|
||||||
@@ -51,7 +49,7 @@ function isAgentArray(arr: ReadonlyArray<unknown>): boolean {
|
|||||||
if (element === null || typeof element !== "object") return false
|
if (element === null || typeof element !== "object") return false
|
||||||
const name = (element as { name?: unknown }).name
|
const name = (element as { name?: unknown }).name
|
||||||
if (typeof name !== "string") return false
|
if (typeof name !== "string") return false
|
||||||
if (AGENT_RANK.has(name)) rankedCount++
|
if (AGENT_ARRAY_SENTINELS.has(name)) rankedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
return rankedCount >= 2
|
return rankedCount >= 2
|
||||||
@@ -62,8 +60,8 @@ function agentComparator(
|
|||||||
b: unknown,
|
b: unknown,
|
||||||
fallback: ((a: unknown, b: unknown) => number) | undefined,
|
fallback: ((a: unknown, b: unknown) => number) | undefined,
|
||||||
): number {
|
): number {
|
||||||
const aRank = AGENT_RANK.get(extractAgentName(a)) ?? UNRANKED
|
const aRank = agentRank.get(extractAgentName(a)) ?? UNRANKED
|
||||||
const bRank = AGENT_RANK.get(extractAgentName(b)) ?? UNRANKED
|
const bRank = agentRank.get(extractAgentName(b)) ?? UNRANKED
|
||||||
|
|
||||||
if (aRank !== bRank) return aRank - bRank
|
if (aRank !== bRank) return aRank - bRank
|
||||||
if (fallback) return fallback(a, b)
|
if (fallback) return fallback(a, b)
|
||||||
@@ -72,6 +70,18 @@ function agentComparator(
|
|||||||
|
|
||||||
let installed = false
|
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 {
|
export function installAgentSortShim(): void {
|
||||||
if (installed) return
|
if (installed) return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user