fix: permanently resolve agent name duplication yo-yo bug

This commit is contained in:
Sami Jawhar
2026-03-29 04:02:13 +00:00
parent 3dc11ea620
commit 4314a3e482
9 changed files with 180 additions and 16 deletions
@@ -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
@@ -1,3 +1,5 @@
import { getAgentConfigKey } from "../../shared/agent-display-names"
export const subagentSessions = new Set<string>()
export const syncSubagentSessions = new Set<string>()
@@ -14,7 +16,13 @@ export function getMainSessionID(): string | undefined {
const registeredAgentNames = new Set<string>()
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 {
@@ -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)",
}),
}),
)
})
})
@@ -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)],
+34 -1
View File
@@ -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")
+28 -10
View File
@@ -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()
})
})
+5 -1
View File
@@ -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
}
+3
View File
@@ -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
+32 -1
View File
@@ -11,10 +11,13 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
"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
}
}
/**
* 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
}