From 4314a3e4826b76d778a4855fadb158345c2153cd Mon Sep 17 00:00:00 2001 From: Sami Jawhar Date: Sun, 29 Mar 2026 04:02:13 +0000 Subject: [PATCH] fix: permanently resolve agent name duplication yo-yo bug --- .../claude-code-session-state/state.test.ts | 13 +++++ .../claude-code-session-state/state.ts | 10 +++- .../boulder-continuation-injector.test.ts | 54 +++++++++++++++++++ .../atlas/boulder-continuation-injector.ts | 4 +- src/hooks/atlas/index.test.ts | 35 +++++++++++- .../agent-key-remapper.test.ts | 38 +++++++++---- src/plugin-handlers/agent-key-remapper.ts | 6 ++- src/shared/agent-display-names.test.ts | 3 ++ src/shared/agent-display-names.ts | 33 +++++++++++- 9 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 src/hooks/atlas/boulder-continuation-injector.test.ts diff --git a/src/features/claude-code-session-state/state.test.ts b/src/features/claude-code-session-state/state.test.ts index 82018316c..7a08f676d 100644 --- a/src/features/claude-code-session-state/state.test.ts +++ b/src/features/claude-code-session-state/state.test.ts @@ -6,6 +6,8 @@ import { updateSessionAgent, setMainSession, getMainSessionID, + registerAgentName, + isAgentRegistered, _resetForTesting, } from "./state" @@ -102,6 +104,17 @@ describe("claude-code-session-state", () => { }) }) + describe("agent registration", () => { + test("should register config-key lookup when given a display name", () => { + // given + registerAgentName("Atlas (Plan Executor)") + + // when / then + expect(isAgentRegistered("atlas")).toBe(true) + expect(isAgentRegistered("Atlas (Plan Executor)")).toBe(true) + }) + }) + describe("prometheus-md-only integration scenario", () => { test("should correctly identify Prometheus agent for permission checks", () => { // given - Prometheus session diff --git a/src/features/claude-code-session-state/state.ts b/src/features/claude-code-session-state/state.ts index d8f3298cf..f0a167b06 100644 --- a/src/features/claude-code-session-state/state.ts +++ b/src/features/claude-code-session-state/state.ts @@ -1,3 +1,5 @@ +import { getAgentConfigKey } from "../../shared/agent-display-names" + export const subagentSessions = new Set() export const syncSubagentSessions = new Set() @@ -14,7 +16,13 @@ export function getMainSessionID(): string | undefined { const registeredAgentNames = new Set() export function registerAgentName(name: string): void { - registeredAgentNames.add(name.toLowerCase()) + const normalizedName = name.toLowerCase() + registeredAgentNames.add(normalizedName) + + const configKey = getAgentConfigKey(name).toLowerCase() + if (configKey !== normalizedName) { + registeredAgentNames.add(configKey) + } } export function isAgentRegistered(name: string): boolean { diff --git a/src/hooks/atlas/boulder-continuation-injector.test.ts b/src/hooks/atlas/boulder-continuation-injector.test.ts new file mode 100644 index 000000000..5eb6e90c1 --- /dev/null +++ b/src/hooks/atlas/boulder-continuation-injector.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state" +import { injectBoulderContinuation } from "./boulder-continuation-injector" + +describe("injectBoulderContinuation", () => { + beforeEach(() => { + // given + _resetForTesting() + }) + + afterEach(() => { + // then + _resetForTesting() + }) + + test("normalizes config-key agent to display-name for promptAsync", async () => { + // given + registerAgentName("atlas") + const promptAsyncMock = mock(async (_request: unknown) => undefined) + const messagesMock = mock(async () => ({ data: [] })) + + const ctx = { + directory: "/tmp", + client: { + session: { + messages: messagesMock, + promptAsync: promptAsyncMock, + }, + }, + } as unknown as PluginInput + + // when + await injectBoulderContinuation({ + ctx, + sessionID: "ses_test_123", + planName: "test-plan", + remaining: 1, + total: 2, + agent: "atlas", + sessionState: { promptFailureCount: 0 }, + }) + + // then + expect(promptAsyncMock).toHaveBeenCalledTimes(1) + expect(promptAsyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent: "Atlas (Plan Executor)", + }), + }), + ) + }) +}) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index f72196f63..7d64c6175 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -1,9 +1,9 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" import { isAgentRegistered } from "../../features/claude-code-session-state" +import { normalizeAgentForPrompt } from "../../shared/agent-display-names" import { log } from "../../shared/logger" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" -import { getAgentConfigKey } from "../../shared/agent-display-names" import { HOOK_NAME } from "./hook-name" import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" import { resolveRecentPromptContextForSession } from "./recent-model-resolver" @@ -73,7 +73,7 @@ export async function injectBoulderContinuation(input: { await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - agent: getAgentConfigKey(continuationAgent), + agent: normalizeAgentForPrompt(continuationAgent) ?? continuationAgent, ...(promptContext.model !== undefined ? { model: promptContext.model } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), parts: [createInternalAgentTextPart(prompt)], diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 8b19c7539..458853915 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1679,9 +1679,42 @@ session_id: ses_untrusted_999 // then - should call prompt for sisyphus expect(mockInput._promptMock).toHaveBeenCalled() const callArgs = mockInput._promptMock.mock.calls[0][0] - expect(callArgs.body.agent).toBe("sisyphus") + expect(callArgs.body.agent).toBe("Sisyphus (Ultraworker)") }) + test("should preserve display-name agent in continuation prompt when boulder agent uses display form", async () => { + // given - boulder state uses display-form agent name + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + agent: "Atlas (Plan Executor)", + } + writeBoulderState(TEST_DIR, state) + registerAgentName("Atlas (Plan Executor)") + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.body.agent).toBe("Atlas (Plan Executor)") + expect(callArgs.body.agent).not.toBe("atlas") + }) + test("should debounce rapid continuation injections (prevent infinite loop)", async () => { // given - boulder state with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/plugin-handlers/agent-key-remapper.test.ts b/src/plugin-handlers/agent-key-remapper.test.ts index 49818276c..fea227ea3 100644 --- a/src/plugin-handlers/agent-key-remapper.test.ts +++ b/src/plugin-handlers/agent-key-remapper.test.ts @@ -12,10 +12,10 @@ describe("remapAgentKeysToDisplayNames", () => { // when remapping const result = remapAgentKeysToDisplayNames(agents) - // then known agents get display name keys and config key aliases + // then known agents get display name keys only expect(result["Sisyphus (Ultraworker)"]).toBeDefined() expect(result["oracle"]).toBeDefined() - expect(result["sisyphus"]).toBeDefined() + expect(result["sisyphus"]).toBeUndefined() }) it("preserves unknown agent keys unchanged", () => { @@ -38,6 +38,7 @@ describe("remapAgentKeysToDisplayNames", () => { hephaestus: {}, prometheus: {}, atlas: {}, + athena: {}, metis: {}, momus: {}, "sisyphus-junior": {}, @@ -46,20 +47,37 @@ describe("remapAgentKeysToDisplayNames", () => { // when remapping const result = remapAgentKeysToDisplayNames(agents) - // then all get display name keys with config key aliases preserved + // then all get display name keys expect(result["Sisyphus (Ultraworker)"]).toBeDefined() - expect(result["sisyphus"]).toBeDefined() + expect(result["sisyphus"]).toBeUndefined() expect(result["Hephaestus (Deep Agent)"]).toBeDefined() - expect(result["hephaestus"]).toBeDefined() + expect(result["hephaestus"]).toBeUndefined() expect(result["Prometheus (Plan Builder)"]).toBeDefined() - expect(result["prometheus"]).toBeDefined() + expect(result["prometheus"]).toBeUndefined() expect(result["Atlas (Plan Executor)"]).toBeDefined() - expect(result["atlas"]).toBeDefined() + expect(result["atlas"]).toBeUndefined() + expect(result["Athena (Council)"]).toBeDefined() + expect(result["athena"]).toBeUndefined() expect(result["Metis (Plan Consultant)"]).toBeDefined() - expect(result["metis"]).toBeDefined() + expect(result["metis"]).toBeUndefined() expect(result["Momus (Plan Critic)"]).toBeDefined() - expect(result["momus"]).toBeDefined() + expect(result["momus"]).toBeUndefined() expect(result["Sisyphus-Junior"]).toBeDefined() - expect(result["sisyphus-junior"]).toBeDefined() + expect(result["sisyphus-junior"]).toBeUndefined() + }) + + it("does not emit both config and display keys for remapped agents", () => { + // given one remapped agent + const agents = { + sisyphus: { prompt: "test", mode: "primary" }, + } + + // when remapping + const result = remapAgentKeysToDisplayNames(agents) + + // then only display key is emitted + expect(Object.keys(result)).toEqual(["Sisyphus (Ultraworker)"]) + expect(result["Sisyphus (Ultraworker)"]).toBeDefined() + expect(result["sisyphus"]).toBeUndefined() }) }) diff --git a/src/plugin-handlers/agent-key-remapper.ts b/src/plugin-handlers/agent-key-remapper.ts index c60bcfcb9..57803df18 100644 --- a/src/plugin-handlers/agent-key-remapper.ts +++ b/src/plugin-handlers/agent-key-remapper.ts @@ -9,7 +9,11 @@ export function remapAgentKeysToDisplayNames( const displayName = AGENT_DISPLAY_NAMES[key] if (displayName && displayName !== key) { result[displayName] = value - result[key] = value + // Regression guard: do not also assign result[key]. + // This line was repeatedly re-added and caused duplicate agent rows in the UI. + // Runtime callers that previously depended on config-key aliases were fixed in: + // - hooks/atlas/boulder-continuation-injector.ts (prompt agent normalization) + // - features/claude-code-session-state/state.ts (dual registration for display + config forms) } else { result[key] = value } diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 3d7276a1b..5419e46ce 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -187,10 +187,13 @@ describe("AGENT_DISPLAY_NAMES", () => { "sisyphus-junior": "Sisyphus-Junior", metis: "Metis (Plan Consultant)", momus: "Momus (Plan Critic)", + athena: "Athena (Council)", + "athena-junior": "Athena-Junior (Council)", oracle: "oracle", librarian: "librarian", explore: "explore", "multimodal-looker": "multimodal-looker", + "council-member": "council-member", } // when checking the constant diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index a0bda2241..57b9be27d 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -11,10 +11,13 @@ export const AGENT_DISPLAY_NAMES: Record = { "sisyphus-junior": "Sisyphus-Junior", metis: "Metis (Plan Consultant)", momus: "Momus (Plan Critic)", + athena: "Athena (Council)", + "athena-junior": "Athena-Junior (Council)", oracle: "oracle", librarian: "librarian", explore: "explore", "multimodal-looker": "multimodal-looker", + "council-member": "council-member", } /** @@ -51,4 +54,32 @@ export function getAgentConfigKey(agentName: string): string { if (reversed !== undefined) return reversed if (AGENT_DISPLAY_NAMES[lower] !== undefined) return lower return lower -} \ No newline at end of file +} + +/** + * Normalize an agent name for prompt APIs. + * - Known display names -> canonical display names + * - Known config keys (any case) -> canonical display names + * - Unknown/custom names -> preserved as-is (trimmed) + */ +export function normalizeAgentForPrompt(agentName: string | undefined): string | undefined { + if (typeof agentName !== "string") { + return undefined + } + + const trimmed = agentName.trim() + if (!trimmed) { + return undefined + } + + const lower = trimmed.toLowerCase() + const reversed = REVERSE_DISPLAY_NAMES[lower] + if (reversed !== undefined) { + return AGENT_DISPLAY_NAMES[reversed] ?? trimmed + } + if (AGENT_DISPLAY_NAMES[lower] !== undefined) { + return AGENT_DISPLAY_NAMES[lower] + } + + return trimmed +}