From 8b418ea38a8ebfd48334001b48f5bd33a2a18ac7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 10:10:12 +0900 Subject: [PATCH] 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 --- src/plugin-interface.test.ts | 1 - src/plugin/chat-message.test.ts | 8 +++-- src/shared/agent-display-names.test.ts | 44 +++++++++++++++++-------- src/shared/agent-display-names.ts | 45 +++++++++++++++++--------- 4 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts index 4dac3f7be..fea4752e2 100644 --- a/src/plugin-interface.test.ts +++ b/src/plugin-interface.test.ts @@ -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, diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index 6dd7a0397..2d96f6065 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -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 diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 353bfb31e..b77a5e1ff 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -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") }) }) diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index 426425851..2ccb545ad 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -26,13 +26,23 @@ export const AGENT_DISPLAY_NAMES: Record = { "council-member": "council-member", } -const AGENT_LIST_SORT_PREFIXES: Record = { - 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 = Object.fromEntries(