fix(agents): remove ZWSP sort prefixes from display name helper (#3259)

AGENT_LIST_SORT_PREFIXES prepended U+200B Zero Width Space characters
to the four core agent display names so they would sort ahead of user
agents in the Tab cycle. Two problems with that approach surfaced:

  1. Some terminal emulators (Ghostty, certain Windows Terminal
     builds) render ZWSP as a visible box or extra space, producing a
     visible black gap in the status bar before "Sisyphus" and
     misaligning the layout (#3259).

  2. The prefixes leaked into the plugin API surface via config.agent
     keys, breaking prompt_async consumers that received
     ZWSP-contaminated agent names (#3238).

#3242 already removed every call site of getAgentListDisplayName() in
production code. That made the sort prefixes dead code: the constant
table was still defined but nothing read it. This PR finishes the
cleanup by:

  - Deleting the AGENT_LIST_SORT_PREFIXES constant entirely
  - Turning getAgentListDisplayName() into a thin alias over
    getAgentDisplayName() for BC with external importers
  - Keeping stripAgentListSortPrefix() as a legacy data migration for
    users upgrading from v3.14.0-v3.16.0 whose config.agent keys may
    still have ZWSP baked in from the old code path
  - Documenting the history on stripAgentListSortPrefix() so future
    maintainers understand why the stripper has to stay even after
    the injector is gone

Sort ordering is preserved via JS object insertion order in
reorderAgentsByPriority() plus the `order` field it injects on the
four core agents. Both mechanisms are already in place and both
pre-date this PR; the ZWSP prefix was an older third layer that was
only meant to work around alphabetical sorting in legacy OpenCode
before the `order` field landed upstream.

Tests: 4445 pass, 0 fail. Added 3 new assertions to
agent-display-names.test.ts verifying that getAgentListDisplayName
returns plain names containing no zero-width characters. Updated
chat-message.test.ts to use a literal ZWSP string instead of the
helper so the defensive-strip path still has coverage.

Closes #3259
This commit is contained in:
YeonGyu-Kim
2026-04-09 10:10:12 +09:00
parent d2bb5d57d1
commit 8b418ea38a
4 changed files with 64 additions and 34 deletions
-1
View File
@@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto"
import { createPluginInterface } from "./plugin-interface"
import { createAutoSlashCommandHook } from "./hooks/auto-slash-command"
import { createStartWorkHook } from "./hooks/start-work"
import { getAgentListDisplayName } from "./shared/agent-display-names"
import { readBoulderState } from "./features/boulder-state"
import {
_resetForTesting,
+5 -3
View File
@@ -9,7 +9,6 @@ import { createAutoSlashCommandHook } from "../hooks/auto-slash-command"
import { createStartWorkHook } from "../hooks/start-work"
import { readBoulderState } from "../features/boulder-state"
import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, updateSessionAgent, getSessionAgent } from "../features/claude-code-session-state"
import { getAgentListDisplayName } from "../shared/agent-display-names"
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
@@ -403,7 +402,10 @@ describe("createChatMessageHandler - TUI variant passthrough", () => {
expect(getSessionModel("test-session")).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
})
test("treats prefixed list-display agent names as explicit model overrides", async () => {
test("treats legacy ZWSP-prefixed agent names as explicit model overrides (GH-3259)", async () => {
// Users upgrading from v3.14.0-v3.16.0 may still have ZWSP-prefixed agent
// keys persisted in their session state. The handler must strip the
// prefix and resolve to the canonical display name.
//#given
setMainSession("test-session")
setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" })
@@ -416,7 +418,7 @@ describe("createChatMessageHandler - TUI variant passthrough", () => {
},
})
const handler = createChatMessageHandler(args)
const input = createMockInput(getAgentListDisplayName("prometheus"))
const input = createMockInput("\u200B\u200B\u200BPrometheus - Plan Builder")
const output = createMockOutput()
//#when
+30 -14
View File
@@ -183,30 +183,46 @@ describe("getAgentConfigKey", () => {
expect(getAgentConfigKey("Sisyphus-Junior")).toBe("sisyphus-junior")
})
it("resolves atlas even when the UI ordering prefix is present", () => {
expect(getAgentConfigKey(getAgentListDisplayName("atlas"))).toBe("atlas")
it("resolves atlas even when a legacy ZWSP sort prefix is present on the stored key", () => {
// Users who installed v3.14.0 through v3.16.0 may have ZWSP-prefixed agent
// names baked into their config.agent keys. The resolver must still find
// the canonical config key after strip.
expect(getAgentConfigKey("\u200B\u200B\u200B\u200BAtlas - Plan Executor")).toBe("atlas")
})
})
describe("getAgentListDisplayName", () => {
it("applies invisible stable-sort prefixes to the core agent list", () => {
expect(getAgentListDisplayName("sisyphus")).toBe("\u200BSisyphus - Ultraworker")
expect(getAgentListDisplayName("hephaestus")).toBe("\u200B\u200BHephaestus - Deep Agent")
expect(getAgentListDisplayName("prometheus")).toBe("\u200B\u200B\u200BPrometheus - Plan Builder")
expect(getAgentListDisplayName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
describe("getAgentListDisplayName (deprecated alias, GH-3259)", () => {
it("returns plain display names without the legacy ZWSP sort prefix", () => {
// ZWSP prefixes were removed in #3242/#3259. This alias is retained for
// external callers that may still import it, but it now behaves
// identically to getAgentDisplayName.
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", () => {
it("matches getAgentDisplayName for unknown agents", () => {
expect(getAgentListDisplayName("oracle")).toBe("oracle")
})
it("contains no zero-width characters in any core agent output (GH-3259)", () => {
const coreAgents = ["sisyphus", "hephaestus", "prometheus", "atlas"]
for (const agent of coreAgents) {
const result = getAgentListDisplayName(agent)
expect(result).not.toMatch(/[\u200B\u200C\u200D\uFEFF]/)
}
})
})
describe("normalizeAgentForPrompt", () => {
it("strips core UI ordering prefixes back to canonical display names", () => {
expect(normalizeAgentForPrompt(getAgentListDisplayName("sisyphus"))).toBe("Sisyphus - Ultraworker")
expect(normalizeAgentForPrompt(getAgentListDisplayName("hephaestus"))).toBe("Hephaestus - Deep Agent")
expect(normalizeAgentForPrompt(getAgentListDisplayName("prometheus"))).toBe("Prometheus - Plan Builder")
expect(normalizeAgentForPrompt(getAgentListDisplayName("atlas"))).toBe("Atlas - Plan Executor")
it("strips legacy ZWSP sort prefixes from stored agent keys back to canonical display names", () => {
// Configs from v3.14.0-v3.16.0 may persist ZWSP-prefixed keys. The
// normalizer must restore the canonical name on read.
expect(normalizeAgentForPrompt("\u200BSisyphus - Ultraworker")).toBe("Sisyphus - Ultraworker")
expect(normalizeAgentForPrompt("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent")
expect(normalizeAgentForPrompt("\u200B\u200B\u200BPrometheus - Plan Builder")).toBe("Prometheus - Plan Builder")
expect(normalizeAgentForPrompt("\u200B\u200B\u200B\u200BAtlas - Plan Executor")).toBe("Atlas - Plan Executor")
})
})
+29 -16
View File
@@ -26,13 +26,23 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
"council-member": "council-member",
}
const AGENT_LIST_SORT_PREFIXES: Record<string, string> = {
sisyphus: "\u200B",
hephaestus: "\u200B\u200B",
prometheus: "\u200B\u200B\u200B",
atlas: "\u200B\u200B\u200B\u200B",
}
/**
* Strip the legacy zero-width-space sort prefix from an agent name.
*
* v3.14.0 through v3.16.0 prefixed the four core agents (Sisyphus,
* Hephaestus, Prometheus, Atlas) with U+200B Zero Width Space characters
* so they would sort ahead of user agents in the Tab cycle. Some terminal
* emulators (Ghostty, certain Windows Terminal builds) render ZWSP as a
* visible box or extra space, breaking the status bar layout (#3259), and
* the prefixes also leaked through the plugin API and broke prompt_async
* consumers (#3238).
*
* The prefixes are no longer injected anywhere (#3242 removed all call
* sites and #3259 removed the constant table). This helper remains so
* existing user configs that still have the ZWSP baked into their
* `config.agent` keys from an older install continue to resolve
* correctly after upgrading.
*/
export function stripAgentListSortPrefix(agentName: string): string {
return agentName.replace(/^\u200B+/, "")
}
@@ -58,17 +68,20 @@ export function getAgentDisplayName(configKey: string): string {
}
/**
* @deprecated Do NOT use for config.agent keys or API-facing names.
* ZWSP prefixes leak into the /agent API response and break prompt_async consumers.
* Use getAgentDisplayName() instead. The `order` field injected by
* reorderAgentsByPriority() handles sort ordering without invisible characters.
* See: https://github.com/code-yeongyu/oh-my-openagent/issues/3238
* @deprecated Use {@link getAgentDisplayName} directly.
*
* Historically this returned the display name with a ZWSP sort prefix
* prepended so core agents would sort ahead of user agents in the Tab
* cycle. The ZWSP prefixes caused visible rendering artifacts in some
* terminals (#3259) and leaked into the plugin API surface (#3238), so
* they were removed in #3242/#3259. This function is now a thin alias
* over {@link getAgentDisplayName} that exists only for external
* callers that may still import it. Sort ordering is now handled by
* the `order` field injection in `reorderAgentsByPriority()` plus the
* core-first insertion order in the same helper.
*/
export function getAgentListDisplayName(configKey: string): string {
const displayName = getAgentDisplayName(configKey)
const prefix = AGENT_LIST_SORT_PREFIXES[configKey.toLowerCase()]
return prefix ? `${prefix}${displayName}` : displayName
return getAgentDisplayName(configKey)
}
const REVERSE_DISPLAY_NAMES: Record<string, string> = Object.fromEntries(