fix: permanently resolve agent name duplication yo-yo bug
This commit is contained in:
@@ -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)],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user