From 06b825dd74a41bc610da86418c73eeee626a53f1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 16:18:26 +0900 Subject: [PATCH] fix(start-work): reuse registered opencode agent names --- .../claude-code-session-state/state.test.ts | 10 ++++++++++ src/features/claude-code-session-state/state.ts | 17 +++++++++++++++++ .../atlas/boulder-continuation-injector.ts | 9 +++++++-- .../compaction-context-injector/recovery.ts | 8 ++++++-- src/hooks/no-hephaestus-non-gpt/hook.ts | 14 ++++++++------ src/hooks/no-sisyphus-gpt/hook.ts | 14 ++++++++------ src/hooks/runtime-fallback/auto-retry.ts | 6 +++--- src/hooks/start-work/start-work-hook.ts | 3 ++- .../continuation-injection.test.ts | 4 ++-- .../continuation-injection.ts | 10 +++++++--- .../command-config-handler.test.ts | 13 ++++++++----- src/plugin-handlers/command-config-handler.ts | 4 ++-- src/plugin-handlers/config-handler.test.ts | 4 ++-- 13 files changed, 82 insertions(+), 34 deletions(-) diff --git a/src/features/claude-code-session-state/state.test.ts b/src/features/claude-code-session-state/state.test.ts index 367ad6d3e..69c482b40 100644 --- a/src/features/claude-code-session-state/state.test.ts +++ b/src/features/claude-code-session-state/state.test.ts @@ -10,6 +10,7 @@ import { getMainSessionID, registerAgentName, isAgentRegistered, + resolveRegisteredAgentName, _resetForTesting, } from "./state" @@ -140,6 +141,15 @@ describe("claude-code-session-state", () => { expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true) }) + test("should resolve config keys back to the registered raw agent name", () => { + // given + registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + + // when / then + expect(resolveRegisteredAgentName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + expect(resolveRegisteredAgentName("Atlas - Plan Executor")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + }) + describe("#given atlas display name with zero-width prefix", () => { describe("#when checking registration without the zero-width prefix", () => { test("#then it treats the display name as registered", () => { diff --git a/src/features/claude-code-session-state/state.ts b/src/features/claude-code-session-state/state.ts index f044b4ec6..496d655fd 100644 --- a/src/features/claude-code-session-state/state.ts +++ b/src/features/claude-code-session-state/state.ts @@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined { } const registeredAgentNames = new Set() +const registeredAgentAliases = new Map() const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g @@ -28,10 +29,16 @@ function normalizeStoredAgentName(name: string): string { export function registerAgentName(name: string): void { const normalizedName = normalizeRegisteredAgentName(name) registeredAgentNames.add(normalizedName) + if (!registeredAgentAliases.has(normalizedName)) { + registeredAgentAliases.set(normalizedName, name) + } const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name)) if (configKey !== normalizedName) { registeredAgentNames.add(configKey) + if (!registeredAgentAliases.has(configKey)) { + registeredAgentAliases.set(configKey, name) + } } } @@ -39,6 +46,15 @@ export function isAgentRegistered(name: string): boolean { return registeredAgentNames.has(normalizeRegisteredAgentName(name)) } +export function resolveRegisteredAgentName(name: string | undefined): string | undefined { + if (typeof name !== "string") { + return undefined + } + + const normalizedName = normalizeRegisteredAgentName(name) + return registeredAgentAliases.get(normalizedName) ?? normalizeStoredAgentName(name) +} + /** @internal For testing only */ export function _resetForTesting(): void { _mainSessionID = undefined @@ -46,6 +62,7 @@ export function _resetForTesting(): void { syncSubagentSessions.clear() sessionAgentMap.clear() registeredAgentNames.clear() + registeredAgentAliases.clear() } const sessionAgentMap = new Map() diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index ca4ee146e..8f3e1a57d 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -1,6 +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 { + isAgentRegistered, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { HOOK_NAME } from "./hook-name" @@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: { `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + preferredSessionContext + worktreeContext - const continuationAgent = (agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined))?.replace(/\u200B/g, "") + const continuationAgent = resolveRegisteredAgentName( + agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined), + ) if (!continuationAgent || !isAgentRegistered(continuationAgent)) { log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, { diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index 35b8a89de..31040d35f 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -1,4 +1,7 @@ -import { updateSessionAgent } from "../../features/claude-code-session-state" +import { + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { getCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" @@ -66,6 +69,7 @@ export function createRecoveryLogic( checkpointWithAgent, currentPromptConfig, ) + const launchAgent = resolveRegisteredAgentName(expectedPromptConfig.agent) const model = expectedPromptConfig.model const tools = expectedPromptConfig.tools @@ -81,7 +85,7 @@ export function createRecoveryLogic( path: { id: sessionID }, body: { noReply: true, - agent: expectedPromptConfig.agent, + agent: launchAgent ?? expectedPromptConfig.agent, ...(model ? { model } : {}), ...(tools ? { tools } : {}), parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)], diff --git a/src/hooks/no-hephaestus-non-gpt/hook.ts b/src/hooks/no-hephaestus-non-gpt/hook.ts index afce7ba9c..66efed424 100644 --- a/src/hooks/no-hephaestus-non-gpt/hook.ts +++ b/src/hooks/no-hephaestus-non-gpt/hook.ts @@ -1,8 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { isGptModel } from "../../agents/types" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { log } from "../../shared" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentConfigKey } from "../../shared/agent-display-names" const TOAST_TITLE = "NEVER Use Hephaestus with Non-GPT" const TOAST_MESSAGE = [ @@ -10,8 +14,6 @@ const TOAST_MESSAGE = [ "Hephaestus is trash without GPT.", "For Claude/Kimi/GLM models, always use Sisyphus.", ].join("\n") -const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") - type NoHephaestusNonGptHookOptions = { allowNonGptModel?: boolean } @@ -54,9 +56,9 @@ export function createNoHephaestusNonGptHook( if (allowNonGptModel) { return } - input.agent = "sisyphus" + input.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus" if (output?.message) { - output.message.agent = "sisyphus" + output.message.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus" } updateSessionAgent(input.sessionID, "sisyphus") } diff --git a/src/hooks/no-sisyphus-gpt/hook.ts b/src/hooks/no-sisyphus-gpt/hook.ts index 65ab8d113..fa1b53ebd 100644 --- a/src/hooks/no-sisyphus-gpt/hook.ts +++ b/src/hooks/no-sisyphus-gpt/hook.ts @@ -1,8 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { isGptModel, isGpt5_4Model } from "../../agents/types" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { log } from "../../shared" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentConfigKey } from "../../shared/agent-display-names" const TOAST_TITLE = "NEVER Use Sisyphus with GPT" const TOAST_MESSAGE = [ @@ -10,8 +14,6 @@ const TOAST_MESSAGE = [ "Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).", "For GPT models (other than 5.4), always use Hephaestus.", ].join("\n") -const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") - function showToast(ctx: PluginInput, sessionID: string): void { ctx.client.tui.showToast({ body: { @@ -43,9 +45,9 @@ export function createNoSisyphusGptHook(ctx: PluginInput) { if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) { showToast(ctx, input.sessionID) - input.agent = "hephaestus" + input.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus" if (output?.message) { - output.message.agent = "hephaestus" + output.message.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus" } updateSessionAgent(input.sessionID, "hephaestus") } diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index de946af5b..cbb3be2be 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -9,7 +9,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { buildRetryModelPayload } from "./retry-model-payload" import { getLastUserRetryParts } from "./last-user-retry-parts" import { extractSessionMessages } from "./session-messages" -import { getAgentDisplayName } from "../../shared/agent-display-names" +import { resolveRegisteredAgentName } from "../../features/claude-code-session-state" const SESSION_TTL_MS = 30 * 60 * 1000 @@ -133,14 +133,14 @@ export function createAutoRetryHelpers(deps: HookDeps) { }) const retryAgent = resolvedAgent ?? getSessionAgent(sessionID) + const launchAgent = resolveRegisteredAgentName(retryAgent) sessionAwaitingFallbackResult.add(sessionID) scheduleSessionFallbackTimeout(sessionID, retryAgent) await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - // Use config key to avoid HTTP header validation issues with display names - ...(retryAgent ? { agent: retryAgent } : {}), + ...(launchAgent ? { agent: launchAgent } : {}), ...retryModelPayload, parts: retryParts, }, diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index b8ad853aa..ec8a5011b 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -13,6 +13,7 @@ import { import { log } from "../../shared/logger" import { isAgentRegistered, + resolveRegisteredAgentName, updateSessionAgent, } from "../../features/claude-code-session-state" import { detectWorktreePath } from "./worktree-detector" @@ -83,7 +84,7 @@ export function createStartWorkHook(ctx: PluginInput) { : "sisyphus" updateSessionAgent(input.sessionID, activeAgent) if (output.message) { - output.message["agent"] = activeAgent + output.message["agent"] = resolveRegisteredAgentName(activeAgent) ?? activeAgent } const existingState = readBoulderState(ctx.directory) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index 514b6f15b..56dd7cb4e 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -5,7 +5,7 @@ import { injectContinuation } from "./continuation-injection" import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" describe("injectContinuation", () => { - test("normalizes built-in display names to config keys before promptAsync", async () => { + test("preserves the registered built-in agent name before promptAsync", async () => { // given let capturedAgent: string | undefined const ctx = { @@ -40,7 +40,7 @@ describe("injectContinuation", () => { }) // then - expect(capturedAgent).toBe("sisyphus") + expect(capturedAgent).toBe("Sisyphus - Ultraworker") }) test("inherits tools from resolved message info when reinjecting", async () => { diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index fdd12efc1..5844bebd2 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,7 +1,10 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { getSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { createInternalAgentTextPart, normalizeSDKResponse, @@ -127,6 +130,7 @@ export async function injectContinuation(args: { } const promptAgent = normalizeAgentForPromptKey(agentName) + const launchAgent = resolveRegisteredAgentName(agentName) if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) @@ -168,7 +172,7 @@ ${todoList}` try { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, - agent: promptAgent, + agent: launchAgent ?? promptAgent, model, incompleteCount: freshIncompleteCount, }) @@ -183,7 +187,7 @@ ${todoList}` await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - agent: promptAgent, + agent: launchAgent ?? promptAgent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 74267b069..41836dc6b 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,7 +5,10 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; -import { getAgentDisplayName } from "../shared/agent-display-names"; +import { + getAgentDisplayName, + getAgentListDisplayName, +} from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -97,7 +100,7 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); - test("normalizes Atlas command agents to the canonical display name used for native routing", async () => { + test("normalizes Atlas command agents to the exported list key used by opencode command routing", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -119,10 +122,10 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); - test("normalizes legacy display-name command agents to the canonical display name", async () => { + test("normalizes legacy display-name command agents to the exported list key", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -144,6 +147,6 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 86fdcfe26..471e4df52 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,7 +1,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { getAgentConfigKey, - getAgentDisplayName, + getAgentListDisplayName, } from "../shared/agent-display-names"; import { loadUserCommands, @@ -99,7 +99,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent)); + cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent)); } } } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 76e468f51..1d9324f9e 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1251,7 +1251,7 @@ describe("config-handler plugin loading error boundary (#1559)", () => { }) describe("command agent routing coherence", () => { - test("keeps start-work aligned with the canonical Atlas display name", async () => { + test("keeps start-work aligned with the exported Atlas list key opencode matches exactly", async () => { //#given const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { mockResolvedValue: (value: Record) => void @@ -1291,7 +1291,7 @@ describe("command agent routing coherence", () => { const agentConfig = config.agent as Record const commandConfig = config.command as Record expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas")) - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")) + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")) }) })