fix: remove ZWSP from agent display names (#3146)

This commit is contained in:
YeonGyu-Kim
2026-04-07 15:30:51 +09:00
parent b269f11522
commit 6eb527c914
9 changed files with 109 additions and 42 deletions
@@ -28,7 +28,7 @@ describe("claude-code-session-state", () => {
test("should store agent for session", () => { test("should store agent for session", () => {
// given // given
const sessionID = "test-session-1" const sessionID = "test-session-1"
const agent = "Prometheus (Planner)" const agent = "Prometheus - Plan Builder"
// when // when
setSessionAgent(sessionID, agent) setSessionAgent(sessionID, agent)
@@ -52,13 +52,13 @@ describe("claude-code-session-state", () => {
test("should NOT overwrite existing agent (first-write wins)", () => { test("should NOT overwrite existing agent (first-write wins)", () => {
// given // given
const sessionID = "test-session-1" const sessionID = "test-session-1"
setSessionAgent(sessionID, "Prometheus (Planner)") setSessionAgent(sessionID, "Prometheus - Plan Builder")
// when - try to overwrite // when - try to overwrite
setSessionAgent(sessionID, "sisyphus") setSessionAgent(sessionID, "sisyphus")
// then - first agent preserved // then - first agent preserved
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)") expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder")
}) })
test("should return undefined for unknown session", () => { test("should return undefined for unknown session", () => {
@@ -73,7 +73,7 @@ describe("claude-code-session-state", () => {
test("should overwrite existing agent", () => { test("should overwrite existing agent", () => {
// given // given
const sessionID = "test-session-1" const sessionID = "test-session-1"
setSessionAgent(sessionID, "Prometheus (Planner)") setSessionAgent(sessionID, "Prometheus - Plan Builder")
// when - force update // when - force update
updateSessionAgent(sessionID, "sisyphus") updateSessionAgent(sessionID, "sisyphus")
@@ -99,8 +99,8 @@ describe("claude-code-session-state", () => {
test("should remove agent from session", () => { test("should remove agent from session", () => {
// given // given
const sessionID = "test-session-1" const sessionID = "test-session-1"
setSessionAgent(sessionID, "Prometheus (Planner)") setSessionAgent(sessionID, "Prometheus - Plan Builder")
expect(getSessionAgent(sessionID)).toBe("Prometheus (Planner)") expect(getSessionAgent(sessionID)).toBe("Prometheus - Plan Builder")
// when // when
clearSessionAgent(sessionID) clearSessionAgent(sessionID)
@@ -160,15 +160,15 @@ describe("claude-code-session-state", () => {
test("should correctly identify Prometheus agent for permission checks", () => { test("should correctly identify Prometheus agent for permission checks", () => {
// given - Prometheus session // given - Prometheus session
const sessionID = "test-prometheus-session" const sessionID = "test-prometheus-session"
const prometheusAgent = "Prometheus (Planner)" const prometheusAgent = "Prometheus - Plan Builder"
// when - agent is set (simulating chat.message hook) // when - agent is set (simulating chat.message hook)
setSessionAgent(sessionID, prometheusAgent) setSessionAgent(sessionID, prometheusAgent)
// then - getSessionAgent returns correct agent for prometheus-md-only hook // then - getSessionAgent returns correct agent for prometheus-md-only hook
const agent = getSessionAgent(sessionID) const agent = getSessionAgent(sessionID)
expect(agent).toBe("Prometheus (Planner)") expect(agent).toBe("Prometheus - Plan Builder")
expect(["Prometheus (Planner)"].includes(agent!)).toBe(true) expect(["Prometheus - Plan Builder"].includes(agent!)).toBe(true)
}) })
test("should return undefined when agent not set (bug scenario)", () => { test("should return undefined when agent not set (bug scenario)", () => {
+2 -2
View File
@@ -51,14 +51,14 @@ ${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)}
│ │ - Record decisions to .sisyphus/drafts/ │ │ │ - Record decisions to .sisyphus/drafts/ │
├──────┼──────────────────────────────────────────────────────────────┤ ├──────┼──────────────────────────────────────────────────────────────┤
│ 2 │ METIS CONSULTATION: Pre-generation gap analysis │ │ 2 │ METIS CONSULTATION: Pre-generation gap analysis │
│ │ - task(agent="Metis (Plan Consultant)", ...) │ │ │ - task(agent="Metis - Plan Consultant", ...) │
│ │ - Identify missed questions, guardrails, assumptions │ │ │ - Identify missed questions, guardrails, assumptions │
├──────┼──────────────────────────────────────────────────────────────┤ ├──────┼──────────────────────────────────────────────────────────────┤
│ 3 │ PLAN GENERATION: Write to .sisyphus/plans/*.md │ │ 3 │ PLAN GENERATION: Write to .sisyphus/plans/*.md │
│ │ <- YOU ARE HERE │ │ │ <- YOU ARE HERE │
├──────┼──────────────────────────────────────────────────────────────┤ ├──────┼──────────────────────────────────────────────────────────────┤
│ 4 │ MOMUS REVIEW (if high accuracy requested) │ │ 4 │ MOMUS REVIEW (if high accuracy requested) │
│ │ - task(agent="Momus (Plan Reviewer)", ...) │ │ │ - task(agent="Momus - Plan Critic", ...)
│ │ - Loop until OKAY verdict │ │ │ - Loop until OKAY verdict │
├──────┼──────────────────────────────────────────────────────────────┤ ├──────┼──────────────────────────────────────────────────────────────┤
│ 5 │ SUMMARY: Present to user │ │ 5 │ SUMMARY: Present to user │
+1 -1
View File
@@ -113,7 +113,7 @@ describe("prometheus-md-only", () => {
test("should enforce md-only restriction for Prometheus display name Planner", async () => { test("should enforce md-only restriction for Prometheus display name Planner", async () => {
//#given //#given
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)") setupMessageStorage(TEST_SESSION_ID, "Prometheus - Plan Builder")
const hook = createPrometheusMdOnlyHook(createMockPluginInput()) const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = { const input = {
tool: "Write", tool: "Write",
@@ -158,6 +158,25 @@ describe("applyAgentConfig builtin override protection", () => {
logSpy.mockRestore() logSpy.mockRestore()
}) })
test("registered agent keys are HTTP-header-safe (no parentheses) for UI selector compatibility", async () => {
// given builtin agents are registered via applyAgentConfig
// when applyAgentConfig runs
const result = await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then every registered agent key must be HTTP-header-safe (no parentheses)
// Parentheses in agent names cause HTTP header validation errors in
// x-opencode-agent-name and prevent the agents from showing in the OpenCode UI.
for (const key of Object.keys(result)) {
expect(key).not.toMatch(/[()]/)
}
})
test("filters user agents whose key matches the builtin display-name alias", async () => { test("filters user agents whose key matches the builtin display-name alias", async () => {
// given // given
loadUserAgentsSpy.mockReturnValue({ loadUserAgentsSpy.mockReturnValue({
+8 -8
View File
@@ -10,9 +10,9 @@ describe("Agent Config Integration", () => {
const oldConfig = { const oldConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-6" }, Sisyphus: { model: "anthropic/claude-opus-4-6" },
Atlas: { model: "anthropic/claude-opus-4-6" }, Atlas: { model: "anthropic/claude-opus-4-6" },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" }, "Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" },
"Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-6" }, "Metis - Plan Consultant": { model: "anthropic/claude-sonnet-4-6" },
"Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-6" }, "Momus - Plan Critic": { model: "anthropic/claude-sonnet-4-6" },
} }
// when - migration is applied // when - migration is applied
@@ -28,9 +28,9 @@ describe("Agent Config Integration", () => {
// then - old keys are removed // then - old keys are removed
expect(result.migrated).not.toHaveProperty("Sisyphus") expect(result.migrated).not.toHaveProperty("Sisyphus")
expect(result.migrated).not.toHaveProperty("Atlas") expect(result.migrated).not.toHaveProperty("Atlas")
expect(result.migrated).not.toHaveProperty("Prometheus (Planner)") expect(result.migrated).not.toHaveProperty("Prometheus - Plan Builder")
expect(result.migrated).not.toHaveProperty("Metis (Plan Consultant)") expect(result.migrated).not.toHaveProperty("Metis - Plan Consultant")
expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)") expect(result.migrated).not.toHaveProperty("Momus - Plan Critic")
// then - values are preserved // then - values are preserved
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6" }) expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6" })
@@ -64,7 +64,7 @@ describe("Agent Config Integration", () => {
const mixedConfig = { const mixedConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-6" }, Sisyphus: { model: "anthropic/claude-opus-4-6" },
oracle: { model: "openai/gpt-5.4" }, oracle: { model: "openai/gpt-5.4" },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" }, "Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" },
librarian: { model: "opencode/big-pickle" }, librarian: { model: "opencode/big-pickle" },
} }
@@ -174,7 +174,7 @@ describe("Agent Config Integration", () => {
// given - old format config // given - old format config
const oldConfig = { const oldConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-6", temperature: 0.1 }, Sisyphus: { model: "anthropic/claude-opus-4-6", temperature: 0.1 },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" }, "Prometheus - Plan Builder": { model: "anthropic/claude-opus-4-6" },
} }
// when - config is migrated // when - config is migrated
+24 -5
View File
@@ -150,6 +150,14 @@ describe("getAgentConfigKey", () => {
expect(getAgentConfigKey("atlas - plan executor")).toBe("atlas") expect(getAgentConfigKey("atlas - plan executor")).toBe("atlas")
}) })
it("resolves legacy parenthesized display names", () => {
// given legacy parenthesized display name from old configs/sessions
// when getAgentConfigKey called
// then resolves to canonical config key
expect(getAgentConfigKey("Sisyphus (Ultraworker)")).toBe("sisyphus")
expect(getAgentConfigKey("Atlas (Plan Executor)")).toBe("atlas")
})
it("passes through lowercase config keys unchanged", () => { it("passes through lowercase config keys unchanged", () => {
// given lowercase config key "prometheus" // given lowercase config key "prometheus"
// when getAgentConfigKey called // when getAgentConfigKey called
@@ -195,16 +203,16 @@ describe("getAgentListDisplayName", () => {
describe("normalizeAgentForPrompt", () => { describe("normalizeAgentForPrompt", () => {
it("strips core UI ordering prefixes back to canonical display names", () => { it("strips core UI ordering prefixes back to canonical display names", () => {
expect(normalizeAgentForPrompt(getAgentListDisplayName("sisyphus"))).toBe("Sisyphus - Ultraworker") expect(normalizeAgentForPrompt(getAgentListDisplayName("sisyphus"))).toBe("Sisyphus (Ultraworker)")
expect(normalizeAgentForPrompt(getAgentListDisplayName("hephaestus"))).toBe("Hephaestus - Deep Agent") expect(normalizeAgentForPrompt(getAgentListDisplayName("hephaestus"))).toBe("Hephaestus (Deep Agent)")
expect(normalizeAgentForPrompt(getAgentListDisplayName("prometheus"))).toBe("Prometheus - Plan Builder") expect(normalizeAgentForPrompt(getAgentListDisplayName("prometheus"))).toBe("Prometheus (Plan Builder)")
expect(normalizeAgentForPrompt(getAgentListDisplayName("atlas"))).toBe("Atlas - Plan Executor") expect(normalizeAgentForPrompt(getAgentListDisplayName("atlas"))).toBe("Atlas (Plan Executor)")
}) })
}) })
describe("normalizeAgentForPromptKey", () => { describe("normalizeAgentForPromptKey", () => {
it("converts built-in display names to config keys", () => { it("converts built-in display names to config keys", () => {
expect(normalizeAgentForPromptKey("Sisyphus - Ultraworker")).toBe("sisyphus") expect(normalizeAgentForPromptKey("Sisyphus (Ultraworker)")).toBe("sisyphus")
}) })
it("preserves custom agents", () => { it("preserves custom agents", () => {
@@ -236,4 +244,15 @@ describe("AGENT_DISPLAY_NAMES", () => {
// then contains all expected mappings // then contains all expected mappings
expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings) expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings)
}) })
it("all display names must be HTTP-header-safe (no parentheses)", () => {
// given all agent display names
const httpHeaderUnsafe = /[()]/
// when checking each display name
for (const [key, displayName] of Object.entries(AGENT_DISPLAY_NAMES)) {
// then none should contain parentheses
expect(httpHeaderUnsafe.test(displayName)).toBe(false)
}
})
}) })
+31 -2
View File
@@ -1,7 +1,13 @@
/** /**
* Agent config keys to display names mapping. * Agent config keys to display names mapping.
* Config keys are lowercase (e.g., "sisyphus", "atlas"). * Config keys are lowercase (e.g., "sisyphus", "atlas").
* Display names include suffixes for UI/logs (e.g., "Sisyphus (Ultraworker)"). * Display names include suffixes for UI/logs (e.g., "Sisyphus - Ultraworker").
*
* IMPORTANT: Display names MUST NOT contain parentheses or other characters
* that are invalid in HTTP header values per RFC 7230. OpenCode passes the
* agent name in the `x-opencode-agent-name` header, and parentheses cause
* header validation failures that prevent agents from appearing in the UI
* type selector dropdown. Use ` - ` (space-dash-space) instead of `(...)`.
*/ */
export const AGENT_DISPLAY_NAMES: Record<string, string> = { export const AGENT_DISPLAY_NAMES: Record<string, string> = {
sisyphus: "Sisyphus - Ultraworker", sisyphus: "Sisyphus - Ultraworker",
@@ -62,14 +68,29 @@ const REVERSE_DISPLAY_NAMES: Record<string, string> = Object.fromEntries(
Object.entries(AGENT_DISPLAY_NAMES).map(([key, displayName]) => [displayName.toLowerCase(), key]), Object.entries(AGENT_DISPLAY_NAMES).map(([key, displayName]) => [displayName.toLowerCase(), key]),
) )
// Legacy parenthesized display names for backward compatibility.
// Old configs/sessions may reference these names; resolve them to config keys.
const LEGACY_DISPLAY_NAMES: Record<string, string> = {
"sisyphus (ultraworker)": "sisyphus",
"hephaestus (deep agent)": "hephaestus",
"prometheus (plan builder)": "prometheus",
"atlas (plan executor)": "atlas",
"metis (plan consultant)": "metis",
"momus (plan critic)": "momus",
"athena (council)": "athena",
"athena-junior (council)": "athena-junior",
}
/** /**
* Resolve an agent name (display name or config key) to its lowercase config key. * Resolve an agent name (display name or config key) to its lowercase config key.
* "Atlas (Plan Executor)" "atlas", "atlas" → "atlas", "unknown" → "unknown" * "Atlas - Plan Executor" -> "atlas", "Atlas (Plan Executor)" -> "atlas", "atlas" -> "atlas"
*/ */
export function getAgentConfigKey(agentName: string): string { export function getAgentConfigKey(agentName: string): string {
const lower = stripAgentListSortPrefix(agentName).toLowerCase() const lower = stripAgentListSortPrefix(agentName).toLowerCase()
const reversed = REVERSE_DISPLAY_NAMES[lower] const reversed = REVERSE_DISPLAY_NAMES[lower]
if (reversed !== undefined) return reversed if (reversed !== undefined) return reversed
const legacy = LEGACY_DISPLAY_NAMES[lower]
if (legacy !== undefined) return legacy
if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower
return lower return lower
} }
@@ -95,6 +116,10 @@ export function normalizeAgentForPrompt(agentName: string | undefined): string |
if (reversed !== undefined) { if (reversed !== undefined) {
return AGENT_DISPLAY_NAMES[reversed] ?? trimmed return AGENT_DISPLAY_NAMES[reversed] ?? trimmed
} }
const legacy = LEGACY_DISPLAY_NAMES[lower]
if (legacy !== undefined) {
return AGENT_DISPLAY_NAMES[legacy] ?? trimmed
}
if (AGENT_DISPLAY_NAMES[lower] !== undefined) { if (AGENT_DISPLAY_NAMES[lower] !== undefined) {
return AGENT_DISPLAY_NAMES[lower] return AGENT_DISPLAY_NAMES[lower]
} }
@@ -117,6 +142,10 @@ export function normalizeAgentForPromptKey(agentName: string | undefined): strin
if (reversed !== undefined) { if (reversed !== undefined) {
return reversed return reversed
} }
const legacy = LEGACY_DISPLAY_NAMES[lower]
if (legacy !== undefined) {
return legacy
}
if (AGENT_DISPLAY_NAMES[lower] !== undefined) { if (AGENT_DISPLAY_NAMES[lower] !== undefined) {
return lower return lower
} }
+9 -9
View File
@@ -148,36 +148,36 @@ describe("migrateAgentNames", () => {
}) })
test("migrates Prometheus variants to lowercase", () => { test("migrates Prometheus variants to lowercase", () => {
// given agents config with "Prometheus (Planner)" key // given agents config with "Prometheus - Plan Builder" key
// when migrateAgentNames called // when migrateAgentNames called
// then key becomes "prometheus" // then key becomes "prometheus"
const agents = { "Prometheus (Planner)": { model: "test" } } const agents = { "Prometheus - Plan Builder": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents) const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true) expect(changed).toBe(true)
expect(migrated["prometheus"]).toEqual({ model: "test" }) expect(migrated["prometheus"]).toEqual({ model: "test" })
expect(migrated["Prometheus (Planner)"]).toBeUndefined() expect(migrated["Prometheus - Plan Builder"]).toBeUndefined()
}) })
test("migrates Metis variants to lowercase", () => { test("migrates Metis variants to lowercase", () => {
// given agents config with "Metis (Plan Consultant)" key // given agents config with "Metis - Plan Consultant" key
// when migrateAgentNames called // when migrateAgentNames called
// then key becomes "metis" // then key becomes "metis"
const agents = { "Metis (Plan Consultant)": { model: "test" } } const agents = { "Metis - Plan Consultant": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents) const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true) expect(changed).toBe(true)
expect(migrated["metis"]).toEqual({ model: "test" }) expect(migrated["metis"]).toEqual({ model: "test" })
expect(migrated["Metis (Plan Consultant)"]).toBeUndefined() expect(migrated["Metis - Plan Consultant"]).toBeUndefined()
}) })
test("migrates Momus variants to lowercase", () => { test("migrates Momus variants to lowercase", () => {
// given agents config with "Momus (Plan Reviewer)" key // given agents config with "Momus - Plan Critic" key
// when migrateAgentNames called // when migrateAgentNames called
// then key becomes "momus" // then key becomes "momus"
const agents = { "Momus (Plan Reviewer)": { model: "test" } } const agents = { "Momus - Plan Critic": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents) const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true) expect(changed).toBe(true)
expect(migrated["momus"]).toEqual({ model: "test" }) expect(migrated["momus"]).toEqual({ model: "test" })
expect(migrated["Momus (Plan Reviewer)"]).toBeUndefined() expect(migrated["Momus - Plan Critic"]).toBeUndefined()
}) })
test("migrates Sisyphus-Junior to lowercase", () => { test("migrates Sisyphus-Junior to lowercase", () => {
+6 -6
View File
@@ -10,7 +10,7 @@ export const AGENT_NAME_MAP: Record<string, string> = {
"omo-plan": "prometheus", "omo-plan": "prometheus",
"Planner-Sisyphus": "prometheus", "Planner-Sisyphus": "prometheus",
"planner-sisyphus": "prometheus", "planner-sisyphus": "prometheus",
"Prometheus (Planner)": "prometheus", "Prometheus - Plan Builder": "prometheus",
prometheus: "prometheus", prometheus: "prometheus",
// Atlas variants → "atlas" // Atlas variants → "atlas"
@@ -20,11 +20,11 @@ export const AGENT_NAME_MAP: Record<string, string> = {
// Metis variants → "metis" // Metis variants → "metis"
"plan-consultant": "metis", "plan-consultant": "metis",
"Metis (Plan Consultant)": "metis", "Metis - Plan Consultant": "metis",
metis: "metis", metis: "metis",
// Momus variants → "momus" // Momus variants → "momus"
"Momus (Plan Reviewer)": "momus", "Momus - Plan Critic": "momus",
momus: "momus", momus: "momus",
// Sisyphus-Junior → "sisyphus-junior" // Sisyphus-Junior → "sisyphus-junior"
@@ -45,9 +45,9 @@ export const BUILTIN_AGENT_NAMES = new Set([
"librarian", "librarian",
"explore", "explore",
"multimodal-looker", "multimodal-looker",
"metis", // was "Metis (Plan Consultant)" "metis", // was "Metis - Plan Consultant"
"momus", // was "Momus (Plan Reviewer)" "momus", // was "Momus - Plan Critic"
"prometheus", // was "Prometheus (Planner)" "prometheus", // was "Prometheus - Plan Builder"
"atlas", // was "Atlas" "atlas", // was "Atlas"
"build", "build",
]) ])