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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/plugin-handlers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,35 @@ The canonical agent order is **sisyphus → hephaestus → prometheus → atlas*

This order is enforced via two mechanisms working together:
1. `CANONICAL_CORE_AGENT_ORDER` in `agent-priority-order.ts` controls object key insertion order
2. `agent-key-remapper.ts` injects ZWSP-prefixed runtime names into the `name` field for OpenCode's `localeCompare` sort
2. `agent-priority-order.ts` injects an explicit `order` field (1-4) into each core agent config

### Why Two Mechanisms
### How Ordering Works

OpenCode's `Agent.list()` sorts agents by `name` field via `localeCompare`. Object key order alone is not enough. The `name` field carries ZWSP prefixes (1-4 chars) so core agents sort before alphabetically-named agents.
`reorderAgentsByPriority` iterates over `CANONICAL_CORE_AGENT_ORDER`, matches each core agent by
display name, and inserts it first into the result object. JavaScript preserves string-key insertion
order (ES2015+), so core agents always appear before custom agents regardless of alphabetical order.

ZWSP is intentionally used in the `name` field only. It MUST NOT appear in:
The `order` field (integers 1-4) is stored as metadata this repo can provide — **this repo only
stores it**; whether OpenCode consumes it for rendering is outside this codebase's control.

ZWSP (U+200B) MUST NOT appear anywhere in the agent config — not in:
- Object keys (used as HTTP header values, causes RFC 7230 violations)
- Display names returned by `getAgentDisplayName()`
- Config keys
- The `name` field (OpenCode passes `name` as the `mode_type` HTTP header — ZWSP causes TypeError)

### History

Agent ordering has caused 15+ commits, 8+ PRs, and multiple reverts due to:
1. Early ZWSP attempts that leaked into HTTP headers via object keys
2. Object.entries() iteration order depending on merge sequence
3. Multiple code paths assembling agents differently
4. ZWSP in the `name` field leaking into the `mode_type` HTTP header

### Forbidden Patterns

DO NOT introduce:
- ZWSP in object keys or display names (only allowed in `name` field via `getAgentRuntimeName()`)
- ZWSP anywhere in agent configs (keys, display names, or `name` field)
- Runtime sort shims or comparators
- Alternative ordering constants
- Object.entries() order dependencies
Expand Down
14 changes: 7 additions & 7 deletions src/plugin-handlers/agent-config-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import type { OhMyOpenCodeConfig } from "../config"
import * as agentLoader from "../features/claude-code-agent-loader"
import * as skillLoader from "../features/opencode-skill-loader"
import type { LoadedSkill } from "../features/opencode-skill-loader"
import { getAgentListDisplayName, getAgentRuntimeName } from "../shared/agent-display-names"
import { getAgentDisplayName } from "../shared/agent-display-names"
import { applyAgentConfig } from "./agent-config-handler"
import type { PluginComponents } from "./plugin-components-loader"

const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentListDisplayName("sisyphus")
const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentListDisplayName("sisyphus-junior")
const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentListDisplayName("multimodal-looker")
const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentDisplayName("sisyphus")
const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentDisplayName("sisyphus-junior")
const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentDisplayName("multimodal-looker")

function createPluginComponents(): PluginComponents {
return {
Expand Down Expand Up @@ -203,7 +203,7 @@ describe("applyAgentConfig builtin override protection", () => {
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual({
...builtinSisyphusConfig,
name: getAgentRuntimeName("sisyphus"),
name: getAgentDisplayName("sisyphus"),
})
})

Expand All @@ -228,7 +228,7 @@ describe("applyAgentConfig builtin override protection", () => {
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual({
...builtinSisyphusConfig,
name: getAgentRuntimeName("sisyphus"),
name: getAgentDisplayName("sisyphus"),
})
expect(result.SiSyPhUs).toBeUndefined()
})
Expand All @@ -255,7 +255,7 @@ describe("applyAgentConfig builtin override protection", () => {
// then
expect(result[BUILTIN_SISYPHUS_DISPLAY_NAME]).toEqual({
...builtinSisyphusConfig,
name: getAgentRuntimeName("sisyphus"),
name: getAgentDisplayName("sisyphus"),
})
})

Expand Down
6 changes: 3 additions & 3 deletions src/plugin-handlers/agent-config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createBuiltinAgents } from "../agents";
import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior";
import type { OhMyOpenCodeConfig } from "../config";
import { isTaskSystemEnabled, log, migrateAgentConfig } from "../shared";
import { getAgentRuntimeName } from "../shared/agent-display-names";
import { getAgentDisplayName, getAgentRuntimeName } from "../shared/agent-display-names";
import { AGENT_NAME_MAP } from "../shared/migration";
import { registerAgentName } from "../features/claude-code-session-state";
import {
Expand Down Expand Up @@ -159,10 +159,10 @@ export async function applyAgentConfig(params: {
if (isSisyphusEnabled && builtinAgents.sisyphus) {
if (configuredDefaultAgent) {
(params.config as { default_agent?: string }).default_agent =
getAgentRuntimeName(configuredDefaultAgent);
getAgentDisplayName(configuredDefaultAgent);
} else {
(params.config as { default_agent?: string }).default_agent =
getAgentRuntimeName("sisyphus");
getAgentDisplayName("sisyphus");
}

// Assembly order: Sisyphus -> Hephaestus -> Prometheus -> Atlas
Expand Down
85 changes: 44 additions & 41 deletions src/plugin-handlers/agent-key-remapper.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "bun:test"
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"
import { getAgentDisplayName, getAgentListDisplayName, getAgentRuntimeName } from "../shared/agent-display-names"
import { getAgentDisplayName } from "../shared/agent-display-names"

describe("remapAgentKeysToDisplayNames", () => {
it("remaps known agent keys to display names", () => {
Expand All @@ -13,8 +13,8 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping
const result = remapAgentKeysToDisplayNames(agents)

// then known agents get display name keys only
expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined()
// then known agents get display name keys only (no ZWSP in key)
expect(result[getAgentDisplayName("sisyphus")]).toBeDefined()
expect(result["oracle"]).toBeDefined()
expect(result["sisyphus"]).toBeUndefined()
})
Expand Down Expand Up @@ -48,14 +48,14 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping
const result = remapAgentKeysToDisplayNames(agents)

// then all get display name keys
expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined()
// then all get display name keys (no ZWSP in key)
expect(result[getAgentDisplayName("sisyphus")]).toBeDefined()
expect(result["sisyphus"]).toBeUndefined()
expect(result[getAgentListDisplayName("hephaestus")]).toBeDefined()
expect(result[getAgentDisplayName("hephaestus")]).toBeDefined()
expect(result["hephaestus"]).toBeUndefined()
expect(result[getAgentListDisplayName("prometheus")]).toBeDefined()
expect(result[getAgentDisplayName("prometheus")]).toBeDefined()
expect(result["prometheus"]).toBeUndefined()
expect(result[getAgentListDisplayName("atlas")]).toBeDefined()
expect(result[getAgentDisplayName("atlas")]).toBeDefined()
expect(result["atlas"]).toBeUndefined()
expect(result[getAgentDisplayName("athena")]).toBeDefined()
expect(result["athena"]).toBeUndefined()
Expand All @@ -76,13 +76,13 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping
const result = remapAgentKeysToDisplayNames(agents)

// then only display key is emitted
expect(Object.keys(result)).toEqual([getAgentListDisplayName("sisyphus")])
expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined()
// then only display key is emitted (no ZWSP in key)
expect(Object.keys(result)).toEqual([getAgentDisplayName("sisyphus")])
expect(result[getAgentDisplayName("sisyphus")]).toBeDefined()
expect(result["sisyphus"]).toBeUndefined()
})

it("returns runtime core agent list names in canonical order", () => {
it("returns clean core agent display names without ZWSP prefixes in keys", () => {
// given
const result = remapAgentKeysToDisplayNames({
atlas: {},
Expand All @@ -94,16 +94,19 @@ describe("remapAgentKeysToDisplayNames", () => {
// when
const remappedNames = Object.keys(result)

// then
// then keys have no ZWSP
expect(remappedNames).toEqual([
getAgentListDisplayName("atlas"),
getAgentListDisplayName("prometheus"),
getAgentListDisplayName("hephaestus"),
getAgentListDisplayName("sisyphus"),
getAgentDisplayName("atlas"),
getAgentDisplayName("prometheus"),
getAgentDisplayName("hephaestus"),
getAgentDisplayName("sisyphus"),
])
for (const name of remappedNames) {
expect(name).not.toContain("\u200B")
}
})

it("keeps remapped core agent name fields aligned with OpenCode list ordering", () => {
it("preserves clean keys and rewrites core agent name fields to display names (no ZWSP)", () => {
// given agents with raw config-key names
const agents = {
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
Expand All @@ -116,37 +119,37 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping
const result = remapAgentKeysToDisplayNames(agents)

// then keys and names both use the same runtime-facing list names
// then both keys and name fields are HTTP-header-safe (no ZWSP)
expect(Object.keys(result).slice(0, 4)).toEqual([
getAgentListDisplayName("sisyphus"),
getAgentListDisplayName("hephaestus"),
getAgentListDisplayName("prometheus"),
getAgentListDisplayName("atlas"),
getAgentDisplayName("sisyphus"),
getAgentDisplayName("hephaestus"),
getAgentDisplayName("prometheus"),
getAgentDisplayName("atlas"),
])
expect(result[getAgentListDisplayName("sisyphus")]).toEqual({
name: getAgentRuntimeName("sisyphus"),
expect(result[getAgentDisplayName("sisyphus")]).toEqual({
name: getAgentDisplayName("sisyphus"),
prompt: "test",
mode: "primary",
})
expect(result[getAgentListDisplayName("hephaestus")]).toEqual({
name: getAgentRuntimeName("hephaestus"),
expect(result[getAgentDisplayName("hephaestus")]).toEqual({
name: getAgentDisplayName("hephaestus"),
prompt: "test",
mode: "primary",
})
expect(result[getAgentListDisplayName("prometheus")]).toEqual({
name: getAgentRuntimeName("prometheus"),
expect(result[getAgentDisplayName("prometheus")]).toEqual({
name: getAgentDisplayName("prometheus"),
prompt: "test",
mode: "all",
})
expect(result[getAgentListDisplayName("atlas")]).toEqual({
name: getAgentRuntimeName("atlas"),
expect(result[getAgentDisplayName("atlas")]).toEqual({
name: getAgentDisplayName("atlas"),
prompt: "test",
mode: "primary",
})
expect(result.oracle).toEqual({ name: "oracle", prompt: "test", mode: "subagent" })
})

it("backfills runtime names for core agents when builtin configs omit name", () => {
it("backfills display names for core agents when builtin configs omit name", () => {
// given builtin-style configs without name fields
const agents = {
sisyphus: { prompt: "test", mode: "primary" },
Expand All @@ -158,24 +161,24 @@ describe("remapAgentKeysToDisplayNames", () => {
// when remapping
const result = remapAgentKeysToDisplayNames(agents)

// then runtime-facing names stay aligned even when builtin configs omit name
expect(result[getAgentListDisplayName("sisyphus")]).toEqual({
name: getAgentRuntimeName("sisyphus"),
// then name fields are clean display names (no ZWSP); ordering is via `order` field
expect(result[getAgentDisplayName("sisyphus")]).toEqual({
name: getAgentDisplayName("sisyphus"),
prompt: "test",
mode: "primary",
})
expect(result[getAgentListDisplayName("hephaestus")]).toEqual({
name: getAgentRuntimeName("hephaestus"),
expect(result[getAgentDisplayName("hephaestus")]).toEqual({
name: getAgentDisplayName("hephaestus"),
prompt: "test",
mode: "primary",
})
expect(result[getAgentListDisplayName("prometheus")]).toEqual({
name: getAgentRuntimeName("prometheus"),
expect(result[getAgentDisplayName("prometheus")]).toEqual({
name: getAgentDisplayName("prometheus"),
prompt: "test",
mode: "all",
})
expect(result[getAgentListDisplayName("atlas")]).toEqual({
name: getAgentRuntimeName("atlas"),
expect(result[getAgentDisplayName("atlas")]).toEqual({
name: getAgentDisplayName("atlas"),
prompt: "test",
mode: "primary",
})
Expand Down
6 changes: 3 additions & 3 deletions src/plugin-handlers/agent-key-remapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAgentListDisplayName, getAgentRuntimeName } from "../shared/agent-display-names"
import { getAgentDisplayName } from "../shared/agent-display-names"

function rewriteAgentNameForListDisplay(
key: string,
Expand All @@ -11,7 +11,7 @@ function rewriteAgentNameForListDisplay(
const agent = value as Record<string, unknown>
return {
...agent,
name: getAgentRuntimeName(key),
name: getAgentDisplayName(key),
}
}

Expand All @@ -21,7 +21,7 @@ export function remapAgentKeysToDisplayNames(
const result: Record<string, unknown> = {}

for (const [key, value] of Object.entries(agents)) {
const displayName = getAgentListDisplayName(key)
const displayName = getAgentDisplayName(key)
if (displayName && displayName !== key) {
result[displayName] = rewriteAgentNameForListDisplay(key, value)
// Regression guard: do not also assign result[key].
Expand Down
10 changes: 5 additions & 5 deletions src/plugin-handlers/agent-priority-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
reorderAgentsByPriority,
CANONICAL_CORE_AGENT_ORDER,
} from "./agent-priority-order"
import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names"
import { getAgentDisplayName } from "../shared/agent-display-names"

describe("agent-priority-order", () => {
describe("CANONICAL_CORE_AGENT_ORDER", () => {
Expand Down Expand Up @@ -36,10 +36,10 @@ describe("agent-priority-order", () => {

describe("reorderAgentsByPriority", () => {
// given: display names for all core agents
const sisyphus = getAgentListDisplayName("sisyphus")
const hephaestus = getAgentListDisplayName("hephaestus")
const prometheus = getAgentListDisplayName("prometheus")
const atlas = getAgentListDisplayName("atlas")
const sisyphus = getAgentDisplayName("sisyphus")
const hephaestus = getAgentDisplayName("hephaestus")
const prometheus = getAgentDisplayName("prometheus")
const atlas = getAgentDisplayName("atlas")
const oracle = getAgentDisplayName("oracle")
const librarian = getAgentDisplayName("librarian")
const explore = getAgentDisplayName("explore")
Expand Down
4 changes: 2 additions & 2 deletions src/plugin-handlers/agent-priority-order.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAgentListDisplayName } from "../shared/agent-display-names"
import { getAgentDisplayName } from "../shared/agent-display-names"

/**
* CRITICAL: This is the ONLY source of truth for core agent ordering.
Expand All @@ -25,7 +25,7 @@ const CORE_AGENT_ORDER: ReadonlyArray<{
order: number
}> = CANONICAL_CORE_AGENT_ORDER.map((configKey, index) => ({
configKey,
displayName: getAgentListDisplayName(configKey),
displayName: getAgentDisplayName(configKey),
order: index + 1,
}))

Expand Down
5 changes: 2 additions & 3 deletions src/plugin-handlers/command-config-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type { PluginComponents } from "./plugin-components-loader";
import { applyCommandConfig } from "./command-config-handler";
import {
getAgentDisplayName,
getAgentListDisplayName,
} from "../shared/agent-display-names";

function createPluginComponents(): PluginComponents {
Expand Down Expand Up @@ -130,7 +129,7 @@ describe("applyCommandConfig", () => {

// then
const commandConfig = config.command as Record<string, { agent?: string }>;
expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas"));
expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas"));
});

test("normalizes legacy display-name command agents to the runtime list name", async () => {
Expand All @@ -155,6 +154,6 @@ describe("applyCommandConfig", () => {

// then
const commandConfig = config.command as Record<string, { agent?: string }>;
expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas"));
expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas"));
});
});
4 changes: 2 additions & 2 deletions src/plugin-handlers/command-config-handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OhMyOpenCodeConfig } from "../config";
import {
getAgentConfigKey,
getAgentListDisplayName,
getAgentDisplayName,
} from "../shared/agent-display-names";
import {
loadUserCommands,
Expand Down Expand Up @@ -99,7 +99,7 @@ export async function applyCommandConfig(params: {
function remapCommandAgentFields(commands: Record<string, Record<string, unknown>>): void {
for (const cmd of Object.values(commands)) {
if (cmd?.agent && typeof cmd.agent === "string") {
cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent));
cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent));
}
}
}
Loading
Loading