refactor(agents): replace broken ZWSP sort prefixes with leading ASCII spaces

The ZWSP-based core agent sort prefixes silently failed to produce the
canonical sisyphus -> hephaestus -> prometheus -> atlas order.

Empirical testing of OpenCode's Agent.list() sort behavior shows that
Unicode collation treats zero-width characters as ignorable at the primary
level, so ZWSP-prefixed names sorted alphabetically with non-core agents
interleaved (e.g. Sisyphus, athena, Atlas, explore, Hephaestus, ...).

This commit replaces the ZWSP prefixes with leading ASCII spaces in
descending lengths (sisyphus=4, hephaestus=3, prometheus=2, atlas=1).
ASCII spaces sort reliably before alphabetic characters in localeCompare
under all locales and render correctly in every terminal.

Changes:
- AGENT_LIST_SORT_PREFIXES: ZWSP -> leading spaces (4-3-2-1 descending)
- stripAgentListSortPrefix: now strips both legacy ZWSP and new leading
  whitespace, preserving backward compatibility with existing sessions
- normalizeStoredAgentName / normalizeRegisteredAgentName: extract a
  shared stripSortPrefix helper that handles both prefix formats
- agent-config-handler: resolve user-provided default_agent display
  names through getAgentConfigKey before applying the runtime prefix,
  so configs like default_agent="Hephaestus - Deep Agent" are normalized
- agent-runtime-name-sort.test.ts: new regression test simulating
  OpenCode's exact sortBy logic (default_agent desc + name asc localeCompare)
  to verify canonical core agent order under randomised input permutations
- AGENTS.md: document the empirical finding that ZWSP was broken, why
  ASCII spaces work, and the descending prefix-length contract

Existing strip functions retain ZWSP support so legacy session state and
configs continue to resolve correctly without migration.
This commit is contained in:
YeonGyu-Kim
2026-04-27 13:48:09 +09:00
parent f74d03ca90
commit 0e9bb5969d
6 changed files with 196 additions and 20 deletions
+22 -5
View File
@@ -8,28 +8,45 @@ 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-key-remapper.ts` injects leading-space-prefixed runtime names into the `name` field for OpenCode's `localeCompare` sort
### Why Two Mechanisms
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.
OpenCode's `Agent.list()` sorts agents by `name` field via `localeCompare`. Object key order alone is not enough. The `name` field carries leading ASCII spaces (4-3-2-1 descending) so core agents sort before alphabetically-named agents.
ZWSP is intentionally used in the `name` field only. It MUST NOT appear in:
The prefix lengths are intentionally **descending** (sisyphus=4, hephaestus=3, prometheus=2, atlas=1) because `localeCompare` puts strings with more leading whitespace before strings with fewer. Reference: see `agent-runtime-name-sort.test.ts` for empirical verification.
### Why ASCII Spaces, Not ZWSP
Earlier versions used ZWSP (`\u200B`) prefixes hoping they would be invisible to users. They silently failed: Unicode collation algorithms treat zero-width characters as ignorable at the primary level, so ZWSP-prefixed names sorted as if the prefix did not exist. The result was alphabetical order interleaving core and non-core agents.
ASCII space (`\u0020`) is the only character that:
- Sorts before alphabetic characters reliably under all locales
- Renders correctly in every terminal (no glyph substitution)
- Is valid in HTTP header values (RFC 7230) when placed in the `name` field
The leading-space prefix MUST NOT appear in:
- Object keys (used as HTTP header values, causes RFC 7230 violations)
- Display names returned by `getAgentDisplayName()`
- Config keys
### Backward Compatibility
`stripAgentListSortPrefix()` strips both the new leading-space prefix AND legacy ZWSP/zero-width characters. Existing sessions and configs from the ZWSP era continue to resolve correctly.
### History
Agent ordering has caused 15+ commits, 8+ PRs, and multiple reverts due to:
Agent ordering 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. The ZWSP prefix being silently broken in `localeCompare` sort (resolved in this commit by switching to leading ASCII spaces)
### Forbidden Patterns
DO NOT introduce:
- ZWSP in object keys or display names (only allowed in `name` field via `getAgentRuntimeName()`)
- ZWSP in any field (broken in `localeCompare`, replaced by leading ASCII spaces)
- Leading whitespace in object keys or display names (allowed only in `name` field via `getAgentRuntimeName()`)
- Runtime sort shims or comparators
- Alternative ordering constants
- Object.entries() order dependencies
+6 -3
View File
@@ -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 { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentRuntimeName } from "../shared/agent-display-names";
import { AGENT_NAME_MAP } from "../shared/migration";
import { registerAgentName } from "../features/claude-code-session-state";
import {
@@ -189,8 +189,11 @@ export async function applyAgentConfig(params: {
if (isSisyphusEnabled && builtinAgents.sisyphus) {
if (configuredDefaultAgent) {
(params.config as { default_agent?: string }).default_agent =
getAgentRuntimeName(configuredDefaultAgent);
const configKey = getAgentConfigKey(configuredDefaultAgent);
const isKnownBuiltin = configKey in AGENT_DISPLAY_NAMES;
(params.config as { default_agent?: string }).default_agent = isKnownBuiltin
? getAgentRuntimeName(configKey)
: configuredDefaultAgent;
} else {
(params.config as { default_agent?: string }).default_agent =
getAgentRuntimeName("sisyphus");