From f100a8565b3a10c5a4883e469048e0bf5c71d7c5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 27 Apr 2026 15:30:20 +0900 Subject: [PATCH] fix(agents): keep object keys clean by separating list display from runtime name Discovered via post-implementation review (Oracle goal verification): the prior commit's prefix swap (ZWSP -> ASCII spaces) inherited a pre-existing architectural bug from the ZWSP era. `getAgentListDisplayName()` was an alias for `getAgentRuntimeName()`, which meant every callsite that used the "list display" name as an OBJECT KEY (config.agent keys, lookup keys, HTTP-header-bound paths) ended up carrying the sort prefix. This worked silently with ZWSP because zero-width characters are visually invisible. With ASCII space prefixes, the same bug becomes user-visible and violates the explicit RFC 7230 constraint documented in AGENTS.md: "ZWSP MUST NOT appear in object keys (used as HTTP header values)." Fix: separate the two concepts that were conflated. - `getAgentListDisplayName(key)` now returns the CLEAN display name (alias of `getAgentDisplayName`). Used for object keys, config keys, and any path where the name will be sent over HTTP. - `getAgentRuntimeName(key)` keeps its prefixed return value. Used ONLY for the `.name` field that OpenCode reads for `localeCompare` sort. `agent-key-remapper.ts` was already correct: it uses `getAgentRuntimeName` for the `.name` field. The bug was that `getAgentListDisplayName` (used as the object key) also returned the prefix. Test updates: - agent-display-names.test.ts splits the assertions: getAgentListDisplayName asserts clean names, new getAgentRuntimeName describe asserts prefixes - All other tests using getAgentListDisplayName as an expected object key continue to pass because they always wanted clean names Verification: - bun test: 5769 pass / 10 pre-existing failures (unchanged) - bun run typecheck: clean - Manual: agent-key-remapper output keys verified RFC 7230 safe (no leading whitespace, no ZWSP); name fields preserve descending-space prefix for canonical core agent ordering --- src/shared/agent-display-names.test.ts | 25 +++++++++++++++++++------ src/shared/agent-display-names.ts | 17 +++-------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 0fb52ec06..e4abea669 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentDisplayName, getAgentListDisplayName, normalizeAgentForPrompt, normalizeAgentForPromptKey } from "./agent-display-names" +import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentDisplayName, getAgentListDisplayName, getAgentRuntimeName, normalizeAgentForPrompt, normalizeAgentForPromptKey } from "./agent-display-names" describe("getAgentDisplayName", () => { it("returns display name for lowercase config key (new format)", () => { @@ -194,11 +194,11 @@ describe("getAgentConfigKey", () => { }) describe("getAgentListDisplayName", () => { - it("applies leading-space stable-sort prefixes so OpenCode localeCompare yields canonical order", () => { - expect(getAgentListDisplayName("sisyphus")).toBe(" Sisyphus - Ultraworker") - expect(getAgentListDisplayName("hephaestus")).toBe(" Hephaestus - Deep Agent") - expect(getAgentListDisplayName("prometheus")).toBe(" Prometheus - Plan Builder") - expect(getAgentListDisplayName("atlas")).toBe(" Atlas - Plan Executor") + it("returns clean display names for object keys (no leading whitespace, RFC 7230 safe)", () => { + expect(getAgentListDisplayName("sisyphus")).toBe("Sisyphus - Ultraworker") + expect(getAgentListDisplayName("hephaestus")).toBe("Hephaestus - Deep Agent") + expect(getAgentListDisplayName("prometheus")).toBe("Prometheus - Plan Builder") + expect(getAgentListDisplayName("atlas")).toBe("Atlas - Plan Executor") }) it("keeps non-core agents unprefixed for list display", () => { @@ -206,6 +206,19 @@ describe("getAgentListDisplayName", () => { }) }) +describe("getAgentRuntimeName", () => { + it("applies leading-space stable-sort prefixes so OpenCode localeCompare yields canonical order", () => { + expect(getAgentRuntimeName("sisyphus")).toBe(" Sisyphus - Ultraworker") + expect(getAgentRuntimeName("hephaestus")).toBe(" Hephaestus - Deep Agent") + expect(getAgentRuntimeName("prometheus")).toBe(" Prometheus - Plan Builder") + expect(getAgentRuntimeName("atlas")).toBe(" Atlas - Plan Executor") + }) + + it("keeps non-core agents unprefixed (no entry in AGENT_LIST_SORT_PREFIXES)", () => { + expect(getAgentRuntimeName("oracle")).toBe("oracle") + }) +}) + describe("normalizeAgentForPrompt", () => { it("strips core UI ordering prefixes back to canonical display names", () => { expect(normalizeAgentForPrompt(getAgentListDisplayName("sisyphus"))).toBe("Sisyphus - Ultraworker") diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index 081550d7c..10bf91845 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -50,31 +50,20 @@ export function getAgentRuntimeName(configKey: string): string { return prefix ? `${prefix}${displayName}` : displayName } -/** - * Get display name for an agent config key. - * Uses case-insensitive lookup for backward compatibility. - * Returns original key if not found. - */ export function getAgentDisplayName(configKey: string): string { - // Try exact match first const exactMatch = AGENT_DISPLAY_NAMES[configKey] if (exactMatch !== undefined) return exactMatch - - // Fall back to case-insensitive search + const lowerKey = configKey.toLowerCase() for (const [k, v] of Object.entries(AGENT_DISPLAY_NAMES)) { if (k.toLowerCase() === lowerKey) return v } - - // Unknown agent: return original key + return configKey } -/** - * Runtime-facing agent name used for OpenCode list ordering. - */ export function getAgentListDisplayName(configKey: string): string { - return getAgentRuntimeName(configKey) + return getAgentDisplayName(configKey) } const REVERSE_DISPLAY_NAMES: Record = Object.fromEntries(